From fb8b45dcc96e0925f5893977a7543152812e3873 Mon Sep 17 00:00:00 2001 From: karl Date: Tue, 13 Oct 2026 11:43:20 +0200 Subject: [PATCH] Prevent duplicate desktop model sessions and record calibration evidence --- apps/desktop/src-tauri/Cargo.lock | 18 ++++++ apps/desktop/src-tauri/Cargo.toml | 2 + apps/desktop/src-tauri/src/instance.rs | 78 ++++++++++++++++++++++++++ apps/desktop/src-tauri/src/main.rs | 19 +++++-- docs/RELEVANCE-CALIBRATION.md | 34 ++++++++++- docs/ROADMAP.md | 9 ++- docs/SINGLE-INSTANCE.md | 56 ++++++++++++++++++ 7 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src-tauri/src/instance.rs create mode 100644 docs/SINGLE-INSTANCE.md diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 77b3511..4aeddfb 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3814,11 +3814,13 @@ dependencies = [ "searchi-service", "serde", "serde_json", + "sha2 0.10.9", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tempfile", "uuid", "window-vibrancy", @@ -4586,6 +4588,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.12.1" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 56f9d43..5e1f7c3 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-plugin-opener = "2" tauri-plugin-global-shortcut = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" tempfile = "3" searchi-core = { path = "../../../crates/searchi-core" } searchi-service = { path = "../../../crates/searchi-service" } @@ -30,6 +31,7 @@ searchi-media = { path = "../../../crates/searchi-media" } searchi-ai = { path = "../../../crates/searchi-ai" } uuid = { version = "1", features = ["v4"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "bmp"] } +tauri-plugin-single-instance = "2" [target.'cfg(windows)'.dependencies] window-vibrancy = "=0.8.1" diff --git a/apps/desktop/src-tauri/src/instance.rs b/apps/desktop/src-tauri/src/instance.rs new file mode 100644 index 0000000..f796ae4 --- /dev/null +++ b/apps/desktop/src-tauri/src/instance.rs @@ -0,0 +1,78 @@ +//! Official single-instance transport, scoped to an explicitly selected profile. +use sha2::{Digest, Sha256}; +use std::{ffi::OsString, io, path::PathBuf}; +use tauri::{Emitter, Manager}; + +pub fn profile_identifier(base: &str, profile: Option) -> io::Result { + let Some(profile) = profile else { + return Ok(base.to_owned()); + }; + let path = crate::data_directory::resolve(PathBuf::new(), Some(profile))?; + // Resolve the existing ancestor without creating a first-use profile. + let canonical = crate::data_directory::canonical_exclusion(&path)?; + let mut digest = Sha256::new(); + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + for unit in canonical.as_os_str().encode_wide() { + digest.update(unit.to_le_bytes()); + } + } + #[cfg(not(windows))] + { + use std::os::unix::ffi::OsStrExt; + digest.update(canonical.as_os_str().as_bytes()); + } + Ok(format!("{base}.profile{:x}", digest.finalize())) +} + +pub fn focus_search(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let result = window + .show() + .and_then(|_| window.unminimize()) + .and_then(|_| window.set_focus()); + if let Err(error) = result { + eprintln!("Could not focus searchi: {error}"); + } + let _ = window.emit("focus-search", ()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn default_identity_is_unchanged_and_invalid_profile_rejected() { + assert_eq!( + profile_identifier("dev.searchi.desktop", None).unwrap(), + "dev.searchi.desktop" + ); + assert!(profile_identifier("dev.searchi.desktop", Some("relative".into())).is_err()); + } + #[test] + fn canonical_profiles_are_stable_distinct_and_do_not_expose_paths() { + let root = tempfile::tempdir().unwrap(); + let first = root.path().join("Gemäß_日本_😀"); + let second = root.path().join("other"); + let id = profile_identifier("dev.searchi.desktop", Some(first.clone().into_os_string())) + .unwrap(); + assert!(!first.exists()); + std::fs::create_dir(&first).unwrap(); + assert_eq!( + id, + profile_identifier( + "dev.searchi.desktop", + Some(first.join(".").into_os_string()) + ) + .unwrap() + ); + assert_ne!( + id, + profile_identifier("dev.searchi.desktop", Some(second.into_os_string())).unwrap() + ); + assert!(!id.contains("Gemäß")); + assert_eq!(id.len(), "dev.searchi.desktop.profile".len() + 64); + assert!(!root.path().join("other").exists()); + } +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 44117f1..57f0039 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -6,6 +6,7 @@ compile_error!("Release desktop builds require --features custom-protocol to emb mod ai_commands; mod appearance; mod data_directory; +mod instance; mod model_download; mod semantic_commands; mod settings; @@ -545,16 +546,22 @@ fn start_folder_watching( } 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"); 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); + })) .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 { - if let Some(window) = app.get_webview_window("main") { - let result = window.show().and_then(|_| window.unminimize()).and_then(|_| window.set_focus()); - if let Err(error) = result { eprintln!("Could not focus Searchi: {error}"); } - let _ = window.emit("focus-search", ()); - } + instance::focus_search(app); } }).build()) .setup(|app| { @@ -687,7 +694,7 @@ fn main() { hybrid_search, hybrid_search_measured ]) - .build(tauri::generate_context!()) + .build(context) .expect("Searchi desktop could not start") .run(|app, event| { if matches!(event, tauri::RunEvent::Exit) { diff --git a/docs/RELEVANCE-CALIBRATION.md b/docs/RELEVANCE-CALIBRATION.md index 5ff62d6..1dae49f 100644 --- a/docs/RELEVANCE-CALIBRATION.md +++ b/docs/RELEVANCE-CALIBRATION.md @@ -14,8 +14,9 @@ an exploratory topic query, so these absence labels require concrete artifacts. The source inventory and blinded review have been completed. Independent AI review agreed with all expected file sets; the final labels are frozen in `fixture.json` with hashes and a UTC timestamp in `fixture.lock.json`. -No retrieval run, threshold selection or ranking change has occurred. Agent -agreement will not be described as human review. +The calibration-only baseline has run with genuine Google CPU inference. +No threshold selection or ranking change has occurred. Agent agreement will +not be described as human review. The first independent agent review detected Windows-1252 mojibake in the authored German requests and ambiguity in the wildcard-comparator request. @@ -46,3 +47,32 @@ why instead of altering this diagnostic silently. This small corpus does not close independent human relevance, media abstention, music discrimination, speech localisation or production-scale quality gates. + +## Recorded CPU baseline, 2026-10-13 + +The shared-engine runner completed all 24 calibration requests plus six code +intent requests repeated under Code scope. Held-out requests were not evaluated. +All 14 frozen sources remained available as distractors. + +| Scope | Positive requests | Lexical top-1 | Semantic / hybrid top-1 | Absent requests returning semantic / hybrid neighbours | +| --- | --- | --- | --- | --- | +| All files | 16 | 1/16 | 16/16 | 8/8 | +| Additional Code scope requests | 4 | 0/4 | 4/4 | 2/2 | + +The additional code rows repeat intents; they are not independent observations. +Lexical search returned no results for the absent requests. Semantic similarity +still retrieves related neighbours when the requested source is absent. This +baseline therefore supports useful positive retrieval, but does not establish +reliable abstention or justify a cutoff chosen without further calibration. + +Ignored raw evidence: `.searchi/qa/calibration-v2-baseline.json`, SHA-256 +`56f42c523527dd748918bb8f17be6bd7818158227e692fccaf387d915f9d0a94`. +The report records completion, calibration split and `held_out_evaluated: false`. +Frozen fixture SHA-256: +`000047fd17accd315b6138093bbdc9f353cd877b4bf1958fbe336e4a6b10cd2e`. + +This ordered debug diagnostic used one CPU thread and the normal worker +`c01cb3ec13cfca722ee768e8350e1399a8b9a4d14a24a35adc3473a5cfc82a73`. +Lexical indexing took 19.9823 ms, semantic indexing 74,545.518 ms and retained +query-session startup 7,433.3914 ms. These are diagnostic setup measurements, +not native first-search latency or warm production p95. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 041308a..220aaf4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Production readiness roadmap -Status: implementation started; no production release yet. Windows VM tests use CPU only. +Status: initial Windows v0.1.0 published and installed; broader production gates remain open. Windows VM tests use CPU only. Karl's release priority, 2026-10-13: publish a usable first Windows version and install it on DEV-WIN11 now. Extensive platform/GPU, clean-machine, signing and @@ -43,6 +43,9 @@ release. Keep their evidence and unchecked production gates intact. see [incremental semantic design](INCREMENTAL-SEMANTIC.md). ## 3. Desktop +- [x] Official single-instance transport with isolated explicit profiles; + Windows CPU duplicate launch exits without a second query worker. + See [launch evidence](SINGLE-INSTANCE.md); visual focus and other OS checks remain open. - [x] Persisted solid/glass/frosted appearance and bounded background tint; opaque reading surfaces, serialized settings updates and high-contrast fallback. - [ ] Verify native glass visually on Windows 11 22H2+; macOS/Linux currently @@ -250,7 +253,9 @@ Completion requires recorded evidence; targets are not measured results. after independent AI review; verify exact Git-preserved source/label bytes. - [x] Add a shared-engine calibration-only runner retaining raw cosine, lexical provenance, hybrid ranks and atomic incomplete evidence. - - [ ] Execute calibration, freeze the proposed relevance policy, then + - [x] Execute the frozen calibration-only baseline with genuine Google CPU + inference; preserve raw results and leave held-out requests unevaluated. + - [ ] Freeze the proposed relevance policy, then evaluate held-out queries without tuning against their results. - [x] Explicit English/German code intent vetoes exclusive audio routing; actual Google CPU regression preserves code among qualifying audio candidates. diff --git a/docs/SINGLE-INSTANCE.md b/docs/SINGLE-INSTANCE.md new file mode 100644 index 0000000..363c899 --- /dev/null +++ b/docs/SINGLE-INSTANCE.md @@ -0,0 +1,56 @@ +# Desktop launch behavior + +Launching searchi again shows, restores and focuses the existing main window, +then focuses its search field through the existing `focus-search` event. It does +not interpret arguments, open supplied files, or start another index/model session. + +The [official Tauri single-instance plugin](https://v2.tauri.app/plugin/single-instance/) +is registered first, before other plugins and application setup. Its native +transport provides duplicate detection; searchi does not maintain its own mutex. + +Normal launches retain `dev.searchi.desktop`. An explicit absolute +`SEARCHI_DATA_DIR` creates an isolated desktop profile. Before plugin setup, +searchi resolves its canonical existing ancestor and any missing suffix without +creating the directory, then appends a SHA-256 of its native path +bytes to the runtime identifier. Windows uses UTF-16 units; Unix uses raw path +bytes. The identifier does not disclose the profile path. This also isolates +identifier-derived WebView/app-data locations. Same canonical profiles converge; +different profiles can run concurrently. This override is a developer/portable +profile mechanism, not a command-line search interface. + +The plugin owns its Windows mutex, Linux D-Bus registration, or macOS socket. +Independent Linux D-Bus sessions have independent instances. Snap/Flatpak need +the upstream-documented D-Bus permissions; those packages remain unvalidated. +The previously published v0.1.0 application predates this change and cannot be +retroactively notified by the plugin. + +## Validation + +- Unit checks cover unchanged normal identity, rejected relative profiles, + Unicode profile paths, canonical aliases, stable/different identities and + absence of readable paths in identifiers. +- Native duplicate-launch behavior and window restoration require a running + desktop; platform evidence is recorded separately from unit checks. +- Windows VM inference tests must force CPU. No native macOS/Linux duplicate + launch or GPU claim is implied by the implementation. + +### Windows VM evidence, 2026-10-13 + +- Desktop tests: 35 passed. Final strengthened profile tests: 2 passed. + Strict desktop all-target Clippy and formatting passed. +- Owned custom-protocol debug executable SHA-256 + `93a22d5949fc0837f23e8455497b11333499bf41a5650b3f88493d5142de627f`. +- Fresh isolated profile A launched as PID 10624 using the normal bundled + worker SHA-256 `c01cb3ec13cfca722ee768e8350e1399a8b9a4d14a24a35adc3473a5cfc82a73`. + Its genuine Google CPU query worker PID 14520 remained identical before and + after duplicate PID 19240 exited successfully with code 0. The worker command + retained `--threads 1 --device cpu --role query`. +- Concurrent distinct profile B PID 12096 remained alive alongside profile A. + Profile B deliberately used an unavailable worker to avoid another inference + model; this proves desktop profile isolation, not semantic readiness there. +- Only owned test process trees were terminated afterward. Installed user app + PID 7716 remained alive and was never upgraded, focused or terminated by the + test. Raw local evidence: `.searchi/qa/single-instance/normal-result.json` and + `isolation-result.json` (ignored). +- This is process/lifecycle evidence. Native visual focus, restoration of a + minimized window and frontend search-field focus remain unverified.