2026-05-12 20:31:08 +02:00
use std ::convert ::Infallible ;
2026-06-11 09:44:56 +02:00
use std ::path ::{ Path , PathBuf };
2024-11-05 21:39:21 +01:00
use std ::sync ::Mutex ;
2026-09-10 15:28:08 +02:00
use std ::time ::{ Duration , Instant };
2026-05-12 20:31:08 +02: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 ;
2025-11-24 12:37:18 +01:00
use log ::{ debug , error , info , trace , warn };
2024-11-05 21:39:21 +01:00
use once_cell ::sync ::Lazy ;
2026-06-11 09:44:56 +02:00
use pdfium_render ::prelude ::Pdfium ;
2026-05-12 20:31:08 +02:00
use serde ::{ Deserialize , Serialize };
2026-07-19 22:48:30 +02:00
use tauri ::{ DragDropEvent , RunEvent , Manager , WindowEvent };
2026-05-06 18:45:50 +02:00
use tauri ::path ::PathResolver ;
use tauri ::WebviewWindow ;
2026-09-10 15:28:08 +02:00
use tauri ::PhysicalPosition ;
2026-05-06 18:45:50 +02:00
use tauri_plugin_updater ::{ UpdaterExt , Update };
use tauri_plugin_opener ::OpenerExt ;
2025-11-24 12:37:18 +01:00
use tokio ::sync ::broadcast ;
2024-11-05 21:39:21 +01:00
use tokio ::time ;
use crate ::api_token ::APIToken ;
2026-07-18 16:33:52 +02:00
use crate ::clipboard ::shutdown_clipboard ;
2026-02-03 14:32:17 +01:00
use crate ::dotnet ::{ cleanup_dotnet_server , start_dotnet_server , stop_dotnet_server };
2026-07-05 18:32:36 +02:00
use crate ::environment ::{
2026-08-15 14:04:45 +02:00
installation_kind , is_prod , is_dev , is_flatpak , InstallationKind , CONFIG_DIRECTORY ,
DATA_DIRECTORY , FLATPAK_LIBRARY_DIRECTORY ,
2026-07-05 18:32:36 +02:00
};
2024-11-05 21:39:21 +01:00
use crate ::log ::switch_to_file_logging ;
2025-05-03 12:20:22 +02:00
use crate ::pdfium ::PDFIUM_LIB_PATH ;
2026-06-02 17:22:59 +02:00
use crate ::qdrant_edge_database ::{ start_qdrant_edge_database , stop_qdrant_edge_database };
2026-07-18 17:42:38 +02:00
use crate ::global_shortcuts ::{ RegisterShortcutRequest , ShortcutResponse };
2026-07-13 17:15:55 +02:00
2026-02-03 14:32:17 +01:00
#[cfg(debug_assertions)]
use crate ::dotnet ::create_startup_env_file ;
2026-09-09 18:43:37 +02:00
use crate ::tokenizer ::set_default_tokenizer_path ;
2024-11-05 21:39:21 +01:00
2026-07-13 17:15:55 +02:00
#[cfg(target_os = "linux" )]
use webkit2gtk ::glib ::Cast ;
#[cfg(target_os = "linux" )]
use webkit2gtk ::{ PermissionRequestExt , UserMediaPermissionRequestExt };
2024-11-05 21:39:21 +01:00
/// The Tauri main window.
2026-05-06 18:45:50 +02:00
pub static MAIN_WINDOW : Lazy < Mutex < Option < WebviewWindow >>> = Lazy ::new ( || Mutex ::new ( None ));
2024-11-05 21:39:21 +01:00
/// The update response coming from the Tauri updater.
2026-05-06 18:45:50 +02:00
static CHECK_UPDATE_RESPONSE : Lazy < Mutex < Option < Update >>> = Lazy ::new ( || Mutex ::new ( None ));
2024-11-05 21:39:21 +01:00
2025-11-24 12:37:18 +01:00
/// The event broadcast sender for Tauri events.
static EVENT_BROADCAST : Lazy < Mutex < Option < broadcast ::Sender < Event >>>> = Lazy ::new ( || Mutex ::new ( None ));
2026-09-10 15:28:08 +02:00
/// 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 ));
2026-05-06 18:45:50 +02:00
/// 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 21:39:21 +01:00
/// Starts the Tauri app.
2026-07-19 22:48:30 +02:00
pub fn start_tauri ( tauri_context : tauri ::Context < tauri ::Wry > ) {
2024-11-05 21:39:21 +01:00
info! ( "Starting Tauri app..." );
2025-11-24 12:37:18 +01:00
// 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 21:39:21 +01:00
let app = tauri ::Builder ::default ()
2026-06-10 21:31:02 +02:00
. 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}" );
}
}))
2026-05-06 18:45:50 +02:00
. 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 21:39:21 +01:00
. setup ( move | app | {
2025-11-24 12:37:18 +01:00
// Get the main window:
2026-05-06 18:45:50 +02:00
let window = app . get_webview_window ( "main" ). expect ( "Failed to get main window." );
2025-11-24 12:37:18 +01:00
2026-07-13 17:15:55 +02:00
#[cfg(target_os = "linux" )]
register_linux_permission_request_handler ( & window );
2025-11-24 12:37:18 +01:00
// 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):
2026-09-10 15:28:08 +02:00
//
// 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 ();
2025-11-24 12:37:18 +01:00
window . on_window_event ( move | event | {
2026-09-10 15:28:08 +02:00
//
// 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 ;
};
2025-11-24 12:37:18 +01:00
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 21:39:21 +01:00
* MAIN_WINDOW . lock (). unwrap () = Some ( window );
info! ( Source = "Bootloader Tauri" ; "Setup is running." );
2026-05-06 18:45:50 +02:00
let data_path = app . path (). app_local_data_dir (). unwrap ();
2025-03-03 15:14:09 +01:00
let data_path = data_path . join ( "data" );
2024-11-05 21:39:21 +01:00
2025-11-24 12:37:18 +01:00
// Get and store the data and config directories:
2026-02-03 14:32:17 +01:00
DATA_DIRECTORY . set ( data_path . to_str (). unwrap (). to_string ()). map_err ( | _ | error! ( "Was not able to set the data directory." )). unwrap ();
2026-05-06 18:45:50 +02:00
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 21:39:21 +01:00
2026-02-03 14:32:17 +01:00
if is_dev () {
#[cfg(debug_assertions)]
create_startup_env_file ();
} else {
2026-03-14 12:25:58 +01:00
cleanup_dotnet_server ();
2026-05-06 18:45:50 +02:00
start_dotnet_server ( app . handle (). clone ());
2026-02-03 14:32:17 +01:00
}
2026-03-14 12:25:58 +01:00
2026-06-02 17:22:59 +02:00
start_qdrant_edge_database ( app . handle (). clone ());
2026-02-03 14:32:17 +01:00
2026-09-09 18:43:37 +02:00
set_default_tokenizer_path ( app . handle (). clone ());
2025-03-03 15:14:09 +01:00
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 ();
2026-05-06 18:45:50 +02:00
set_pdfium_path ( app . path ());
2026-02-03 14:32:17 +01:00
2024-11-05 21:39:21 +01:00
Ok (())
})
. plugin ( tauri_plugin_window_state ::Builder ::default (). build ())
2026-07-19 22:48:30 +02:00
. build ( tauri_context )
2024-11-05 21:39:21 +01:00
. expect ( "Error while running Tauri application" );
2025-11-24 12:37:18 +01:00
// The app event handler:
2026-05-06 18:45:50 +02:00
app . run ( | _app_handle , event | {
2025-11-24 12:37:18 +01:00
if ! matches! ( event , RunEvent ::MainEventsCleared ) {
debug! ( Source = "Tauri" ; "Tauri event received: location=app event handler , event={event:?}" );
}
2026-04-15 18:19:53 +02:00
2025-11-24 12:37:18 +01:00
match event {
RunEvent ::WindowEvent { event , label , .. } => {
match event {
WindowEvent ::CloseRequested { .. } => {
warn! ( Source = "Tauri" ; "Window '{label}': close was requested." );
}
2024-11-05 21:39:21 +01:00
2025-11-24 12:37:18 +01:00
WindowEvent ::Destroyed => {
warn! ( Source = "Tauri" ; "Window '{label}': was destroyed." );
}
2024-11-05 21:39:21 +01:00
2025-11-24 12:37:18 +01:00
_ => (),
2024-11-05 21:39:21 +01:00
}
}
2025-11-24 12:37:18 +01:00
RunEvent ::ExitRequested { .. } => {
warn! ( Source = "Tauri" ; "Run event: exit was requested." );
2026-07-18 16:33:52 +02:00
shutdown_clipboard ();
2026-06-02 17:22:59 +02:00
stop_qdrant_edge_database ();
2026-02-03 14:32:17 +01:00
if is_prod () {
warn! ( "Try to stop the .NET server as well..." );
stop_dotnet_server ();
}
2024-11-05 21:39:21 +01:00
}
2025-11-24 12:37:18 +01:00
RunEvent ::Ready => {
info! ( Source = "Tauri" ; "Run event: Tauri app is ready." );
}
2024-11-05 21:39:21 +01:00
2025-11-24 12:37:18 +01:00
_ => {}
2024-11-05 21:39:21 +01:00
}
});
warn! ( Source = "Tauri" ; "Tauri app was stopped." );
}
2026-05-06 18:45:50 +02:00
fn is_local_host ( host : Option <& str > ) -> bool {
matches! ( host , Some ( "localhost" ) | Some ( "127.0.0.1" ) | Some ( "::1" ) | Some ( "[::1]" ))
}
2026-05-08 13:28:18 +02:00
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 ())
}
2026-05-06 18:45:50 +02:00
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 ()
}
2026-07-13 17:15:55 +02:00
#[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}" );
}
}
2026-05-06 18:45:50 +02:00
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 ,
}
2026-05-08 13:28:18 +02:00
if is_tauri_asset_url ( url ) {
return false ;
}
2026-05-06 18:45:50 +02:00
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 ;
}
}
2026-05-16 18:53:53 +02:00
if let Ok ( current_url ) = webview . url () && same_origin ( & current_url , url ) {
return false ;
2026-05-06 18:45:50 +02:00
}
! is_local_host ( url . host_str ())
}
2025-11-24 12:37:18 +01:00
/// 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 20:31:08 +02:00
pub async fn get_event_stream ( _token : APIToken ) -> Response {
2025-11-24 12:37:18 +01:00
// 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 20:31:08 +02:00
let stream = stream! {
2025-11-24 12:37:18 +01:00
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 20:31:08 +02:00
yield Ok ::< Bytes , Infallible > ( Bytes ::from ( event_json ));
2025-11-24 12:37:18 +01:00
// The client expects a newline after each event because we are using
// a method to read the stream line-by-line:
2026-05-12 20:31:08 +02:00
yield Ok ::< Bytes , Infallible > ( Bytes ::from ( " \n " ));
2025-11-24 12:37:18 +01:00
},
// 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 20:31:08 +02:00
yield Ok ::< Bytes , Infallible > ( Bytes ::from ( event_json ));
2025-11-24 12:37:18 +01:00
// The client expects a newline after each event because we are using
// a method to read the stream line-by-line:
2026-05-12 20:31:08 +02:00
yield Ok ::< Bytes , Infallible > ( Bytes ::from ( " \n " ));
2025-11-24 12:37:18 +01:00
},
}
}
2026-05-12 20:31:08 +02:00
};
([( CONTENT_TYPE , "application/jsonl" )], Body ::from_stream ( stream )). into_response ()
2025-11-24 12:37:18 +01:00
}
2026-09-10 15:28:08 +02:00
/// 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 ;
}
2025-11-24 12:37:18 +01:00
/// 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 > ,
2026-09-10 15:28:08 +02:00
/// Where the cursor was, for the drag and drop events which know it.
#[serde(skip_serializing_if = "Option::is_none" )]
pub position : Option < CursorPosition > ,
2025-11-24 12:37:18 +01:00
}
/// 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 ,
2026-09-10 15:28:08 +02:00
position : None ,
2025-11-24 12:37:18 +01:00
}
}
2026-09-10 15:28:08 +02:00
/// 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 > {
2025-11-24 12:37:18 +01:00
match window_event {
2026-05-06 18:45:50 +02:00
WindowEvent ::DragDrop ( drop_event ) => {
2025-11-24 12:37:18 +01:00
match drop_event {
2026-09-10 15:28:08 +02:00
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 ;
}
2025-11-24 12:37:18 +01:00
2026-09-10 15:28:08 +02:00
* last_sent = Some ( now );
drop ( last_sent );
2025-11-24 12:37:18 +01:00
2026-09-10 15:28:08 +02:00
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 ,
2025-11-24 12:37:18 +01:00
}
},
WindowEvent ::Focused ( state ) => if * state {
2026-09-10 15:28:08 +02:00
Some ( Event ::new ( TauriEventType ::WindowFocused ,
Vec ::new (),
))
2025-11-24 12:37:18 +01:00
} else {
2026-09-10 15:28:08 +02:00
Some ( Event ::new ( TauriEventType ::WindowNotFocused ,
Vec ::new (),
))
2025-11-24 12:37:18 +01:00
},
2026-09-10 15:28:08 +02:00
//
// 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 ,
2025-11-24 12:37:18 +01:00
}
}
}
/// 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 ,
2026-09-10 15:28:08 +02:00
FileDropOver ,
2025-11-24 12:37:18 +01:00
FileDropDropped ,
FileDropCanceled ,
2026-01-24 20:05:34 +01:00
GlobalShortcutPressed ,
2026-07-18 17:42:38 +02:00
GlobalShortcutChanged ,
2025-11-24 12:37:18 +01:00
}
2024-11-05 21:39:21 +01: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 ;
}
}
2026-05-16 18:53:53 +02:00
if let Ok ( parsed_url ) = tauri ::Url ::parse ( url ) && is_local_http_url ( & parsed_url ) {
* APPROVED_APP_URL . lock (). unwrap () = Some ( parsed_url );
2026-05-06 18:45:50 +02:00
}
2024-11-05 21:39:21 +01: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 > {
2026-08-15 14:04:45 +02:00
if let Some ( reason ) = self_update_blocked_reason ( is_flatpak (), installation_kind ()) {
2026-07-13 13:35:24 +02:00
warn! ( Source = "Updater" ; "Skipping update check because {reason}." );
2025-09-25 19:47:18 +02:00
return Json ( CheckUpdateResponse {
update_is_available : false ,
error : false ,
new_version : String ::from ( "" ),
changelog : String ::from ( "" ),
});
}
2026-05-06 18:45:50 +02:00
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 21:39:21 +01:00
update_is_available : false ,
2026-05-06 18:45:50 +02:00
error : true ,
2024-11-05 21:39:21 +01:00
new_version : String ::from ( "" ),
changelog : String ::from ( "" ),
2026-05-06 18:45:50 +02:00
});
}
}
};
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 21:39:21 +01:00
2026-05-06 18:45:50 +02: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 21:39:21 +01:00
Err ( e ) => {
2026-05-06 18:45:50 +02:00
warn! ( Source = "Tauri" ; "Updater: failed to update: {e}" );
2024-11-05 21:39:21 +01:00
Json ( CheckUpdateResponse {
update_is_available : false ,
error : true ,
new_version : String ::from ( "" ),
changelog : String ::from ( "" ),
})
2026-05-06 18:45:50 +02:00
}
2024-11-05 21:39:21 +01: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 ) {
2026-08-15 14:04:45 +02:00
if let Some ( reason ) = self_update_blocked_reason ( is_flatpak (), installation_kind ()) {
2026-07-13 13:35:24 +02:00
warn! ( Source = "Updater" ; "Skipping update installation because {reason}." );
2025-09-25 19:47:18 +02:00
return ;
}
2024-11-05 21:39:21 +01:00
let cloned_response_option = CHECK_UPDATE_RESPONSE . lock (). unwrap (). clone ();
2026-05-06 18:45:50 +02:00
let app_handle = MAIN_WINDOW
. lock ()
. unwrap ()
. as_ref ()
. map ( | window | window . app_handle (). clone ());
2024-11-05 21:39:21 +01:00
match cloned_response_option {
Some ( update_response ) => {
2026-05-06 18:45:50 +02:00
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 17:22:59 +02:00
stop_qdrant_edge_database ();
2026-05-06 18:45:50 +02:00
} else {
warn! ( Source = "Tauri" ; "Development environment detected; do not stop the .NET server." );
}
},
). await ;
2025-11-11 15:30:17 +01:00
2026-05-06 18:45:50 +02:00
match result {
Ok ( _ ) => {
info! ( Source = "Tauri" ; "Updater: app has been updated" );
warn! ( Source = "Tauri" ; "Try to restart the app now..." );
2025-11-11 15:30:17 +01:00
2026-05-06 18:45:50 +02:00
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}" );
}
}
2025-11-11 15:30:17 +01:00
},
None => {
2026-05-06 18:45:50 +02:00
error! ( Source = "Updater" ; "No update available to install. Did you check for updates first?" );
2025-11-11 15:30:17 +01:00
},
}
}
2026-08-15 14:04:45 +02:00
/// 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" ),
}
2026-07-13 13:35:24 +02:00
}
2026-04-10 17:11:05 +02:00
/// 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 20:31:08 +02:00
pub async fn exit_app ( _token : APIToken ) -> Json < AppExitResponse > {
2026-05-06 18:45:50 +02:00
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 (),
});
}
2026-04-10 17:11:05 +02:00
}
};
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 20:05:34 +01: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 20:31:08 +02:00
pub async fn register_shortcut ( _token : APIToken , payload : Json < RegisterShortcutRequest > ) -> Json < ShortcutResponse > {
2026-07-18 17:42:38 +02: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 20:05:34 +01: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 20:31:08 +02:00
pub async fn validate_shortcut ( _token : APIToken , payload : Json < ValidateShortcutRequest > ) -> Json < ShortcutValidationResponse > {
2026-01-24 20:05:34 +01: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 17:42:38 +02:00
for ( name , registered_shortcut ) in crate ::global_shortcuts ::registered_shortcuts (). await {
2026-01-24 20:05:34 +01: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 17:42:38 +02:00
/// Suspends shortcut processing. Portal sessions remain active and ignore activations;
/// Tauri shortcuts are temporarily unregistered and restored on resume.
2026-01-24 20:05:34 +01: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 20:31:08 +02:00
pub async fn suspend_shortcuts ( _token : APIToken ) -> Json < ShortcutResponse > {
2026-07-18 17:42:38 +02: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 20:05:34 +01:00
}
/// Resumes shortcut processing by re-registering all shortcuts with the OS.
2026-05-12 20:31:08 +02:00
pub async fn resume_shortcuts ( _token : APIToken ) -> Json < ShortcutResponse > {
2026-07-18 17:42:38 +02: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 20:05:34 +01: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
}
2026-05-06 18:45:50 +02:00
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 ;
}
};
2025-05-02 23:09:50 +02:00
2026-07-05 18:32:36 +02:00
match select_pdfium_library_directory ( & resource_dir , is_flatpak ()) {
2026-06-11 09:44:56 +02:00
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." );
}
}
}
2026-07-05 18:32:36 +02:00
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" ));
2026-05-06 18:45:50 +02:00
2026-06-11 09:44:56 +02:00
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 );
2026-05-06 18:45:50 +02:00
}
2026-06-11 09:44:56 +02:00
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 (),
);
2026-05-06 18:45:50 +02:00
}
}
2026-06-11 09:44:56 +02:00
None
2026-03-14 12:25:58 +01:00
}
2026-05-12 20:31:08 +02:00
#[cfg(test)]
mod tests {
use super ::* ;
2026-06-11 09:44:56 +02:00
use std ::fs ;
2026-07-13 13:35:24 +02:00
#[test]
fn self_update_is_disabled_in_development () {
2026-08-15 14:04:45 +02:00
assert! ( self_update_blocked_reason ( false , InstallationKind ::Development ). is_some ());
2026-07-13 13:35:24 +02:00
}
#[test]
fn self_update_is_disabled_for_flatpak () {
2026-08-15 14:04:45 +02:00
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"
);
}
2026-07-13 13:35:24 +02:00
}
#[test]
fn self_update_is_enabled_for_normal_production_installations () {
2026-08-15 14:04:45 +02:00
assert! ( self_update_blocked_reason ( false , InstallationKind ::User ). is_none ());
2026-07-13 13:35:24 +02:00
}
2026-08-15 14:04:45 +02:00
2026-09-10 15:28:08 +02:00
#[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 ());
}
2026-06-11 09:44:56 +02:00
#[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! (
2026-07-05 18:32:36 +02:00
select_pdfium_library_directory ( temp_dir . path (), false ),
2026-06-11 09:44:56 +02:00
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! (
2026-07-05 18:32:36 +02:00
select_pdfium_library_directory ( temp_dir . path (), false ),
2026-06-11 09:44:56 +02:00
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 ();
2026-07-05 18:32:36 +02:00
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
);
2026-06-11 09:44:56 +02:00
}
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 20:31:08 +02: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 ));
}
2026-07-13 17:15:55 +02:00
#[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 17:22:59 +02:00
}