AI-Studio/runtime/src/app_window.rs

1267 lines
49 KiB
Rust
Raw Normal View History

2026-05-12 18:31:08 +00:00
use std::convert::Infallible;
use std::path::{Path, PathBuf};
2024-11-05 20:39:21 +00:00
use std::sync::Mutex;
use std::time::{Duration, Instant};
2026-05-12 18:31:08 +00:00
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};
2024-11-05 20:39:21 +00:00
use once_cell::sync::Lazy;
use pdfium_render::prelude::Pdfium;
2026-05-12 18:31:08 +00:00
use serde::{Deserialize, Serialize};
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent};
use tauri::path::PathResolver;
use tauri::WebviewWindow;
use tauri::PhysicalPosition;
use tauri_plugin_updater::{UpdaterExt, Update};
use tauri_plugin_opener::OpenerExt;
use tokio::sync::broadcast;
2024-11-05 20:39:21 +00:00
use tokio::time;
use crate::api_token::APIToken;
use crate::clipboard::shutdown_clipboard;
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,
};
2024-11-05 20:39:21 +00:00
use crate::log::switch_to_file_logging;
use crate::pdfium::PDFIUM_LIB_PATH;
2026-06-02 15:22:59 +00:00
use crate::qdrant_edge_database::{start_qdrant_edge_database, stop_qdrant_edge_database};
2026-07-18 15:42:38 +00:00
use crate::global_shortcuts::{RegisterShortcutRequest, ShortcutResponse};
#[cfg(debug_assertions)]
use crate::dotnet::create_startup_env_file;
use crate::tokenizer::set_default_tokenizer_path;
2024-11-05 20:39:21 +00:00
#[cfg(target_os = "linux")]
use webkit2gtk::glib::Cast;
#[cfg(target_os = "linux")]
use webkit2gtk::{PermissionRequestExt, UserMediaPermissionRequestExt};
2024-11-05 20:39:21 +00:00
/// The Tauri main window.
pub static MAIN_WINDOW: Lazy<Mutex<Option<WebviewWindow>>> = Lazy::new(|| Mutex::new(None));
2024-11-05 20:39:21 +00:00
/// The update response coming from the Tauri updater.
static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<Update>>> = Lazy::new(|| Mutex::new(None));
2024-11-05 20:39:21 +00:00
/// 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));
2024-11-05 20:39:21 +00:00
/// Starts the Tauri app.
pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
2024-11-05 20:39:21 +00:00
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;
},
}
}
});
2024-11-05 20:39:21 +00:00
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())
.plugin(tauri_plugin_opener::init())
.plugin(
tauri::plugin::Builder::<tauri::Wry, ()>::new("external-link-handler")
.on_navigation(|webview, url| {
if !should_open_in_system_browser(webview, url) {
return true;
}
match webview.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}");
},
}
false
})
.build(),
)
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
2024-11-05 20:39:21 +00:00
.setup(move |app| {
// Get the main window:
let window = app.get_webview_window("main").expect("Failed to get main window.");
#[cfg(target_os = "linux")]
register_linux_permission_request_handler(&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| {
//
// Only a drag and drop event carries a position, and only that position needs the
// scale factor. 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 = match event {
WindowEvent::DragDrop(_) => event_window.scale_factor().unwrap_or(1.0),
_ => 1.0,
};
let Some(event_to_send) = Event::from_window_event(event, scale_factor) 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}"),
}
});
});
// Save the main window for later access:
2024-11-05 20:39:21 +00:00
*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");
2024-11-05 20:39:21 +00:00
// Get and store the data and config directories:
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(app.path().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap();
2024-11-05 20:39:21 +00:00
if is_dev() {
#[cfg(debug_assertions)]
create_startup_env_file();
} else {
cleanup_dotnet_server();
start_dotnet_server(app.handle().clone());
}
2026-06-02 15:22:59 +00:00
start_qdrant_edge_database(app.handle().clone());
set_default_tokenizer_path(app.handle().clone());
info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {data_path:?}");
switch_to_file_logging(data_path).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap();
set_pdfium_path(app.path());
2024-11-05 20:39:21 +00:00
Ok(())
})
.plugin(tauri_plugin_window_state::Builder::default().build())
.build(tauri_context)
2024-11-05 20:39:21 +00:00
.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.");
}
2024-11-05 20:39:21 +00:00
WindowEvent::Destroyed => {
warn!(Source = "Tauri"; "Window '{label}': was destroyed.");
}
2024-11-05 20:39:21 +00:00
_ => (),
2024-11-05 20:39:21 +00:00
}
}
RunEvent::ExitRequested { .. } => {
warn!(Source = "Tauri"; "Run event: exit was requested.");
shutdown_clipboard();
2026-06-02 15:22:59 +00:00
stop_qdrant_edge_database();
if is_prod() {
warn!("Try to stop the .NET server as well...");
stop_dotnet_server();
}
2024-11-05 20:39:21 +00:00
}
RunEvent::Ready => {
info!(Source = "Tauri"; "Run event: Tauri app is ready.");
}
2024-11-05 20:39:21 +00:00
_ => {}
2024-11-05 20:39:21 +00:00
}
});
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())
}
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))
}
#[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}");
}
}
fn should_open_in_system_browser<R: tauri::Runtime>(webview: &tauri::Webview<R>, url: &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.lock().unwrap().as_ref() {
if same_origin(approved_app_url, url) {
return false;
}
if is_local_http_url(url) {
return true;
}
}
if let Ok(current_url) = webview.url() && same_origin(&current_url, url) {
return false;
}
!is_local_host(url.host_str())
}
/// 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.
2026-05-12 18:31:08 +00:00
pub async fn get_event_stream(_token: APIToken) -> 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);
2026-05-12 18:31:08 +00:00
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();
2026-05-12 18:31:08 +00:00
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:
2026-05-12 18:31:08 +00:00
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();
2026-05-12 18:31:08 +00:00
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:
2026-05-12 18:31:08 +00:00
yield Ok::<Bytes, Infallible>(Bytes::from("\n"));
},
}
}
2026-05-12 18:31:08 +00:00
};
([(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.
///
/// What the equality of a logical point and a CSS pixel does depend on is that nobody zooms the
/// webview: neither through WebviewWindow::set_zoom nor through zoomHotkeysEnabled, which our
/// tauri.conf.json leaves off. Should AI Studio ever offer a zoom, say for accessibility, the
/// position has to be divided by it as well -- on every platform, this time.
///
/// 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) -> CursorPosition {
scale_cursor_position(position, if cfg!(target_os = "windows") { scale_factor } else { 1.0 })
}
/// 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.
pub fn from_window_event(window_event: &WindowEvent, scale_factor: 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),
))
},
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),
))
},
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),
))
},
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,
2026-01-24 19:05:34 +00:00
GlobalShortcutPressed,
2026-07-18 15:42:38 +00:00
GlobalShortcutChanged,
}
2024-11-05 20:39:21 +00:00
/// Changes the location of the main window to the given URL.
pub async fn change_location_to(url: &str) {
// 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 let Ok(parsed_url) = tauri::Url::parse(url) && is_local_http_url(&parsed_url) {
*APPROVED_APP_URL.lock().unwrap() = Some(parsed_url);
}
2024-11-05 20:39:21 +00:00
let js_location_change = format!("window.location = '{url}';");
let main_window = main_window_spawn_clone.lock().unwrap();
let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str());
match location_change_result {
Ok(_) => info!("The app location was changed to {url}."),
Err(e) => error!("Failed to change the app location to {url}: {e}."),
}
}
/// Checks for updates.
pub async fn check_for_update(_token: APIToken) -> 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 {
2024-11-05 20:39:21 +00:00
update_is_available: false,
error: true,
2024-11-05 20:39:21 +00:00
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(""),
});
}
};
2024-11-05 20:39:21 +00:00
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(""),
})
}
2024-11-05 20:39:21 +00:00
Err(e) => {
warn!(Source = "Tauri"; "Updater: failed to update: {e}");
2024-11-05 20:39:21 +00:00
Json(CheckUpdateResponse {
update_is_available: false,
error: true,
new_version: String::from(""),
changelog: String::from(""),
})
}
2024-11-05 20:39:21 +00:00
}
}
/// 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(_token: APIToken) {
if let Some(reason) = self_update_blocked_reason(is_flatpak(), installation_kind()) {
warn!(Source = "Updater"; "Skipping update installation because {reason}.");
return;
}
2024-11-05 20:39:21 +00:00
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());
2024-11-05 20:39:21 +00:00
match cloned_response_option {
Some(update_response) => {
info!(Source = "Tauri"; "Updater: update is pending!");
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();
2026-06-02 15:22:59 +00:00
stop_qdrant_edge_database();
} 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}");
}
}
},
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.
2026-05-12 18:31:08 +00:00
pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
let app_handle = {
let main_window_lock = MAIN_WINDOW.lock().unwrap();
match main_window_lock.as_ref() {
Some(window) => window.app_handle().clone(),
None => {
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(),
})
}
2026-01-24 19:05:34 +00:00
/// Registers or updates a global shortcut. If the shortcut string is empty,
/// the existing shortcut for that name will be unregistered.
2026-05-12 18:31:08 +00:00
pub async fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
2026-07-18 15:42:38 +00:00
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
Json(crate::global_shortcuts::register(app_handle, event_sender, payload.0).await)
2026-01-24 19:05:34 +00:00
}
/// 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.
2026-05-12 18:31:08 +00:00
pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutRequest>) -> Json<ShortcutValidationResponse> {
2026-01-24 19:05:34 +00:00
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:
2026-07-18 15:42:38 +00:00
for (name, registered_shortcut) in crate::global_shortcuts::registered_shortcuts().await {
2026-01-24 19:05:34 +00:00
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(),
})
}
}
2026-07-18 15:42:38 +00:00
/// Suspends shortcut processing. Portal sessions remain active and ignore activations;
/// Tauri shortcuts are temporarily unregistered and restored on resume.
2026-01-24 19:05:34 +00:00
/// 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.
2026-05-12 18:31:08 +00:00
pub async fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
2026-07-18 15:42:38 +00:00
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
Json(crate::global_shortcuts::suspend(app_handle).await)
2026-01-24 19:05:34 +00:00
}
/// Resumes shortcut processing by re-registering all shortcuts with the OS.
2026-05-12 18:31:08 +00:00
pub async fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
2026-07-18 15:42:38 +00:00
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
Json(crate::global_shortcuts::resume(app_handle, event_sender).await)
2026-01-24 19:05:34 +00:00
}
/// 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
}
2026-05-12 18:31:08 +00:00
#[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);
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 a_window_event_we_do_not_care_about_is_not_channeled() {
assert!(Event::from_window_event(&WindowEvent::Destroyed, 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).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();
}
2026-05-12 18:31:08 +00:00
#[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 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(&current), 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(&current), 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(&current), 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));
}
2026-06-02 15:22:59 +00:00
}