From 24e80edaf9c1a50cb3dde97ef21b211b52c66f73 Mon Sep 17 00:00:00 2001 From: karl Date: Tue, 13 Oct 2026 22:18:01 +0200 Subject: [PATCH] Keep searchi resident with a global search shortcut and tray --- apps/desktop/package-lock.json | 4 +- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 4 +- apps/desktop/src-tauri/src/instance.rs | 58 ++++++++++++++++++++++++++ apps/desktop/src-tauri/src/main.rs | 36 +++++++++++++++- apps/desktop/src-tauri/tauri.conf.json | 17 ++++++-- apps/desktop/src/main.ts | 18 ++++++++ docs/WINDOWS-0.1.7.md | 23 ++++++++++ 9 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 docs/WINDOWS-0.1.7.md diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 4d1de2a..1413806 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "searchi-desktop", - "version": "0.1.6", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "searchi-desktop", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@fluentui/svg-icons": "1.1.344", "@fontsource-variable/inter": "5.3.0", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2356187..2fc3b03 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "searchi-desktop", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 3ef4528..d2cf360 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3799,7 +3799,7 @@ dependencies = [ [[package]] name = "searchi-desktop" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "image", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index b1afe1c..2f69b6a 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "searchi-desktop" -version = "0.1.6" +version = "0.1.7" edition = "2021" description = "Local Searchi desktop shell" license = "MIT" @@ -16,7 +16,7 @@ tauri-build = { version = "2", features = [] } [dependencies] anyhow = "1" notify = "=8.2.0" -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-global-shortcut = "2" diff --git a/apps/desktop/src-tauri/src/instance.rs b/apps/desktop/src-tauri/src/instance.rs index f796ae4..41549fa 100644 --- a/apps/desktop/src-tauri/src/instance.rs +++ b/apps/desktop/src-tauri/src/instance.rs @@ -3,6 +3,46 @@ use sha2::{Digest, Sha256}; use std::{ffi::OsString, io, path::PathBuf}; use tauri::{Emitter, Manager}; +/// Login launches stay resident without interrupting the user's desktop. +pub fn background_requested(args: impl IntoIterator>) -> bool { + args.into_iter() + .any(|argument| argument.as_ref() == "--background") +} + +pub fn install_tray(app: &tauri::App) -> tauri::Result<()> { + use tauri::menu::{Menu, MenuItem}; + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + let show = MenuItem::with_id(app, "show-search", "Show searchi", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit-searchi", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &quit])?; + let mut tray = TrayIconBuilder::with_id("searchi-resident") + .tooltip("searchi") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "show-search" => focus_search(app), + "quit-searchi" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + ) { + focus_search(tray.app_handle()); + } + }); + if let Some(icon) = app.default_window_icon() { + tray = tray.icon(icon.clone()); + } + tray.build(app)?; + Ok(()) +} + pub fn profile_identifier(base: &str, profile: Option) -> io::Result { let Some(profile) = profile else { return Ok(base.to_owned()); @@ -39,10 +79,28 @@ pub fn focus_search(app: &tauri::AppHandle) { } } +#[tauri::command] +pub fn hide_search(app: tauri::AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or_else(|| "Search window is unavailable.".to_owned())?; + window.hide().map_err(|error| error.to_string())?; + window + .emit("search-hidden", ()) + .map_err(|error| error.to_string()) +} + #[cfg(test)] mod tests { use super::*; #[test] + fn background_flag_is_explicit_and_exact() { + assert!(background_requested(["searchi", "--background"])); + assert!(!background_requested(["searchi", "--background=false"])); + assert!(!background_requested(["searchi", "a--background"])); + assert!(!background_requested(["searchi"])); + } + #[test] fn default_identity_is_unchanged_and_invalid_profile_rejected() { assert_eq!( profile_identifier("dev.searchi.desktop", None).unwrap(), diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 8c96466..8eb838f 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -553,6 +553,17 @@ fn start_folder_watching( fn main() { let mut context = tauri::generate_context!(); + if instance::background_requested(std::env::args_os()) { + if let Some(window) = context + .config_mut() + .app + .windows + .iter_mut() + .find(|window| window.label == "main") + { + window.visible = false; + } + } context.config_mut().identifier = instance::profile_identifier( &context.config().identifier, std::env::var_os("SEARCHI_DATA_DIR"), @@ -562,8 +573,10 @@ fn main() { 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); + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + if !instance::background_requested(args) { + instance::focus_search(app); + } })) .register_asynchronous_uri_scheme_protocol("searchi-media", move |_context, request, responder| { let Some(admission) = protocol_previews.admit() else { @@ -584,6 +597,7 @@ fn main() { } }).build()) .setup(move |app| { + instance::install_tray(app)?; let directory = data_directory::resolve( app.path().app_data_dir()?, std::env::var_os("SEARCHI_DATA_DIR"), @@ -716,7 +730,25 @@ speech_worker, directory.join("SpeechModels")), }); Ok(()) }) + .on_window_event(|window, event| { + if window.label() == "main" { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + // Keep indexing, prepared readers and the global shortcut alive. + // The tray's explicit Quit action still exits normally. + match window.hide() { + Ok(()) => { + api.prevent_close(); + if let Err(error) = window.emit("search-hidden", ()) { + eprintln!("Could not notify hidden searchi: {error}"); + } + } + Err(error) => eprintln!("Could not hide searchi: {error}"), + } + } + } + }) .invoke_handler(tauri::generate_handler![ + instance::hide_search, prepare_ai_source, generate_ai, search, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 12ea296..b6ba003 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "searchi", - "version": "0.1.6", + "version": "0.1.7", "identifier": "dev.searchi.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -21,13 +21,22 @@ ], "security": { "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; img-src 'self' data: searchi-media: http://searchi-media.localhost https://searchi-media.localhost; media-src 'self' searchi-media: http://searchi-media.localhost https://searchi-media.localhost", - "assetProtocol": { "enable": false, "scope": [] } + "assetProtocol": { + "enable": false, + "scope": [] + } } }, "bundle": { "active": false, - "windows": { "nsis": { "installerHooks": "installer-hooks.nsh" } }, - "resources": { "resources/": "" }, + "windows": { + "nsis": { + "installerHooks": "installer-hooks.nsh" + } + }, + "resources": { + "resources/": "" + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 5c8278c..1cacaaf 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1555,6 +1555,19 @@ document.addEventListener("keydown", (event) => { query.select(); } if (event.key === "Escape") { + if (isNativeDesktop()) { + event.preventDefault(); + const popovers = + document.querySelectorAll(".popover[open]"); + if (popovers.length) { + popovers.forEach((popover) => { + popover.open = false; + }); + } else { + void invoke("hide_search").catch(showError); + } + return; + } clearMedia(); document .querySelectorAll(".popover[open]") @@ -1618,6 +1631,11 @@ async function initializeSurface() { queueSemanticRefresh(0); }).catch(showError); if (isNativeDesktop()) { + void listen("search-hidden", () => { + element("media-preview") + .querySelectorAll("audio,video") + .forEach((player) => player.pause()); + }).catch(showError); void listen("folder-indexing-changed", () => { void refreshDesktopStatus().catch(showError); queueSemanticRefresh(0); diff --git a/docs/WINDOWS-0.1.7.md b/docs/WINDOWS-0.1.7.md new file mode 100644 index 0000000..91b44bc --- /dev/null +++ b/docs/WINDOWS-0.1.7.md @@ -0,0 +1,23 @@ +# Windows resident launcher + +Version 0.1.7 adds a global launcher workflow. `Ctrl+Shift+Space` restores the +window and selects the search field. Escape dismisses a popover first, then +hides the native window. Closing the window also hides it, keeping prepared +search readers and the shortcut alive. Hidden audio/video previews pause. +The tray provides Show searchi and an explicit Quit action. + +`--background` starts without showing the main window; a duplicate background +launch does not interrupt an existing session. Karl's Windows Dev installation +now has a login shortcut using this flag and the existing CPU-only VM wrapper. +Production acceleration remains automatic; the wrapper is a VM constraint. + +Validation: three native instance tests and strict desktop Clippy passed. +Frontend build, 62 tests and formatting passed. The release executable compiled +and was installed as 0.1.7, then observed with `--background`. Settings remained +byte-identical after a consistent SQLite backup; database quick_check passed. +The updated normal speech runtime was verified/staged and passed its installed +self-test with forced CPU. The built-in key uses the existing focus handler. + +Actual native keyboard/tray interaction was not automated; available computer +control does not support it in this session. These checks do not close broader +GPU, macOS, clean-machine or search-quality gates. Release packaging is pending.