#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #[cfg(all(not(debug_assertions), not(feature = "custom-protocol")))] compile_error!("Release desktop builds require --features custom-protocol to embed the frontend"); mod ai_commands; mod appearance; mod data_directory; mod instance; mod media_preview; mod model_download; mod semantic_commands; mod settings; mod watching; use ai_commands::{generate_ai, prepare_ai_source, AiSession}; use searchi_core::metrics::{MetricsMode, SearchMetrics, SearchResponse, TraceCollector}; use searchi_core::{IndexReport, IndexStats, SearchIndex, SearchOptions, SearchResult}; use semantic_commands::{ hybrid_search, hybrid_search_measured, index_embeddings, semantic_status, setup_model, SemanticState, }; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use std::{ path::PathBuf, sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, }, }; use tauri::{Emitter, Manager, State}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; use tauri_plugin_opener::OpenerExt; struct AppState { appearance: Arc>, ai: Arc, database: PathBuf, lexical: Arc>, indexing: Arc, settings: Arc>, shortcut_status: String, semantic: Arc, watching: Arc, index_options: searchi_core::IndexOptions, document_worker: PathBuf, previews: Arc, } #[derive(Serialize)] struct MeasuredFailure { message: String, metrics: SearchMetrics, } impl MeasuredFailure { fn new(message: impl Into, trace: TraceCollector) -> Self { Self { message: message.into(), metrics: trace.finish(0), } } fn task(message: impl Into, started: std::time::Instant) -> Self { Self { message: message.into(), metrics: SearchMetrics { total_ms: started.elapsed().as_secs_f64() * 1000.0, result_count: 0, trace: None, dropped_spans: 1, }, } } } fn measured_outcome( result: Result, String>, trace: TraceCollector, ) -> Result, MeasuredFailure> { match result { Ok(results) => Ok(SearchResponse { metrics: trace.finish(results.len()), results, }), Err(message) => Err(MeasuredFailure::new(message, trace)), } } #[derive(Serialize)] struct DesktopStatus { appearance: appearance::AppearanceStatus, settings: Settings, indexing: bool, shortcut_status: String, watching: Vec, } #[tauri::command] fn desktop_status(state: State<'_, AppState>) -> Result { Ok(DesktopStatus { appearance: state .appearance .lock() .map_err(|_| "Appearance is unavailable.")? .clone(), settings: state .settings .lock() .map_err(|_| "Settings are unavailable.")? .value .clone(), indexing: state.indexing.load(Ordering::Acquire), shortcut_status: state.shortcut_status.clone(), watching: state.watching.status(), }) } #[tauri::command] fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result { if state.indexing.load(Ordering::Acquire) { return Err("Wait for indexing to finish before changing resource settings.".into()); } let mut store = state .settings .lock() .map_err(|_| "Settings are unavailable.")?; let mut next = store.value.clone(); next.cpu_threads = threads; next.validate()?; state .semantic .change_resources(state.database.clone(), threads, || store.save(next))?; Ok(store.value.clone()) } #[tauri::command] async fn set_appearance( material: settings::BackgroundMaterial, opacity: u8, high_contrast: bool, app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { { let mut store = state .settings .lock() .map_err(|_| "Settings are unavailable.")?; let mut next = store.value.clone(); next.background_material = material; next.background_opacity = opacity; store.save(next)?; } apply_saved_appearance( app, state.appearance.clone(), material, opacity, high_contrast, ) .await } #[tauri::command] async fn apply_appearance( high_contrast: bool, app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { let (material, opacity) = { let store = state .settings .lock() .map_err(|_| "Settings are unavailable.")?; ( store.value.background_material, store.value.background_opacity, ) }; apply_saved_appearance( app, state.appearance.clone(), material, opacity, high_contrast, ) .await } async fn apply_saved_appearance( app: tauri::AppHandle, current: Arc>, material: settings::BackgroundMaterial, opacity: u8, high_contrast: bool, ) -> Result { let window = app .get_webview_window("main") .ok_or("Main window is unavailable.")?; let (sender, receiver) = std::sync::mpsc::sync_channel(1); app.run_on_main_thread(move || { let status = appearance::apply(&window, material, opacity, high_contrast); let result = current .lock() .map_err(|_| "Appearance is unavailable.".to_string()) .map(|mut current| { *current = status.clone(); status }); let _ = sender.send(result); }) .map_err(|error| error.to_string())?; tauri::async_runtime::spawn_blocking(move || { receiver .recv_timeout(std::time::Duration::from_secs(5)) .map_err(|_| "Appearance update was cancelled.".to_string())? }) .await .map_err(|error| error.to_string())? } #[derive(Default, Deserialize)] struct Filters { #[serde(default)] code_only: bool, folder: Option, extension: Option, min_size: Option, max_size: Option, min_duration_ms: Option, max_duration_ms: Option, modified_after_unix_ms: Option, modified_before_unix_ms: Option, } impl From for SearchOptions { fn from(value: Filters) -> Self { Self { code_only: value.code_only, folder: value.folder, extension: value.extension, min_size: value.min_size, max_size: value.max_size, min_duration_ms: value.min_duration_ms, max_duration_ms: value.max_duration_ms, modified_after_unix_ms: value.modified_after_unix_ms, modified_before_unix_ms: value.modified_before_unix_ms, } } } // Each job owns its connection. Slow indexing never holds a desktop mutex. #[tauri::command] async fn search( query: String, filters: Option, state: State<'_, AppState>, ) -> Result, String> { let lexical = state.lexical.clone(); tauri::async_runtime::spawn_blocking(move || { lexical .try_lock() .map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned())? .search_with_options(&query, 50, &filters.unwrap_or_default().into()) .map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())? } /// Measured command preserves the legacy array-returning API for integrations. #[tauri::command] async fn search_measured( query: String, filters: Option, debug_performance: bool, state: State<'_, AppState>, ) -> Result, MeasuredFailure> { let started = std::time::Instant::now(); let mut trace = TraceCollector::new(if debug_performance { MetricsMode::Debug } else { MetricsMode::Basic }); let queue = trace.begin("desktop.worker_queue", None); let lexical = state.lexical.clone(); tauri::async_runtime::spawn_blocking(move || { trace.end(queue, Default::default()); let result = (|| { let index = trace.timed("desktop.lexical_lock", None, |_, _| { lexical .try_lock() .map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned()) })?; index .search_with_options_traced( &query, 50, &filters.unwrap_or_default().into(), &mut trace, None, ) .map_err(|error| error.to_string()) })(); measured_outcome(result, trace) }) .await .map_err(|error| MeasuredFailure::task(error.to_string(), started))? } #[tauri::command] async fn pick_folder(app: tauri::AppHandle) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { app.dialog() .file() .set_title("Choose a folder to index") .blocking_pick_folder() .map(|path| { path.into_path() .map(|path| path.to_string_lossy().into_owned()) .map_err(|e| e.to_string()) }) .transpose() }) .await .map_err(|e| e.to_string())? } // No broad opener permission is given to the webview. Every action is checked // against the index and the current filesystem before invoking a native opener. fn validated_result(index: &SearchIndex, path: &str) -> Result { let canonical = std::fs::canonicalize(path).map_err(|e| format!("File is unavailable: {e}"))?; if !canonical.is_file() { return Err("Result is no longer a file.".into()); } if !index .contains_path(&canonical.to_string_lossy()) .map_err(|e| e.to_string())? { return Err("Only files in your search index can be opened.".into()); } Ok(canonical) } fn preview_pixel_limit(format: Option) -> u64 { if format == Some(image::ImageFormat::Jpeg) { 64 * 1024 * 1024 } else { 16 * 1024 * 1024 } } #[derive(Serialize)] struct MediaPreview { path: String, kind: &'static str, } // Return a bounded opaque capability, never a lifetime asset-scope permission. #[tauri::command] async fn prepare_media_preview( path: String, state: State<'_, AppState>, ) -> Result { let database = state.database.clone(); let previews = state.previews.clone(); let admission = previews .admit() .ok_or("Media preview is busy; try again.")?; tauri::async_runtime::spawn_blocking(move || { let _admission = admission; let index = SearchIndex::open(&database).map_err(|error| error.to_string())?; let canonical = validated_result(&index, &path)?; let kind = match searchi_media::classify(&canonical) { Some(searchi_media::MediaKind::Image) => "image", Some(searchi_media::MediaKind::Audio) => "audio", Some(searchi_media::MediaKind::Video) => "video", None => return Err("This file is not a supported media preview.".into()), }; let token = previews.grant(database, canonical, kind)?; Ok(MediaPreview { path: token, kind }) }) .await .map_err(|error| error.to_string())? } #[tauri::command] async fn open_result( path: String, reveal: bool, app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { let database = state.database.clone(); tauri::async_runtime::spawn_blocking(move || { let index = SearchIndex::open(database).map_err(|e| e.to_string())?; let canonical = validated_result(&index, &path)?; if reveal { app.opener() .reveal_item_in_dir(canonical) .map_err(|e| e.to_string()) } else { app.opener() .open_path(canonical.to_string_lossy().into_owned(), None::<&str>) .map_err(|e| e.to_string()) } }) .await .map_err(|e| e.to_string())? } #[tauri::command] async fn stats(state: State<'_, AppState>) -> Result { let database = state.database.clone(); tauri::async_runtime::spawn_blocking(move || { SearchIndex::open(database) .and_then(|index| index.stats()) .map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())? } // Reset the gate on every exit, including extractor failures or task panics. struct IndexGuard(Arc); impl Drop for IndexGuard { fn drop(&mut self) { self.0.store(false, Ordering::Release); } } #[tauri::command] async fn index_folder(root: String, state: State<'_, AppState>) -> Result { if state .indexing .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { return Err("A folder is already being indexed. Wait for it to finish.".into()); } let guard = IndexGuard(state.indexing.clone()); let database = state.database.clone(); let settings = state.settings.clone(); let lexical = state.lexical.clone(); let watching = state.watching.clone(); let options = state.index_options.clone(); let document_worker = state.document_worker.clone(); tauri::async_runtime::spawn_blocking(move || { let _guard = guard; settings .lock() .map_err(|_| "Settings are unavailable.")? .add_root(std::path::Path::new(&root))?; watching.add_root(std::fs::canonicalize(&root).map_err(|error| error.to_string())?)?; let mut index = SearchIndex::open(database).map_err(|error| error.to_string())?; index.set_document_worker(document_worker); let report = index .index_folder_with_options(root, &options) .map_err(|error| error.to_string())?; index.prewarm().map_err(|error| error.to_string())?; *lexical .lock() .map_err(|_| "Keyword engine is unavailable.")? = index; Ok(report) }) .await .map_err(|e| e.to_string())? } /// Each reconciliation owns its connection; retained readers stay usable while /// extraction runs. The watcher retains dirty work when the shared gate is busy. fn start_folder_watching( app: tauri::AppHandle, database: PathBuf, lexical: Arc>, indexing: Arc, roots: Vec, options: searchi_core::IndexOptions, document_worker: PathBuf, ) -> Result { watching::WatchService::start(roots, options.excluded_paths.clone(), move |request| { if indexing .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { return watching::ScanOutcome::Busy; } let guard = IndexGuard(indexing.clone()); let _ = app.emit("folder-indexing-changed", ()); let result = (|| -> Result { let mut index = SearchIndex::open(&database).map_err(|error| error.to_string())?; index.set_document_worker(document_worker.clone()); let report = match request.paths { Some(paths) => index.index_paths_cancellable( &request.root, &paths, &options, &request.cancelled, ), None if request.media_hint => index.index_paths_cancellable( &request.root, std::slice::from_ref(&request.root), &options, &request.cancelled, ), None => index.index_folder_cancellable(&request.root, &options, &request.cancelled), } .map_err(|error| error.to_string())?; let changed = report.indexed > 0 || report.removed > 0; if changed && !request.cancelled.load(Ordering::Acquire) { index.prewarm().map_err(|error| error.to_string())?; *lexical .lock() .map_err(|_| "Keyword engine is unavailable.")? = index; } Ok(changed) })(); drop(guard); let _ = app.emit("folder-indexing-changed", ()); match result { Ok(changed) => { if changed { let _ = app.emit("search-index-changed", ()); } watching::ScanOutcome::Completed } Err(error) => watching::ScanOutcome::Failed(error), } }) } fn main() { let mut context = tauri::generate_context!(); context.config_mut().identifier = instance::profile_identifier( &context.config().identifier, std::env::var_os("SEARCHI_DATA_DIR"), ) .expect("searchi profile identity could not be resolved"); let previews = Arc::new(media_preview::PreviewRegistry::default()); let protocol_previews = previews.clone(); tauri::Builder::default() // Detect duplicates before any model, database, watcher or other plugin starts. .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { instance::focus_search(app); })) .register_asynchronous_uri_scheme_protocol("searchi-media", move |_context, request, responder| { let Some(admission) = protocol_previews.admit() else { responder.respond(media_preview::error_response(tauri::http::StatusCode::SERVICE_UNAVAILABLE)); return; }; let registry = protocol_previews.clone(); tauri::async_runtime::spawn_blocking(move || { let _admission = admission; responder.respond(registry.respond(request)); }); }) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().with_handler(|app, _, event| { if event.state == ShortcutState::Pressed { instance::focus_search(app); } }).build()) .setup(move |app| { let directory = data_directory::resolve( app.path().app_data_dir()?, std::env::var_os("SEARCHI_DATA_DIR"), )?; std::fs::create_dir_all(&directory)?; let database = directory.join("searchi.sqlite"); // Bundle resources live outside the executable directory on macOS. let document_worker = std::env::var_os("SEARCHI_EXTRACT_WORKER") .map(PathBuf::from) .unwrap_or(app.path().resource_dir()?.join(if cfg!(windows) { "searchi-extract-worker.exe" } else { "searchi-extract-worker" })); let lexical = Arc::new(Mutex::new(SearchIndex::open(&database)?)); let warm_database = database.clone(); let warm_target = lexical.clone(); tauri::async_runtime::spawn_blocking(move || { // Prepare a separate reader and swap it in briefly; foreground // queries never wait on the long statistics-preparation lock. if let Ok(mut index) = SearchIndex::open(warm_database) { if index.prewarm().is_ok() { if let Ok(mut current) = warm_target.lock() { *current = index; } } } }); let settings = SettingsStore::load(directory.join("settings.json")).map_err(std::io::Error::other)?; let development_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); let development_models = development_root.join(".searchi/models/embeddinggemma2"); let bundled_models = app.path().resource_dir()?.join("embeddinggemma2"); let model_directory = if let Some(configured) = settings.value.model_directory.clone() { configured } else if cfg!(debug_assertions) && development_models.is_dir() { development_models.canonicalize().unwrap_or(development_models) } else if searchi_assets::has_model_files(&bundled_models)? { bundled_models } else { searchi_assets::default_model_directory().unwrap_or_else(|_| directory.join("Models")) }; let worker_name = if cfg!(windows) { "searchi-gemma-worker.exe" } else { "searchi-gemma-worker" }; let development_worker = development_root.join(".searchi/runtime/cpu") .join(if cfg!(windows) { "Release" } else { "" }).join(worker_name); let worker = std::env::var_os("SEARCHI_EMBEDDING_WORKER").map(PathBuf::from) .unwrap_or_else(|| { if cfg!(debug_assertions) && development_worker.is_file() { development_worker.canonicalize().unwrap_or(development_worker) } else { app.path().resource_dir().unwrap_or_else(|_| directory.clone()).join(worker_name) } }); let media_tools = (|| -> Result { if let Some(tools) = searchi_media::FfmpegTools::from_environment() .map_err(|error| error.to_string())? { return Ok(tools); } let development_media = development_root.join(".searchi/media-runtime/bin"); let media_directory = if cfg!(debug_assertions) && development_media.is_dir() { development_media } else { app.path().resource_dir().map_err(|error| error.to_string())?.join("media-runtime/bin") }; let ffmpeg = media_directory.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }); let ffprobe = media_directory.join(if cfg!(windows) { "ffprobe.exe" } else { "ffprobe" }); searchi_media::FfmpegTools::new(ffmpeg, ffprobe).map_err(|_| { "The media decoder is missing from this installation. Repair or reinstall searchi.".into() }) })(); let mut ignored = vec![directory.clone(), model_directory.clone()]; if let Some(parent) = worker.parent().filter(|path| path.is_absolute()) { ignored.push(parent.to_path_buf()); } let index_options = searchi_core::IndexOptions { excluded_paths: ignored.iter() .map(|path| data_directory::canonical_exclusion(path)) .collect::>>()?, ..Default::default() }; let indexing = Arc::new(AtomicBool::new(false)); let watching = Arc::new(start_folder_watching( app.handle().clone(), database.clone(), lexical.clone(), indexing.clone(), settings.value.roots.clone(), index_options.clone(), document_worker.clone(), ).map_err(std::io::Error::other)?); let semantic = Arc::new( SemanticState::new(model_directory, worker, media_tools) .with_media_scratch(directory.join("MediaScratch")), ); semantic.begin_automatic_setup(database.clone(), settings.value.cpu_threads); #[cfg(target_os = "macos")] let shortcut = "Shift+Super+Space"; #[cfg(not(target_os = "macos"))] let shortcut = "Control+Shift+Space"; let wayland = cfg!(target_os = "linux") && (std::env::var("XDG_SESSION_TYPE").is_ok_and(|value| value == "wayland") || std::env::var_os("WAYLAND_DISPLAY").is_some()); let shortcut_status = if wayland { "Global shortcut unavailable on Wayland. Open Searchi from your application launcher.".into() } else { match app.global_shortcut().register(shortcut) { Ok(()) => format!("Global shortcut: {shortcut}"), Err(error) => format!("Global shortcut unavailable: {error}"), } }; let window = app.get_webview_window("main").ok_or_else(|| std::io::Error::other("Main window is unavailable."))?; let appearance = appearance::apply(&window, settings.value.background_material, settings.value.background_opacity, false); app.manage(AppState { appearance: Arc::new(Mutex::new(appearance)), ai: Arc::new(AiSession::default()), database, lexical, indexing, settings: Arc::new(Mutex::new(settings)), shortcut_status, semantic, watching, index_options, document_worker, previews: previews.clone(), }); Ok(()) }) .invoke_handler(tauri::generate_handler![ prepare_ai_source, generate_ai, search, search_measured, stats, index_folder, pick_folder, open_result, prepare_media_preview, desktop_status, set_cpu_threads, set_appearance, apply_appearance, semantic_status, setup_model, index_embeddings, hybrid_search, hybrid_search_measured ]) .build(context) .expect("Searchi desktop could not start") .run(|app, event| { if matches!(event, tauri::RunEvent::Exit) { // Release the worker-held AppHandle and owned watcher before // application state is torn down. app.state::().watching.stop(); } }); } #[cfg(test)] mod tests { use super::*; #[test] fn code_scope_defaults_off_and_combines_with_other_filters() { let legacy: Filters = serde_json::from_str(r#"{"extension":"rs"}"#).unwrap(); let legacy: SearchOptions = legacy.into(); assert!(!legacy.code_only); let scoped: Filters = serde_json::from_str(r#"{"code_only":true,"extension":"rs","min_size":10}"#).unwrap(); let scoped: SearchOptions = scoped.into(); assert!(scoped.code_only); assert_eq!(scoped.extension.as_deref(), Some("rs")); assert_eq!(scoped.min_size, Some(10)); assert!(serde_json::from_str::(r#"{"code_only":"true"}"#).is_err()); } #[test] fn code_scope_reaches_real_keyword_search_without_model_readiness() { let root = tempfile::tempdir().unwrap(); std::fs::write( root.path().join("parse.rs"), "fn parse_configuration() { /* validate config */ }", ) .unwrap(); std::fs::write( root.path().join("notes.md"), "parse_configuration validate config", ) .unwrap(); let mut index = SearchIndex::open(":memory:").unwrap(); index.index_folder(root.path()).unwrap(); let all: SearchOptions = Filters::default().into(); assert_eq!( index .search_with_options("parse_configuration", 10, &all) .unwrap() .len(), 2 ); let scoped: SearchOptions = serde_json::from_str::(r#"{"code_only":true}"#) .unwrap() .into(); let hits = index .search_with_options("parse_configuration", 10, &scoped) .unwrap(); assert_eq!(hits.len(), 1); assert_eq!(hits[0].filename, "parse.rs"); } #[test] fn measured_failure_retains_closed_trace_without_inventing_results() { let mut trace = TraceCollector::new(MetricsMode::Debug); let parent = trace.begin("desktop.request", None); trace.begin("desktop.lexical_lock", parent); let failed = measured_outcome(Err("Keyword worker is busy".into()), trace) .err() .unwrap(); assert_eq!(failed.message, "Keyword worker is busy"); assert_eq!(failed.metrics.result_count, 0); let spans = failed.metrics.trace.unwrap(); assert_eq!(spans.len(), 2); assert_eq!(spans[1].parent, Some(0)); assert!(spans[1].start_ms + spans[1].duration_ms <= failed.metrics.total_ms + 0.001); } #[test] fn measured_empty_success_is_distinct_from_failure_and_basic_has_no_trace() { let response = measured_outcome(Ok(Vec::new()), TraceCollector::new(MetricsMode::Basic)) .ok() .unwrap(); assert!(response.results.is_empty()); assert_eq!(response.metrics.result_count, 0); assert!(response.metrics.trace.is_none()); } #[test] fn indexing_gate_is_released_when_worker_unwinds() { let indexing = Arc::new(AtomicBool::new(true)); let gate = indexing.clone(); let result = std::panic::catch_unwind(move || { let _guard = IndexGuard(gate); panic!("extractor failure"); }); assert!(result.is_err()); assert!(!indexing.load(Ordering::Acquire)); } #[test] fn opener_rejects_unindexed_and_deleted_files() { let root = tempfile::tempdir().unwrap(); let indexed = root.path().join("indexed.txt"); std::fs::write(&indexed, "retrieval fixture").unwrap(); let mut index = SearchIndex::open(":memory:").unwrap(); index.index_folder(root.path()).unwrap(); assert!(validated_result(&index, &indexed.to_string_lossy()).is_ok()); let unindexed = root.path().join("new.txt"); std::fs::write(&unindexed, "not indexed").unwrap(); assert!(validated_result(&index, &unindexed.to_string_lossy()).is_err()); std::fs::remove_file(&indexed).unwrap(); assert!(validated_result(&index, &indexed.to_string_lossy()).is_err()); assert!(validated_result(&index, &root.path().to_string_lossy()).is_err()); } #[test] fn renamed_png_cannot_receive_the_larger_jpeg_preview_budget() { let root = tempfile::tempdir().unwrap(); let path = root.path().join("misnamed.jpg"); let image = image::GrayImage::new(4097, 4096); image .save_with_format(&path, image::ImageFormat::Png) .unwrap(); let reader = image::ImageReader::open(&path) .unwrap() .with_guessed_format() .unwrap(); assert_eq!(reader.format(), Some(image::ImageFormat::Png)); let limit = preview_pixel_limit(reader.format()); let (width, height) = reader.into_dimensions().unwrap(); assert!(u64::from(width) * u64::from(height) > limit); assert!( u64::from(width) * u64::from(height) < preview_pixel_limit(Some(image::ImageFormat::Jpeg)) ); } }