mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-15 21:48:27 +00:00
1614 lines
64 KiB
Rust
1614 lines
64 KiB
Rust
use std::convert::Infallible;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
use std::time::{Duration, Instant};
|
|
use async_stream::stream;
|
|
use axum::body::Body;
|
|
use axum::http::header::CONTENT_TYPE;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Json;
|
|
use bytes::Bytes;
|
|
use log::{debug, error, info, trace, warn};
|
|
use once_cell::sync::Lazy;
|
|
use pdfium_render::prelude::Pdfium;
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent};
|
|
use tauri::path::PathResolver;
|
|
use tauri::webview::{NewWindowResponse, PageLoadEvent};
|
|
use tauri::{WebviewWindow, WebviewWindowBuilder};
|
|
use tauri::PhysicalPosition;
|
|
use tauri_plugin_updater::{UpdaterExt, Update};
|
|
use tauri_plugin_opener::OpenerExt;
|
|
use tokio::sync::broadcast;
|
|
use tokio::time;
|
|
use crate::app_access::hand_over_session_cookie;
|
|
use crate::clipboard::shutdown_clipboard;
|
|
use crate::data_restore::apply_pending_restore;
|
|
use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server};
|
|
use crate::environment::{
|
|
installation_kind, is_prod, is_dev, is_flatpak, InstallationKind, CONFIG_DIRECTORY,
|
|
DATA_DIRECTORY, FLATPAK_LIBRARY_DIRECTORY,
|
|
};
|
|
use crate::log::switch_to_file_logging;
|
|
use crate::pdfium::PDFIUM_LIB_PATH;
|
|
use crate::qdrant_edge_database::{start_qdrant_edge_database, stop_qdrant_edge_database};
|
|
use crate::global_shortcuts::{RegisterShortcutRequest, ShortcutResponse};
|
|
|
|
#[cfg(debug_assertions)]
|
|
use crate::dotnet::create_startup_env_file;
|
|
use crate::tokenizer::set_default_tokenizer_path;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
use webkit2gtk::glib::Cast;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
use webkit2gtk::{PermissionRequestExt, UserMediaPermissionRequestExt};
|
|
|
|
/// The Tauri main window.
|
|
pub static MAIN_WINDOW: Lazy<Mutex<Option<WebviewWindow>>> = Lazy::new(|| Mutex::new(None));
|
|
|
|
/// The update response coming from the Tauri updater.
|
|
static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<Update>>> = Lazy::new(|| Mutex::new(None));
|
|
|
|
/// The event broadcast sender for Tauri events.
|
|
static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None));
|
|
|
|
/// The shortest interval between two drag-over events.
|
|
///
|
|
/// A native drag emits one such event per mouse move. Every one of them travels into the app, where
|
|
/// it decides which drop zone lights up, so an unthrottled drag would render the whole page dozens
|
|
/// of times per second. A tenth of a second still follows the cursor closely enough.
|
|
const DRAG_OVER_EVENT_INTERVAL: Duration = Duration::from_millis(100);
|
|
|
|
/// When we sent the last drag-over event, used to protect Blazor from render storms.
|
|
static LAST_DRAG_OVER_SENT: Lazy<Mutex<Option<Instant>>> = Lazy::new(|| Mutex::new(None));
|
|
|
|
/// Stores the localhost origin of the Blazor app after the .NET server is ready.
|
|
static APPROVED_APP_URL: Lazy<Mutex<Option<tauri::Url>>> = Lazy::new(|| Mutex::new(None));
|
|
|
|
/// Starts the Tauri app.
|
|
pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
|
|
info!("Starting Tauri app...");
|
|
|
|
// Create the event broadcast channel:
|
|
let (event_sender, root_event_receiver) = broadcast::channel(100);
|
|
|
|
// Save a copy of the event broadcast sender for later use:
|
|
*EVENT_BROADCAST.lock().unwrap() = Some(event_sender.clone());
|
|
|
|
// When the last receiver is dropped, we lose the ability to send events.
|
|
// Therefore, we spawn a task that keeps the root receiver alive:
|
|
tauri::async_runtime::spawn(async move {
|
|
let mut root_receiver = root_event_receiver;
|
|
loop {
|
|
match root_receiver.recv().await {
|
|
Ok(event) => {
|
|
debug!(Source = "Tauri"; "Tauri event received: location=root receiver , event={event:?}");
|
|
},
|
|
|
|
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
|
warn!(Source = "Tauri"; "Root event receiver lagged, skipped {skipped} messages.");
|
|
},
|
|
|
|
Err(broadcast::error::RecvError::Closed) => {
|
|
warn!(Source = "Tauri"; "Root event receiver channel closed.");
|
|
return;
|
|
},
|
|
}
|
|
}
|
|
});
|
|
|
|
let app = tauri::Builder::default()
|
|
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
|
info!(Source = "Tauri"; "Prevented second app instance from starting. cwd='{cwd}', args={args:?}");
|
|
|
|
let Some(window) = app.get_webview_window("main") else {
|
|
warn!(Source = "Tauri"; "Second app instance was blocked, but the main window was not available for activation.");
|
|
return;
|
|
};
|
|
|
|
if let Err(error) = window.show() {
|
|
warn!(Source = "Tauri"; "Failed to show main window after second app start: {error}");
|
|
}
|
|
|
|
if let Err(error) = window.unminimize() {
|
|
warn!(Source = "Tauri"; "Failed to unminimize main window after second app start: {error}");
|
|
}
|
|
|
|
if let Err(error) = window.set_focus() {
|
|
warn!(Source = "Tauri"; "Failed to focus main window after second app start: {error}");
|
|
}
|
|
}))
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_shell::init())
|
|
//
|
|
// The link script of the opener plugin opens every link clicked while holding Shift or
|
|
// Ctrl in the system browser, links of the app included. Our own script replaces it,
|
|
// see link_guard.js:
|
|
//
|
|
.plugin(tauri_plugin_opener::Builder::new().open_js_links_on_click(false).build())
|
|
.plugin(
|
|
tauri::plugin::Builder::<tauri::Wry, ()>::new("external-link-handler")
|
|
.js_init_script(include_str!("link_guard.js"))
|
|
.on_navigation(|webview, url| {
|
|
let current_url = webview.url().ok();
|
|
if !should_open_in_system_browser(url, approved_app_url().as_ref(), current_url.as_ref()) {
|
|
return true;
|
|
}
|
|
|
|
open_in_system_browser(webview.app_handle(), url);
|
|
false
|
|
})
|
|
.build(),
|
|
)
|
|
.plugin(context_menu_guard())
|
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
.on_page_load(|webview, payload| {
|
|
//
|
|
// A webview may forget its zoom when it loads a page from another origin, as the page
|
|
// of the app is, see interface_zoom.rs. Measuring the screen needs the main thread,
|
|
// which this handler is not guaranteed to run on:
|
|
//
|
|
if matches!(payload.event(), PageLoadEvent::Finished)
|
|
&& let Some(window) = webview.app_handle().get_webview_window(webview.label())
|
|
{
|
|
let ui_window = window.clone();
|
|
if let Err(error) = window.run_on_main_thread(move || crate::interface_zoom::zoom_window(&ui_window)) {
|
|
warn!(Source = "Interface size"; "Failed to schedule the zoom after a page load: {error}");
|
|
}
|
|
}
|
|
})
|
|
.setup(move |app| {
|
|
|
|
//
|
|
// We create the main window ourselves rather than letting Tauri create it from the
|
|
// configuration, where "create" is false therefore. Only a window built here can get a
|
|
// handler for new windows; neither the configuration nor a plugin can set one.
|
|
//
|
|
// AI Studio shows a single window. Whatever asks for another one opens nothing in the
|
|
// app: a link with a target, a click with Shift or the middle mouse button, or
|
|
// window.open. An address outside the app goes to the system browser instead, after the
|
|
// same check as a navigation. The window shows the app as soon as a link can be clicked,
|
|
// so the approved address of the app decides, and the current address is not needed:
|
|
//
|
|
let window_config = app.config().app.windows.iter()
|
|
.find(|config| config.label == "main")
|
|
.expect("The configuration lacks the main window.");
|
|
|
|
let new_window_app_handle = app.handle().clone();
|
|
let window = WebviewWindowBuilder::from_config(app.handle(), window_config)
|
|
.expect("Failed to read the configuration of the main window.")
|
|
.on_new_window(move |url, _| {
|
|
if should_open_in_system_browser(&url, approved_app_url().as_ref(), None) {
|
|
open_in_system_browser(&new_window_app_handle, &url);
|
|
} else {
|
|
info!(Source = "Tauri"; "Denied a new window for '{url}'.");
|
|
}
|
|
|
|
NewWindowResponse::Deny
|
|
})
|
|
.build()
|
|
.expect("Failed to create the main window.");
|
|
|
|
#[cfg(target_os = "linux")]
|
|
register_linux_permission_request_handler(&window);
|
|
|
|
#[cfg(target_os = "macos")]
|
|
disable_link_previews(&window);
|
|
|
|
// Register a callback for window events, such as file drops. We have to use
|
|
// this handler in addition to the app event handler, because file drop events
|
|
// are only available in the window event handler (is a bug, cf. https://github.com/tauri-apps/tauri/issues/14338):
|
|
//
|
|
// Turning a drag and drop position into CSS pixels needs the scale factor of the
|
|
// window. We read it from this clone rather than from MAIN_WINDOW: window events are
|
|
// delivered synchronously on the main thread on macOS, so locking MAIN_WINDOW in here
|
|
// would deadlock as soon as anybody else holds that lock.
|
|
//
|
|
let event_window = window.clone();
|
|
window.on_window_event(move |event| {
|
|
|
|
//
|
|
// A window which moved may rest on another screen now, whose size calls for
|
|
// another zoom of the webview:
|
|
//
|
|
if matches!(event, WindowEvent::Moved(_) | WindowEvent::ScaleFactorChanged { .. }) {
|
|
crate::interface_zoom::follow_window(&event_window);
|
|
}
|
|
|
|
//
|
|
// Only a drag and drop event carries a position, and only that position needs the
|
|
// scale factor and the zoom. Asking the window on every window event would be
|
|
// needless work. Asking it anew for every drag is what keeps a display change
|
|
// covered: we hold no factor of our own which a moved window could leave behind.
|
|
//
|
|
let (scale_factor, zoom) = match event {
|
|
WindowEvent::DragDrop(_) => (event_window.scale_factor().unwrap_or(1.0), crate::interface_zoom::current_zoom()),
|
|
_ => (1.0, 1.0),
|
|
};
|
|
|
|
let Some(event_to_send) = Event::from_window_event(event, scale_factor, zoom) else {
|
|
return;
|
|
};
|
|
|
|
debug!(Source = "Tauri"; "Tauri event received: location=window event handler, event={event:?}");
|
|
let sender = event_sender.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
match sender.send(event_to_send) {
|
|
Ok(_) => {},
|
|
Err(error) => error!(Source = "Tauri"; "Failed to channel window event: {error}"),
|
|
}
|
|
});
|
|
});
|
|
|
|
// The screen shows in its default size from the start, long before the app sends the sizes the user chose:
|
|
crate::interface_zoom::zoom_window(&window);
|
|
|
|
// Save the main window for later access:
|
|
*MAIN_WINDOW.lock().unwrap() = Some(window);
|
|
|
|
info!(Source = "Bootloader Tauri"; "Setup is running.");
|
|
let data_path = app.path().app_local_data_dir().unwrap();
|
|
let data_path = data_path.join("data");
|
|
|
|
// Get and store the data and config directories:
|
|
let config_path = app.path().app_config_dir().unwrap();
|
|
DATA_DIRECTORY.set(data_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the data directory.")).unwrap();
|
|
CONFIG_DIRECTORY.set(config_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap();
|
|
|
|
// A restore of a backup, below, writes to the log, so the log goes into the data directory first:
|
|
info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {data_path:?}");
|
|
switch_to_file_logging(data_path.clone()).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap();
|
|
|
|
//
|
|
// A restore replaces files which the .NET server and the vector database open. It
|
|
// therefore runs before both start, and after a .NET server which outlived an earlier
|
|
// run is gone:
|
|
//
|
|
if is_prod() {
|
|
cleanup_dotnet_server();
|
|
}
|
|
|
|
apply_pending_restore(&data_path, &config_path);
|
|
|
|
if is_dev() {
|
|
#[cfg(debug_assertions)]
|
|
create_startup_env_file();
|
|
} else {
|
|
start_dotnet_server(app.handle().clone());
|
|
}
|
|
|
|
start_qdrant_edge_database(app.handle().clone());
|
|
|
|
set_default_tokenizer_path(app.handle().clone());
|
|
set_pdfium_path(app.path());
|
|
|
|
Ok(())
|
|
})
|
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
|
.build(tauri_context)
|
|
.expect("Error while running Tauri application");
|
|
|
|
// The app event handler:
|
|
app.run(|_app_handle, event| {
|
|
if !matches!(event, RunEvent::MainEventsCleared) {
|
|
debug!(Source = "Tauri"; "Tauri event received: location=app event handler , event={event:?}");
|
|
}
|
|
|
|
match event {
|
|
RunEvent::WindowEvent { event, label, .. } => {
|
|
match event {
|
|
WindowEvent::CloseRequested { .. } => {
|
|
warn!(Source = "Tauri"; "Window '{label}': close was requested.");
|
|
}
|
|
|
|
WindowEvent::Destroyed => {
|
|
warn!(Source = "Tauri"; "Window '{label}': was destroyed.");
|
|
}
|
|
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
RunEvent::ExitRequested { .. } => {
|
|
warn!(Source = "Tauri"; "Run event: exit was requested.");
|
|
shutdown_clipboard();
|
|
stop_qdrant_edge_database();
|
|
if is_prod() {
|
|
warn!("Try to stop the .NET server as well...");
|
|
stop_dotnet_server();
|
|
}
|
|
}
|
|
|
|
RunEvent::Ready => {
|
|
info!(Source = "Tauri"; "Run event: Tauri app is ready.");
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
});
|
|
|
|
warn!(Source = "Tauri"; "Tauri app was stopped.");
|
|
}
|
|
|
|
fn is_local_host(host: Option<&str>) -> bool {
|
|
matches!(host, Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]"))
|
|
}
|
|
|
|
fn is_tauri_asset_host(host: Option<&str>) -> bool {
|
|
matches!(host, Some("tauri.localhost"))
|
|
}
|
|
|
|
fn is_tauri_asset_url(url: &tauri::Url) -> bool {
|
|
matches!(url.scheme(), "http" | "https") && is_tauri_asset_host(url.host_str())
|
|
}
|
|
|
|
pub(crate) fn is_local_http_url(url: &tauri::Url) -> bool {
|
|
matches!(url.scheme(), "http" | "https") && is_local_host(url.host_str())
|
|
}
|
|
|
|
fn same_origin(left: &tauri::Url, right: &tauri::Url) -> bool {
|
|
left.scheme() == right.scheme()
|
|
&& left.host_str() == right.host_str()
|
|
&& left.port_or_known_default() == right.port_or_known_default()
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", test))]
|
|
fn should_allow_audio_capture(
|
|
approved_app_url: Option<&tauri::Url>,
|
|
current_webview_url: Option<&tauri::Url>,
|
|
requests_audio: bool,
|
|
requests_video: bool,
|
|
) -> bool {
|
|
requests_audio
|
|
&& !requests_video
|
|
&& approved_app_url.is_some_and(is_local_http_url)
|
|
&& approved_app_url
|
|
.zip(current_webview_url)
|
|
.is_some_and(|(approved, current)| same_origin(approved, current))
|
|
}
|
|
|
|
/// Switches off the preview which macOS shows when somebody force-clicks a link or looks it up.
|
|
/// wry switches it on, and Tauri offers no setting for it. The preview would show a page of the app
|
|
/// outside the window, and a click on it may open the page in the system browser, which cannot
|
|
/// show it; see link_guard.js.
|
|
#[cfg(target_os = "macos")]
|
|
fn disable_link_previews(window: &WebviewWindow) {
|
|
use objc2::msg_send;
|
|
use objc2::runtime::{AnyObject, Bool};
|
|
|
|
if let Err(error) = window.with_webview(|platform_webview| {
|
|
let webview = platform_webview.inner().cast::<AnyObject>();
|
|
|
|
// Safety: on macOS, the handle is the WKWebView of the window, and Tauri calls this closure
|
|
// on the main thread, as AppKit requires:
|
|
unsafe {
|
|
let _: () = msg_send![webview, setAllowsLinkPreview: Bool::NO];
|
|
}
|
|
}) {
|
|
error!(Source = "Tauri"; "Failed to switch off the link previews: {error}");
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn register_linux_permission_request_handler(window: &WebviewWindow) {
|
|
if let Err(error) = window.with_webview(|platform_webview| {
|
|
use webkit2gtk::WebViewExt;
|
|
use webkit2gtk::UserMediaPermissionRequest;
|
|
|
|
let webview = platform_webview.inner();
|
|
webview.connect_permission_request(|webview, request| {
|
|
let Some(user_media_request) = request.downcast_ref::<UserMediaPermissionRequest>() else {
|
|
request.deny();
|
|
info!(Source = "Tauri"; "Denied a non-user-media WebKit permission request.");
|
|
return true;
|
|
};
|
|
|
|
let current_webview_url = webview
|
|
.uri()
|
|
.and_then(|uri| tauri::Url::parse(uri.as_str()).ok());
|
|
let approved_app_url = APPROVED_APP_URL.lock().unwrap().clone();
|
|
let origin_matches = approved_app_url
|
|
.as_ref()
|
|
.zip(current_webview_url.as_ref())
|
|
.is_some_and(|(approved, current)| same_origin(approved, current));
|
|
let requests_audio = user_media_request.is_for_audio_device();
|
|
let requests_video = user_media_request.is_for_video_device();
|
|
let allow = should_allow_audio_capture(
|
|
approved_app_url.as_ref(),
|
|
current_webview_url.as_ref(),
|
|
requests_audio,
|
|
requests_video,
|
|
);
|
|
|
|
if allow {
|
|
request.allow();
|
|
} else {
|
|
request.deny();
|
|
}
|
|
|
|
info!(
|
|
Source = "Tauri";
|
|
"Handled WebKit user-media permission request: allowed={allow}, origin_matches={origin_matches}, audio={requests_audio}, video={requests_video}."
|
|
);
|
|
true
|
|
});
|
|
}) {
|
|
error!(Source = "Tauri"; "Failed to register the Linux WebKit permission request handler: {error}");
|
|
}
|
|
}
|
|
|
|
/// Limits the right-click menu to text fields and selected text in every frame, see
|
|
/// context_menu_guard.js. Development builds keep the menu, since it opens the developer tools,
|
|
/// which Tauri offers in these builds only.
|
|
fn context_menu_guard() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
|
let plugin = tauri::plugin::Builder::<tauri::Wry, ()>::new("context-menu-guard");
|
|
if cfg!(debug_assertions) {
|
|
return plugin.build();
|
|
}
|
|
|
|
plugin.js_init_script_on_all_frames(include_str!("context_menu_guard.js")).build()
|
|
}
|
|
|
|
/// The address of the app, once the window shows it.
|
|
fn approved_app_url() -> Option<tauri::Url> {
|
|
APPROVED_APP_URL.lock().unwrap().clone()
|
|
}
|
|
|
|
/// Decides whether an address leaves the window for the system: a website for the browser, or a
|
|
/// `mailto:` or `tel:` link for the mail or phone app. Navigations and requests for new windows
|
|
/// both ask here, so that one rule decides what leaves the window.
|
|
///
|
|
/// An address of the app never leaves: a browser outside the window has no session cookie, see
|
|
/// app_access.rs. Another local server, such as a local LLM, leaves once the window shows the app.
|
|
fn should_open_in_system_browser(url: &tauri::Url, approved_app_url: Option<&tauri::Url>, current_url: Option<&tauri::Url>) -> bool {
|
|
match url.scheme() {
|
|
"mailto" | "tel" => return true,
|
|
"http" | "https" => {},
|
|
_ => return false,
|
|
}
|
|
|
|
if is_tauri_asset_url(url) {
|
|
return false;
|
|
}
|
|
|
|
if let Some(approved_app_url) = approved_app_url {
|
|
if same_origin(approved_app_url, url) {
|
|
return false;
|
|
}
|
|
|
|
if is_local_http_url(url) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if current_url.is_some_and(|current_url| same_origin(current_url, url)) {
|
|
return false;
|
|
}
|
|
|
|
!is_local_host(url.host_str())
|
|
}
|
|
|
|
/// Hands an address outside the app to the system, see `should_open_in_system_browser`.
|
|
///
|
|
/// On Linux, the desktop portal opens it, as it does for the Flatpak anyway. The opener plugin
|
|
/// starts the xdg-open of the system with the environment of AI Studio, which an AppImage points
|
|
/// at its own GTK and GIO modules: the browser then stays closed, and no error tells why. The
|
|
/// opener plugin remains for systems without a portal.
|
|
fn open_in_system_browser<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>, url: &tauri::Url) {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
use crate::file_actions::LinuxPortalError;
|
|
|
|
let app_handle = app_handle.clone();
|
|
let url = url.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
match open_url_with_linux_portal(&url).await {
|
|
Ok(()) => {
|
|
info!(Source = "Tauri"; "Opening external URL through the desktop portal: {url}");
|
|
},
|
|
Err(LinuxPortalError::RequestFailed(error)) => {
|
|
error!(Source = "Tauri"; "Failed to open external URL '{url}' through the desktop portal: {error}");
|
|
},
|
|
Err(LinuxPortalError::Unavailable(error)) => {
|
|
warn!(Source = "Tauri"; "The desktop portal cannot open external URL '{url}', so the opener plugin opens it: {error}");
|
|
open_with_opener(&app_handle, &url);
|
|
},
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
open_with_opener(app_handle, url);
|
|
}
|
|
|
|
/// Opens an address outside the app through the desktop portal, see `open_in_system_browser`.
|
|
#[cfg(target_os = "linux")]
|
|
async fn open_url_with_linux_portal(url: &tauri::Url) -> Result<(), crate::file_actions::LinuxPortalError> {
|
|
use ashpd::desktop::open_uri::OpenFileRequest;
|
|
use crate::file_actions::LinuxPortalError;
|
|
|
|
let uri = ashpd::Uri::parse(url.as_str())
|
|
.map_err(|error| LinuxPortalError::RequestFailed(format!("The desktop portal cannot take the address: {error}")))?;
|
|
|
|
let request = OpenFileRequest::default()
|
|
.send_uri(&uri)
|
|
.await
|
|
.map_err(|error| LinuxPortalError::Unavailable(format!("Desktop portal invocation failed: {error}")))?;
|
|
|
|
request.response()
|
|
.map_err(|error| LinuxPortalError::RequestFailed(format!("Desktop portal request failed: {error}")))
|
|
}
|
|
|
|
/// Opens an address outside the app with the opener plugin, see `open_in_system_browser`.
|
|
fn open_with_opener<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>, url: &tauri::Url) {
|
|
match app_handle.opener().open_url(url.as_str(), None::<&str>) {
|
|
Ok(_) => {
|
|
info!(Source = "Tauri"; "Opening external URL in system browser: {url}");
|
|
},
|
|
Err(error) => {
|
|
error!(Source = "Tauri"; "Failed to open external URL '{url}' in system browser: {error}");
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Our event API endpoint for Tauri events. We try to send an endless stream of events to the client.
|
|
/// If no events are available for a certain time, we send a ping event to keep the connection alive.
|
|
/// When the client disconnects, the stream is closed. But we try to not lose events in between.
|
|
/// The client is expected to reconnect automatically when the connection is closed and continue
|
|
/// listening for events.
|
|
pub async fn get_event_stream() -> Response {
|
|
// Get the lock to the event broadcast sender:
|
|
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
|
|
|
|
// Get and subscribe to the event receiver:
|
|
let mut event_receiver = event_broadcast_lock.as_ref()
|
|
.expect("Event sender not initialized.")
|
|
.subscribe();
|
|
|
|
// Drop the lock to allow other access to the sender:
|
|
drop(event_broadcast_lock);
|
|
|
|
let stream = stream! {
|
|
loop {
|
|
// Wait at most 3 seconds for an event:
|
|
match time::timeout(Duration::from_secs(3), event_receiver.recv()).await {
|
|
|
|
// Case: we received an event
|
|
Ok(Ok(event)) => {
|
|
// Serialize the event to JSON. Important is that the entire event
|
|
// is serialized as a single line so that the client can parse it
|
|
// correctly:
|
|
let event_json = serde_json::to_string(&event).unwrap();
|
|
yield Ok::<Bytes, Infallible>(Bytes::from(event_json));
|
|
|
|
// The client expects a newline after each event because we are using
|
|
// a method to read the stream line-by-line:
|
|
yield Ok::<Bytes, Infallible>(Bytes::from("\n"));
|
|
},
|
|
|
|
// Case: we lagged behind and missed some events
|
|
Ok(Err(broadcast::error::RecvError::Lagged(skipped))) => {
|
|
warn!(Source = "Tauri"; "Event receiver lagged, skipped {skipped} messages.");
|
|
},
|
|
|
|
// Case: the event channel was closed
|
|
Ok(Err(broadcast::error::RecvError::Closed)) => {
|
|
warn!(Source = "Tauri"; "Event receiver channel closed.");
|
|
return;
|
|
},
|
|
|
|
// Case: timeout. We will send a ping event to keep the connection alive.
|
|
Err(_) => {
|
|
let ping_event = Event::new(TauriEventType::Ping, Vec::new());
|
|
|
|
// Again, we have to serialize the event as a single line:
|
|
let event_json = serde_json::to_string(&ping_event).unwrap();
|
|
yield Ok::<Bytes, Infallible>(Bytes::from(event_json));
|
|
|
|
// The client expects a newline after each event because we are using
|
|
// a method to read the stream line-by-line:
|
|
yield Ok::<Bytes, Infallible>(Bytes::from("\n"));
|
|
},
|
|
}
|
|
}
|
|
};
|
|
|
|
([(CONTENT_TYPE, "application/jsonl")], Body::from_stream(stream)).into_response()
|
|
}
|
|
|
|
/// The cursor position of a drag and drop event, in CSS pixels relative to the viewport.
|
|
#[derive(Debug, Clone, Copy, Serialize)]
|
|
pub struct CursorPosition {
|
|
pub x: f64,
|
|
pub y: f64,
|
|
}
|
|
|
|
/// Converts the cursor position of a drag and drop event into CSS pixels.
|
|
///
|
|
/// Tauri names the type PhysicalPosition, but only Windows fills it with device pixels: there, wry
|
|
/// converts the screen coordinate with ScreenToClient. macOS hands over the NSView point of the
|
|
/// drag and GTK the logical widget coordinate, and both of those already are what CSS calls a
|
|
/// pixel. tauri-runtime-wry relabels all three without touching them, which is why the scale factor
|
|
/// belongs to the Windows branch alone: applying it everywhere would halve every coordinate on a
|
|
/// display with a scale factor of two.
|
|
/// Changing the display or its scaling at runtime needs no attention here. On Windows the caller
|
|
/// reads the factor anew for every drag and drop event, and Tauri keeps its own value current
|
|
/// through WM_DPICHANGED, so nothing of ours can go stale. On macOS and Linux no factor takes part
|
|
/// in the first place: a point stays a point when the window moves to a display with a different
|
|
/// pixel density, and only the number of device pixels behind it changes.
|
|
///
|
|
/// A logical point equals a CSS pixel only as long as the webview is not zoomed. AI Studio zooms it
|
|
/// to show in the same size on every laptop, see interface_zoom.rs, and a zoomed CSS pixel covers
|
|
/// that many logical points. The position is therefore divided by the zoom as well, on every
|
|
/// platform. Keyboard zooming stays off: tauri.conf.json leaves zoomHotkeysEnabled unset, and a
|
|
/// zoom we did not set ourselves would put the position off.
|
|
///
|
|
/// The decision is written with cfg! rather than #[cfg], so that both branches are compiled and
|
|
/// type-checked on every platform instead of only on the one they apply to.
|
|
fn cursor_position_in_css_pixels(position: PhysicalPosition<f64>, scale_factor: f64, zoom: f64) -> CursorPosition {
|
|
let platform_scale_factor = if cfg!(target_os = "windows") { scale_factor } else { 1.0 };
|
|
scale_cursor_position(position, platform_scale_factor * zoom)
|
|
}
|
|
|
|
/// Divides a cursor position by a scale factor.
|
|
fn scale_cursor_position(position: PhysicalPosition<f64>, scale_factor: f64) -> CursorPosition {
|
|
// Zero or less cannot be a scale. Treating such a value as 1.0 keeps it from turning the
|
|
// position into infinity:
|
|
let scale_factor = if scale_factor > 0.0 { scale_factor } else { 1.0 };
|
|
CursorPosition { x: position.x / scale_factor, y: position.y / scale_factor }
|
|
}
|
|
|
|
/// Decides whether a drag-over event is due, given when we sent the last one.
|
|
fn drag_over_is_due(last_sent: Option<Instant>, now: Instant) -> bool {
|
|
!last_sent.is_some_and(|last_at| now.duration_since(last_at) < DRAG_OVER_EVENT_INTERVAL)
|
|
}
|
|
|
|
/// Forgets when we sent the last drag-over event, so the next drag starts with a fresh interval.
|
|
///
|
|
/// Every drag which begins, ends, or is abandoned calls this. Without it, a drag starting within
|
|
/// the interval of the previous one would have its first drag-over event swallowed, and the
|
|
/// highlight would stay behind until the pointer moves again.
|
|
fn reset_drag_over_throttle() {
|
|
*LAST_DRAG_OVER_SENT.lock().unwrap() = None;
|
|
}
|
|
|
|
/// Data structure representing a Tauri event for our event API.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct Event {
|
|
pub event_type: TauriEventType,
|
|
pub payload: Vec<String>,
|
|
|
|
/// Where the cursor was, for the drag and drop events which know it.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub position: Option<CursorPosition>,
|
|
}
|
|
|
|
/// Implementation of the Event struct.
|
|
impl Event {
|
|
|
|
/// Creates a new Event instance.
|
|
pub fn new(event_type: TauriEventType, payload: Vec<String>) -> Self {
|
|
Event {
|
|
payload,
|
|
event_type,
|
|
position: None,
|
|
}
|
|
}
|
|
|
|
/// Creates a new Event instance which carries the cursor position as well.
|
|
pub fn with_position(event_type: TauriEventType, payload: Vec<String>, position: CursorPosition) -> Self {
|
|
Event {
|
|
payload,
|
|
event_type,
|
|
position: Some(position),
|
|
}
|
|
}
|
|
|
|
/// Creates an Event instance from a Tauri WindowEvent, unless the event is none of our business.
|
|
///
|
|
/// The scale factor and the zoom only matter for the position of a drag and drop event, see
|
|
/// `cursor_position_in_css_pixels`.
|
|
pub fn from_window_event(window_event: &WindowEvent, scale_factor: f64, zoom: f64) -> Option<Self> {
|
|
match window_event {
|
|
WindowEvent::DragDrop(drop_event) => {
|
|
match drop_event {
|
|
DragDropEvent::Enter { paths, position } => {
|
|
reset_drag_over_throttle();
|
|
Some(Event::with_position(
|
|
TauriEventType::FileDropHovered,
|
|
paths.iter().map(|p| p.display().to_string()).collect(),
|
|
cursor_position_in_css_pixels(*position, scale_factor, zoom),
|
|
))
|
|
},
|
|
|
|
DragDropEvent::Over { position } => {
|
|
let now = Instant::now();
|
|
let mut last_sent = LAST_DRAG_OVER_SENT.lock().unwrap();
|
|
if !drag_over_is_due(*last_sent, now) {
|
|
return None;
|
|
}
|
|
|
|
*last_sent = Some(now);
|
|
drop(last_sent);
|
|
|
|
Some(Event::with_position(
|
|
TauriEventType::FileDropOver,
|
|
Vec::new(),
|
|
cursor_position_in_css_pixels(*position, scale_factor, zoom),
|
|
))
|
|
},
|
|
|
|
DragDropEvent::Drop { paths, position } => {
|
|
reset_drag_over_throttle();
|
|
Some(Event::with_position(
|
|
TauriEventType::FileDropDropped,
|
|
paths.iter().map(|p| p.display().to_string()).collect(),
|
|
cursor_position_in_css_pixels(*position, scale_factor, zoom),
|
|
))
|
|
},
|
|
|
|
DragDropEvent::Leave => {
|
|
reset_drag_over_throttle();
|
|
Some(Event::new(TauriEventType::FileDropCanceled, Vec::new()))
|
|
},
|
|
|
|
// The event is marked as non-exhaustive, so a variant added later lands here:
|
|
_ => None,
|
|
}
|
|
},
|
|
|
|
WindowEvent::Focused(state) => if *state {
|
|
Some(Event::new(TauriEventType::WindowFocused,
|
|
Vec::new(),
|
|
))
|
|
} else {
|
|
Some(Event::new(TauriEventType::WindowNotFocused,
|
|
Vec::new(),
|
|
))
|
|
},
|
|
|
|
//
|
|
// Everything else is none of our business. Saying so keeps it out of the broadcast
|
|
// channel, which matters during a drag: the app discarded these events at the far end
|
|
// of the stream, but a single drag pushed hundreds of them through a channel of 100
|
|
// beforehand, which is what made its receiver lag.
|
|
//
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The types of Tauri events we can send through our event API.
|
|
#[derive(Debug, Serialize, Clone)]
|
|
pub enum TauriEventType {
|
|
None,
|
|
Ping,
|
|
Unknown,
|
|
|
|
WindowFocused,
|
|
WindowNotFocused,
|
|
|
|
FileDropHovered,
|
|
FileDropOver,
|
|
FileDropDropped,
|
|
FileDropCanceled,
|
|
|
|
GlobalShortcutPressed,
|
|
GlobalShortcutChanged,
|
|
|
|
/// The interface size on the screen the window shows on changed, see interface_zoom.rs.
|
|
InterfaceSizeChanged,
|
|
}
|
|
|
|
/// Sends an event to the app.
|
|
///
|
|
/// Locks only the broadcast sender, never the main window, so it may run on the main thread.
|
|
pub fn send_event(event: Event) {
|
|
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
|
|
let Some(event_sender) = event_sender else {
|
|
warn!(Source = "Tauri"; "Cannot send the event {:?} before the event channel exists.", event.event_type);
|
|
return;
|
|
};
|
|
|
|
if let Err(error) = event_sender.send(event) {
|
|
error!(Source = "Tauri"; "Failed to send an event: {error}");
|
|
}
|
|
}
|
|
|
|
/// Changes the location of the main window to the given URL. The window gets its session cookie
|
|
/// first, so that the .NET server lets it in.
|
|
pub async fn change_location_to(url: tauri::Url) {
|
|
// Try to get the main window. If it is not available yet, wait for it:
|
|
let mut main_window_ready = false;
|
|
let mut main_window_status_reported = false;
|
|
let main_window_spawn_clone = &MAIN_WINDOW;
|
|
while !main_window_ready
|
|
{
|
|
main_window_ready = {
|
|
let main_window = main_window_spawn_clone.lock().unwrap();
|
|
main_window.is_some()
|
|
};
|
|
|
|
if !main_window_ready {
|
|
if !main_window_status_reported {
|
|
info!("Waiting for main window to be ready, because .NET was faster than Tauri.");
|
|
main_window_status_reported = true;
|
|
}
|
|
|
|
time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
if is_local_http_url(&url) {
|
|
*APPROVED_APP_URL.lock().unwrap() = Some(url.clone());
|
|
}
|
|
|
|
//
|
|
// The window navigates by itself instead of through a script of the page it shows. That page
|
|
// comes from Tauri, which is another site than the app; app_access::session_cookie explains
|
|
// why the session cookie goes along all the same. We take the window out of the lock first:
|
|
// the cookie and the navigation need the main thread, which may wait for this lock meanwhile.
|
|
//
|
|
// Handing over the cookie waits for the main thread. This function serves a request of the
|
|
// runtime API, so the waiting happens outside the async runtime, see runtime/AGENTS.md.
|
|
//
|
|
let main_window = main_window_spawn_clone.lock().unwrap().clone().unwrap();
|
|
let navigation = tokio::task::spawn_blocking(move || {
|
|
hand_over_session_cookie(&main_window, &url);
|
|
match main_window.navigate(url.clone()) {
|
|
Ok(_) => info!("The app location was changed to {url}."),
|
|
Err(e) => error!("Failed to change the app location to {url}: {e}."),
|
|
}
|
|
});
|
|
|
|
if let Err(error) = navigation.await {
|
|
error!("Failed to open the app in the main window: {error}");
|
|
}
|
|
}
|
|
|
|
/// Checks for updates.
|
|
pub async fn check_for_update() -> Json<CheckUpdateResponse> {
|
|
if let Some(reason) = self_update_blocked_reason(is_flatpak(), installation_kind()) {
|
|
warn!(Source = "Updater"; "Skipping update check because {reason}.");
|
|
return Json(CheckUpdateResponse {
|
|
update_is_available: false,
|
|
error: false,
|
|
new_version: String::from(""),
|
|
changelog: String::from(""),
|
|
});
|
|
}
|
|
|
|
let app_handle = {
|
|
let main_window = MAIN_WINDOW.lock().unwrap();
|
|
match main_window.as_ref() {
|
|
Some(window) => window.app_handle().clone(),
|
|
None => {
|
|
error!(Source = "Updater"; "Cannot check updates: main window not available.");
|
|
return Json(CheckUpdateResponse {
|
|
update_is_available: false,
|
|
error: true,
|
|
new_version: String::from(""),
|
|
changelog: String::from(""),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
let response = match app_handle.updater() {
|
|
Ok(updater) => updater.check().await,
|
|
Err(e) => {
|
|
warn!(Source = "Updater"; "Failed to get updater instance: {e}");
|
|
return Json(CheckUpdateResponse {
|
|
update_is_available: false,
|
|
error: true,
|
|
new_version: String::from(""),
|
|
changelog: String::from(""),
|
|
});
|
|
}
|
|
};
|
|
|
|
match response {
|
|
Ok(Some(update)) => {
|
|
let body_len = update.body.as_ref().map_or(0, |body| body.len());
|
|
let date = update.date;
|
|
let new_version = update.version.clone();
|
|
info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={new_version}");
|
|
let changelog = update.body.clone().unwrap_or_default();
|
|
*CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update);
|
|
Json(CheckUpdateResponse {
|
|
update_is_available: true,
|
|
error: false,
|
|
new_version,
|
|
changelog,
|
|
})
|
|
}
|
|
Ok(None) => {
|
|
info!(Source = "Tauri"; "Updater: app is already up to date");
|
|
Json(CheckUpdateResponse {
|
|
update_is_available: false,
|
|
error: false,
|
|
new_version: String::from(""),
|
|
changelog: String::from(""),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
warn!(Source = "Tauri"; "Updater: failed to update: {e}");
|
|
Json(CheckUpdateResponse {
|
|
update_is_available: false,
|
|
error: true,
|
|
new_version: String::from(""),
|
|
changelog: String::from(""),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The response to the check for update request.
|
|
#[derive(Serialize)]
|
|
pub struct CheckUpdateResponse {
|
|
update_is_available: bool,
|
|
error: bool,
|
|
new_version: String,
|
|
changelog: String,
|
|
}
|
|
|
|
/// Installs the update.
|
|
pub async fn install_update() {
|
|
if let Some(reason) = self_update_blocked_reason(is_flatpak(), installation_kind()) {
|
|
warn!(Source = "Updater"; "Skipping update installation because {reason}.");
|
|
return;
|
|
}
|
|
|
|
let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone();
|
|
let app_handle = MAIN_WINDOW
|
|
.lock()
|
|
.unwrap()
|
|
.as_ref()
|
|
.map(|window| window.app_handle().clone());
|
|
|
|
match cloned_response_option {
|
|
Some(update_response) => {
|
|
info!(Source = "Tauri"; "Updater: update is pending!");
|
|
|
|
// Once the download is complete, the .NET server and Qdrant Edge stop so that the installer
|
|
// can replace their files. When the update fails after that, e.g., because the installer
|
|
// cannot start, the app would remain without both. Restarting it brings them back.
|
|
let mut servers_stopped = false;
|
|
let result = update_response.download_and_install(
|
|
|chunk_length, _content_length| {
|
|
trace!(Source = "Tauri"; "Updater: downloading chunk of {chunk_length} bytes");
|
|
},
|
|
|| {
|
|
info!(Source = "Tauri"; "Updater: update has been downloaded!");
|
|
warn!(Source = "Tauri"; "Try to stop the .NET server now...");
|
|
|
|
if is_prod() {
|
|
stop_dotnet_server();
|
|
stop_qdrant_edge_database();
|
|
servers_stopped = true;
|
|
} else {
|
|
warn!(Source = "Tauri"; "Development environment detected; do not stop the .NET server.");
|
|
}
|
|
},
|
|
).await;
|
|
|
|
match result {
|
|
Ok(_) => {
|
|
info!(Source = "Tauri"; "Updater: app has been updated");
|
|
warn!(Source = "Tauri"; "Try to restart the app now...");
|
|
|
|
if is_prod() {
|
|
if let Some(handle) = app_handle {
|
|
handle.restart();
|
|
} else {
|
|
warn!(Source = "Tauri"; "Cannot restart after update: main window not available.");
|
|
}
|
|
} else {
|
|
warn!(Source = "Tauri"; "Development environment detected; do not restart the app.");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!(Source = "Tauri"; "Updater: failed to update: {e}");
|
|
if servers_stopped {
|
|
warn!(Source = "Tauri"; "The .NET server and Qdrant Edge are already stopped; try to restart the app now...");
|
|
if let Some(handle) = app_handle {
|
|
handle.restart();
|
|
} else {
|
|
warn!(Source = "Tauri"; "Cannot restart after the failed update: main window not available.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
None => {
|
|
error!(Source = "Updater"; "No update available to install. Did you check for updates first?");
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Returns why this installation cannot update itself, or `None` when it can.
|
|
fn self_update_blocked_reason(flatpak: bool, installation_kind: InstallationKind) -> Option<&'static str> {
|
|
if flatpak {
|
|
return Some("Flatpak installations are updated externally");
|
|
}
|
|
|
|
match installation_kind {
|
|
InstallationKind::User => None,
|
|
InstallationKind::Managed => Some("this installation is centrally managed"),
|
|
InstallationKind::UnsupportedLocation => Some("this installation is in a location the updater cannot replace"),
|
|
InstallationKind::Development => Some("the app is running in development mode"),
|
|
}
|
|
}
|
|
|
|
/// Response for application exit requests.
|
|
#[derive(Serialize)]
|
|
pub struct AppExitResponse {
|
|
success: bool,
|
|
error_message: String,
|
|
}
|
|
|
|
/// Requests a controlled shutdown of the entire desktop application.
|
|
pub async fn exit_app() -> Json<AppExitResponse> {
|
|
let Some(app_handle) = main_app_handle() else {
|
|
error!(Source = "Tauri"; "Cannot exit app: main window not available.");
|
|
return Json(AppExitResponse {
|
|
success: false,
|
|
error_message: "Main window not available".to_string(),
|
|
});
|
|
};
|
|
|
|
info!(Source = "Tauri"; "Controlled app exit was requested by the UI.");
|
|
tauri::async_runtime::spawn(async move {
|
|
time::sleep(Duration::from_millis(50)).await;
|
|
app_handle.exit(0);
|
|
});
|
|
|
|
Json(AppExitResponse {
|
|
success: true,
|
|
error_message: String::new(),
|
|
})
|
|
}
|
|
|
|
/// Response for application restart requests.
|
|
#[derive(Serialize)]
|
|
pub struct AppRestartResponse {
|
|
success: bool,
|
|
error_message: String,
|
|
}
|
|
|
|
/// Requests a controlled restart of the entire desktop application, e.g., to apply a prepared restore
|
|
/// of a backup. Before the app starts again, the exit handler stops the .NET server and the vector
|
|
/// database, just as when the app exits.
|
|
///
|
|
/// In the development environment, the runtime did not start the .NET server, so it could not start
|
|
/// it again either. It refuses, and the developer restarts both.
|
|
pub async fn restart_app() -> Json<AppRestartResponse> {
|
|
if is_dev() {
|
|
warn!(Source = "Tauri"; "The UI requested a restart, which the development environment does not support.");
|
|
return Json(AppRestartResponse {
|
|
success: false,
|
|
error_message: "The development environment cannot restart the app, since the runtime did not start the .NET server. Restart both yourself.".to_string(),
|
|
});
|
|
}
|
|
|
|
let Some(app_handle) = main_app_handle() else {
|
|
error!(Source = "Tauri"; "Cannot restart app: main window not available.");
|
|
return Json(AppRestartResponse {
|
|
success: false,
|
|
error_message: "Main window not available".to_string(),
|
|
});
|
|
};
|
|
|
|
info!(Source = "Tauri"; "Controlled app restart was requested by the UI.");
|
|
tauri::async_runtime::spawn(async move {
|
|
time::sleep(Duration::from_millis(50)).await;
|
|
app_handle.request_restart();
|
|
});
|
|
|
|
Json(AppRestartResponse {
|
|
success: true,
|
|
error_message: String::new(),
|
|
})
|
|
}
|
|
|
|
/// The handle of the app, or `None` while the main window does not exist yet.
|
|
fn main_app_handle() -> Option<tauri::AppHandle> {
|
|
MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone())
|
|
}
|
|
|
|
/// Registers or updates a global shortcut. If the shortcut string is empty,
|
|
/// the existing shortcut for that name will be unregistered.
|
|
pub async fn register_shortcut(payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
|
|
let app_handle = main_app_handle();
|
|
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
|
|
Json(crate::global_shortcuts::register(app_handle, event_sender, payload.0).await)
|
|
}
|
|
|
|
/// Request payload for validating a shortcut.
|
|
#[derive(Clone, Deserialize)]
|
|
pub struct ValidateShortcutRequest {
|
|
/// The shortcut string to validate (e.g., "CmdOrControl+1").
|
|
shortcut: String,
|
|
}
|
|
|
|
/// Response for shortcut validation.
|
|
#[derive(Serialize)]
|
|
pub struct ShortcutValidationResponse {
|
|
is_valid: bool,
|
|
error_message: String,
|
|
has_conflict: bool,
|
|
conflict_description: String,
|
|
}
|
|
|
|
/// Validates a shortcut string without registering it.
|
|
/// Checks if the shortcut syntax is valid and if it
|
|
/// conflicts with existing shortcuts.
|
|
pub async fn validate_shortcut(payload: Json<ValidateShortcutRequest>) -> Json<ShortcutValidationResponse> {
|
|
let shortcut = payload.shortcut.clone();
|
|
|
|
// Empty shortcuts are always valid (means "disabled"):
|
|
if shortcut.is_empty() {
|
|
return Json(ShortcutValidationResponse {
|
|
is_valid: true,
|
|
error_message: String::new(),
|
|
has_conflict: false,
|
|
conflict_description: String::new(),
|
|
});
|
|
}
|
|
|
|
// Check if the shortcut is already registered:
|
|
for (name, registered_shortcut) in crate::global_shortcuts::registered_shortcuts().await {
|
|
if registered_shortcut.eq_ignore_ascii_case(&shortcut) {
|
|
return Json(ShortcutValidationResponse {
|
|
is_valid: true,
|
|
error_message: String::new(),
|
|
has_conflict: true,
|
|
conflict_description: format!("Already used by: {}", name),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Try to parse the shortcut to validate syntax.
|
|
// We can't easily validate without registering in Tauri 1.x,
|
|
// so we do basic syntax validation here:
|
|
let is_valid = validate_shortcut_syntax(&shortcut);
|
|
|
|
if is_valid {
|
|
Json(ShortcutValidationResponse {
|
|
is_valid: true,
|
|
error_message: String::new(),
|
|
has_conflict: false,
|
|
conflict_description: String::new(),
|
|
})
|
|
} else {
|
|
Json(ShortcutValidationResponse {
|
|
is_valid: false,
|
|
error_message: format!("Invalid shortcut syntax: {}", shortcut),
|
|
has_conflict: false,
|
|
conflict_description: String::new(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Suspends shortcut processing. Portal sessions remain active and ignore activations;
|
|
/// Tauri shortcuts are temporarily unregistered and restored on resume.
|
|
/// This is useful when opening a dialog to configure shortcuts, so the user can
|
|
/// press the current shortcut to re-enter it without triggering the action.
|
|
pub async fn suspend_shortcuts() -> Json<ShortcutResponse> {
|
|
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
|
|
Json(crate::global_shortcuts::suspend(app_handle).await)
|
|
}
|
|
|
|
/// Resumes shortcut processing by re-registering all shortcuts with the OS.
|
|
pub async fn resume_shortcuts() -> Json<ShortcutResponse> {
|
|
let app_handle = main_app_handle();
|
|
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
|
|
Json(crate::global_shortcuts::resume(app_handle, event_sender).await)
|
|
}
|
|
|
|
/// Validates the syntax of a shortcut string.
|
|
fn validate_shortcut_syntax(shortcut: &str) -> bool {
|
|
let parts: Vec<&str> = shortcut.split('+').collect();
|
|
if parts.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let mut has_key = false;
|
|
for part in parts {
|
|
let part_lower = part.to_lowercase();
|
|
match part_lower.as_str() {
|
|
// Modifiers
|
|
"cmdorcontrol" | "commandorcontrol" | "ctrl" | "control" | "cmd" | "command" |
|
|
"shift" | "alt" | "meta" | "super" | "option" => continue,
|
|
|
|
// Keys - letters
|
|
"a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" |
|
|
"n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" => has_key = true,
|
|
|
|
// Keys - numbers
|
|
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" => has_key = true,
|
|
|
|
// Keys - function keys
|
|
_ if part_lower.starts_with('f') && part_lower[1..].parse::<u32>().is_ok() => has_key = true,
|
|
|
|
// Keys - special
|
|
"space" | "enter" | "tab" | "escape" | "backspace" | "delete" | "insert" |
|
|
"home" | "end" | "pageup" | "pagedown" |
|
|
"up" | "down" | "left" | "right" |
|
|
"arrowup" | "arrowdown" | "arrowleft" | "arrowright" |
|
|
"minus" | "equal" | "bracketleft" | "bracketright" | "backslash" |
|
|
"semicolon" | "quote" | "backquote" | "comma" | "period" | "slash" => has_key = true,
|
|
|
|
// Keys - numpad
|
|
_ if part_lower.starts_with("num") => has_key = true,
|
|
|
|
// Unknown
|
|
_ => return false,
|
|
}
|
|
}
|
|
|
|
has_key
|
|
}
|
|
|
|
fn set_pdfium_path<R: tauri::Runtime>(path_resolver: &PathResolver<R>) {
|
|
let resource_dir = match path_resolver.resource_dir() {
|
|
Ok(path) => path,
|
|
Err(error) => {
|
|
error!(Source = "Bootloader Tauri"; "Failed to resolve resource dir: {error}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
match select_pdfium_library_directory(&resource_dir, is_flatpak()) {
|
|
Some(path) => {
|
|
*PDFIUM_LIB_PATH.lock().unwrap() = Some(path.to_string_lossy().to_string());
|
|
}
|
|
None => {
|
|
error!(Source = "Bootloader Tauri"; "Failed to set the PDFium library path.");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn select_pdfium_library_directory(resource_dir: &Path, include_flatpak_library_directory: bool) -> Option<PathBuf> {
|
|
select_pdfium_library_directory_for(resource_dir, include_flatpak_library_directory, Path::new(FLATPAK_LIBRARY_DIRECTORY))
|
|
}
|
|
|
|
fn select_pdfium_library_directory_for(
|
|
resource_dir: &Path,
|
|
include_flatpak_library_directory: bool,
|
|
flatpak_library_directory: &Path,
|
|
) -> Option<PathBuf> {
|
|
let mut candidate_paths = Vec::new();
|
|
|
|
if include_flatpak_library_directory {
|
|
candidate_paths.push(flatpak_library_directory.to_path_buf());
|
|
}
|
|
|
|
candidate_paths.push(resource_dir.join("resources").join("libraries"));
|
|
candidate_paths.push(resource_dir.join("libraries"));
|
|
|
|
for path in candidate_paths {
|
|
let pdfium_library_path = Pdfium::pdfium_platform_library_name_at_path(&path);
|
|
if pdfium_library_path.exists() {
|
|
return Some(path);
|
|
}
|
|
|
|
if path.exists() {
|
|
warn!(
|
|
Source = "Bootloader Tauri";
|
|
"PDFium library directory exists, but the library file was not found at '{path}'.",
|
|
path = pdfium_library_path.to_string_lossy(),
|
|
);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
#[test]
|
|
fn self_update_is_disabled_in_development() {
|
|
assert!(self_update_blocked_reason(false, InstallationKind::Development).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn self_update_is_disabled_for_flatpak() {
|
|
assert!(self_update_blocked_reason(true, InstallationKind::User).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn self_update_is_disabled_for_managed_installations() {
|
|
assert!(self_update_blocked_reason(false, InstallationKind::Managed).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn self_update_is_disabled_for_unsupported_installation_locations() {
|
|
assert!(self_update_blocked_reason(false, InstallationKind::UnsupportedLocation).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn every_blocked_installation_kind_has_its_own_reason() {
|
|
let reasons = [
|
|
self_update_blocked_reason(false, InstallationKind::Managed),
|
|
self_update_blocked_reason(false, InstallationKind::UnsupportedLocation),
|
|
self_update_blocked_reason(false, InstallationKind::Development),
|
|
];
|
|
|
|
for (index, reason) in reasons.iter().enumerate() {
|
|
assert!(reason.is_some(), "expected a reason at index {index}");
|
|
assert_eq!(
|
|
reasons.iter().filter(|other| *other == reason).count(),
|
|
1,
|
|
"expected the reason at index {index} to be unique"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn self_update_is_enabled_for_normal_production_installations() {
|
|
assert!(self_update_blocked_reason(false, InstallationKind::User).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn the_first_drag_over_event_of_a_drag_is_due() {
|
|
assert!(drag_over_is_due(None, Instant::now()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_drag_over_event_within_the_interval_is_not_due() {
|
|
let now = Instant::now();
|
|
assert!(!drag_over_is_due(Some(now - DRAG_OVER_EVENT_INTERVAL / 2), now));
|
|
}
|
|
|
|
#[test]
|
|
fn a_drag_over_event_after_the_interval_is_due() {
|
|
let now = Instant::now();
|
|
assert!(drag_over_is_due(Some(now - DRAG_OVER_EVENT_INTERVAL), now));
|
|
}
|
|
|
|
#[test]
|
|
fn a_scale_factor_of_two_halves_the_cursor_position() {
|
|
let position = scale_cursor_position(PhysicalPosition::new(200.0, 100.0), 2.0);
|
|
assert_eq!((position.x, position.y), (100.0, 50.0));
|
|
}
|
|
|
|
#[test]
|
|
fn an_impossible_scale_factor_leaves_the_cursor_position_alone() {
|
|
let position = scale_cursor_position(PhysicalPosition::new(200.0, 100.0), 0.0);
|
|
assert_eq!((position.x, position.y), (200.0, 100.0));
|
|
}
|
|
|
|
#[test]
|
|
fn the_cursor_position_is_scaled_on_windows_only() {
|
|
let position = cursor_position_in_css_pixels(PhysicalPosition::new(200.0, 100.0), 2.0, 1.0);
|
|
let expected = if cfg!(target_os = "windows") { (100.0, 50.0) } else { (200.0, 100.0) };
|
|
|
|
assert_eq!((position.x, position.y), expected);
|
|
}
|
|
|
|
#[test]
|
|
fn the_zoom_divides_the_cursor_position_on_every_platform() {
|
|
let position = cursor_position_in_css_pixels(PhysicalPosition::new(200.0, 100.0), 1.0, 2.0);
|
|
assert_eq!((position.x, position.y), (100.0, 50.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_window_event_we_do_not_care_about_is_not_channeled() {
|
|
assert!(Event::from_window_event(&WindowEvent::Destroyed, 1.0, 1.0).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn losing_the_window_focus_is_channeled_without_a_position() {
|
|
let event = Event::from_window_event(&WindowEvent::Focused(false), 1.0, 1.0).unwrap();
|
|
|
|
assert!(matches!(event.event_type, TauriEventType::WindowNotFocused));
|
|
assert!(event.position.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn pdfium_library_directory_prefers_resources_libraries() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let resources_libraries = temp_dir.path().join("resources").join("libraries");
|
|
let libraries = temp_dir.path().join("libraries");
|
|
create_pdfium_library_in(&resources_libraries);
|
|
create_pdfium_library_in(&libraries);
|
|
|
|
assert_eq!(
|
|
select_pdfium_library_directory(temp_dir.path(), false),
|
|
Some(resources_libraries)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pdfium_library_directory_falls_back_when_first_directory_has_no_library() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let resources_libraries = temp_dir.path().join("resources").join("libraries");
|
|
let libraries = temp_dir.path().join("libraries");
|
|
fs::create_dir_all(&resources_libraries).unwrap();
|
|
create_pdfium_library_in(&libraries);
|
|
|
|
assert_eq!(
|
|
select_pdfium_library_directory(temp_dir.path(), false),
|
|
Some(libraries)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pdfium_library_directory_requires_library_file() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(temp_dir.path().join("resources").join("libraries")).unwrap();
|
|
fs::create_dir_all(temp_dir.path().join("libraries")).unwrap();
|
|
|
|
assert_eq!(select_pdfium_library_directory(temp_dir.path(), false), None);
|
|
}
|
|
|
|
#[test]
|
|
fn pdfium_library_directory_prefers_flatpak_library_directory_when_flatpak() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let flatpak_library_directory = temp_dir.path().join("app").join("lib");
|
|
let resources_libraries = temp_dir.path().join("resources").join("libraries");
|
|
create_pdfium_library_in(&flatpak_library_directory);
|
|
create_pdfium_library_in(&resources_libraries);
|
|
|
|
assert_eq!(
|
|
select_pdfium_library_directory_for(temp_dir.path(), true, &flatpak_library_directory),
|
|
Some(flatpak_library_directory)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pdfium_library_directory_skips_flatpak_library_directory_when_not_flatpak() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let flatpak_library_directory = temp_dir.path().join("app").join("lib");
|
|
create_pdfium_library_in(&flatpak_library_directory);
|
|
|
|
assert_eq!(
|
|
select_pdfium_library_directory_for(temp_dir.path(), false, &flatpak_library_directory),
|
|
None
|
|
);
|
|
}
|
|
|
|
fn create_pdfium_library_in(path: &Path) {
|
|
fs::create_dir_all(path).unwrap();
|
|
fs::File::create(Pdfium::pdfium_platform_library_name_at_path(path)).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn tauri_localhost_is_tauri_asset_url() {
|
|
let https_url = tauri::Url::parse("https://tauri.localhost/index.html").unwrap();
|
|
let http_url = tauri::Url::parse("http://tauri.localhost/index.html").unwrap();
|
|
|
|
assert!(is_tauri_asset_url(&https_url));
|
|
assert!(is_tauri_asset_url(&http_url));
|
|
}
|
|
|
|
#[test]
|
|
fn localhost_app_url_is_not_tauri_asset_url() {
|
|
let url = tauri::Url::parse("http://localhost:12345/").unwrap();
|
|
|
|
assert!(!is_tauri_asset_url(&url));
|
|
assert!(is_local_http_url(&url));
|
|
}
|
|
|
|
#[test]
|
|
fn external_url_is_not_internal_url() {
|
|
let url = tauri::Url::parse("https://example.com/").unwrap();
|
|
|
|
assert!(!is_tauri_asset_url(&url));
|
|
assert!(!is_local_http_url(&url));
|
|
}
|
|
|
|
#[test]
|
|
fn app_url_never_opens_in_system_browser() {
|
|
let approved = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
|
for address in [
|
|
"http://127.0.0.1:12345/",
|
|
"http://127.0.0.1:12345/chat",
|
|
"http://127.0.0.1:12345/settings?section=providers",
|
|
"http://127.0.0.1:12345/about#licenses",
|
|
] {
|
|
let url = tauri::Url::parse(address).unwrap();
|
|
|
|
assert!(!should_open_in_system_browser(&url, Some(&approved), None), "{address}");
|
|
assert!(!should_open_in_system_browser(&url, Some(&approved), Some(&approved)), "{address}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn app_url_stays_in_window_before_it_is_approved() {
|
|
let current = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
|
let url = tauri::Url::parse("http://127.0.0.1:12345/chat").unwrap();
|
|
|
|
assert!(!should_open_in_system_browser(&url, None, None));
|
|
assert!(!should_open_in_system_browser(&url, None, Some(¤t)));
|
|
}
|
|
|
|
#[test]
|
|
fn external_website_opens_in_system_browser() {
|
|
let approved = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
|
let url = tauri::Url::parse("https://example.com/docs").unwrap();
|
|
|
|
assert!(should_open_in_system_browser(&url, Some(&approved), None));
|
|
assert!(should_open_in_system_browser(&url, None, None));
|
|
}
|
|
|
|
#[test]
|
|
fn mail_and_phone_links_open_in_system() {
|
|
let approved = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
|
for address in ["mailto:someone@example.com", "tel:+490000000"] {
|
|
let url = tauri::Url::parse(address).unwrap();
|
|
|
|
assert!(should_open_in_system_browser(&url, Some(&approved), None), "{address}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn other_local_server_opens_in_system_browser_once_app_is_approved() {
|
|
let approved = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
|
let url = tauri::Url::parse("http://localhost:11434/").unwrap();
|
|
|
|
assert!(should_open_in_system_browser(&url, Some(&approved), None));
|
|
assert!(!should_open_in_system_browser(&url, None, None));
|
|
}
|
|
|
|
#[test]
|
|
fn other_schemes_never_open_in_system_browser() {
|
|
let approved = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
|
for address in [
|
|
"file:///etc/passwd",
|
|
"javascript:alert(1)",
|
|
"data:text/html,hello",
|
|
"tauri://localhost/index.html",
|
|
"https://tauri.localhost/index.html",
|
|
] {
|
|
let url = tauri::Url::parse(address).unwrap();
|
|
|
|
assert!(!should_open_in_system_browser(&url, Some(&approved), None), "{address}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn audio_capture_is_allowed_for_exact_approved_app_origin() {
|
|
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
|
|
let current = tauri::Url::parse("http://localhost:12345/voice-recorder").unwrap();
|
|
|
|
assert!(should_allow_audio_capture(Some(&approved), Some(¤t), true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn audio_capture_is_denied_for_wrong_port() {
|
|
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
|
|
let current = tauri::Url::parse("http://localhost:54321/").unwrap();
|
|
|
|
assert!(!should_allow_audio_capture(Some(&approved), Some(¤t), true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn audio_capture_is_denied_for_external_origin() {
|
|
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
|
|
let current = tauri::Url::parse("https://example.com/").unwrap();
|
|
|
|
assert!(!should_allow_audio_capture(Some(&approved), Some(¤t), true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn video_capture_is_denied() {
|
|
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
|
|
|
|
assert!(!should_allow_audio_capture(Some(&approved), Some(&approved), false, true));
|
|
}
|
|
|
|
#[test]
|
|
fn combined_audio_and_video_capture_is_denied() {
|
|
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
|
|
|
|
assert!(!should_allow_audio_capture(Some(&approved), Some(&approved), true, true));
|
|
}
|
|
}
|