Keep searchi resident with a global search shortcut and tray
This commit is contained in:
1 parent
54de583430
commit
24e80edaf9
9 files changed
+151
-11
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "searchi-desktop",
|
"name": "searchi-desktop",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "searchi-desktop",
|
"name": "searchi-desktop",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/svg-icons": "1.1.344",
|
"@fluentui/svg-icons": "1.1.344",
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "searchi-desktop",
|
"name": "searchi-desktop",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -3799,7 +3799,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "searchi-desktop"
|
name = "searchi-desktop"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"image",
|
"image",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "searchi-desktop"
|
name = "searchi-desktop"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Local Searchi desktop shell"
|
description = "Local Searchi desktop shell"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -16,7 +16,7 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
notify = "=8.2.0"
|
notify = "=8.2.0"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
|
|||||||
@@ -3,6 +3,46 @@ use sha2::{Digest, Sha256};
|
|||||||
use std::{ffi::OsString, io, path::PathBuf};
|
use std::{ffi::OsString, io, path::PathBuf};
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
|
/// Login launches stay resident without interrupting the user's desktop.
|
||||||
|
pub fn background_requested(args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>) -> 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<OsString>) -> io::Result<String> {
|
pub fn profile_identifier(base: &str, profile: Option<OsString>) -> io::Result<String> {
|
||||||
let Some(profile) = profile else {
|
let Some(profile) = profile else {
|
||||||
return Ok(base.to_owned());
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[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() {
|
fn default_identity_is_unchanged_and_invalid_profile_rejected() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
profile_identifier("dev.searchi.desktop", None).unwrap(),
|
profile_identifier("dev.searchi.desktop", None).unwrap(),
|
||||||
|
|||||||
@@ -553,6 +553,17 @@ fn start_folder_watching(
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut context = tauri::generate_context!();
|
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_mut().identifier = instance::profile_identifier(
|
||||||
&context.config().identifier,
|
&context.config().identifier,
|
||||||
std::env::var_os("SEARCHI_DATA_DIR"),
|
std::env::var_os("SEARCHI_DATA_DIR"),
|
||||||
@@ -562,8 +573,10 @@ fn main() {
|
|||||||
let protocol_previews = previews.clone();
|
let protocol_previews = previews.clone();
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
// Detect duplicates before any model, database, watcher or other plugin starts.
|
// Detect duplicates before any model, database, watcher or other plugin starts.
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
|
if !instance::background_requested(args) {
|
||||||
instance::focus_search(app);
|
instance::focus_search(app);
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
.register_asynchronous_uri_scheme_protocol("searchi-media", move |_context, request, responder| {
|
.register_asynchronous_uri_scheme_protocol("searchi-media", move |_context, request, responder| {
|
||||||
let Some(admission) = protocol_previews.admit() else {
|
let Some(admission) = protocol_previews.admit() else {
|
||||||
@@ -584,6 +597,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}).build())
|
}).build())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
|
instance::install_tray(app)?;
|
||||||
let directory = data_directory::resolve(
|
let directory = data_directory::resolve(
|
||||||
app.path().app_data_dir()?,
|
app.path().app_data_dir()?,
|
||||||
std::env::var_os("SEARCHI_DATA_DIR"),
|
std::env::var_os("SEARCHI_DATA_DIR"),
|
||||||
@@ -716,7 +730,25 @@ speech_worker, directory.join("SpeechModels")),
|
|||||||
});
|
});
|
||||||
Ok(())
|
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![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
instance::hide_search,
|
||||||
prepare_ai_source,
|
prepare_ai_source,
|
||||||
generate_ai,
|
generate_ai,
|
||||||
search,
|
search,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "searchi",
|
"productName": "searchi",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"identifier": "dev.searchi.desktop",
|
"identifier": "dev.searchi.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -21,13 +21,22 @@
|
|||||||
],
|
],
|
||||||
"security": {
|
"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",
|
"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": {
|
"bundle": {
|
||||||
"active": false,
|
"active": false,
|
||||||
"windows": { "nsis": { "installerHooks": "installer-hooks.nsh" } },
|
"windows": {
|
||||||
"resources": { "resources/": "" },
|
"nsis": {
|
||||||
|
"installerHooks": "installer-hooks.nsh"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"resources/": ""
|
||||||
|
},
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -1555,6 +1555,19 @@ document.addEventListener("keydown", (event) => {
|
|||||||
query.select();
|
query.select();
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
|
if (isNativeDesktop()) {
|
||||||
|
event.preventDefault();
|
||||||
|
const popovers =
|
||||||
|
document.querySelectorAll<HTMLDetailsElement>(".popover[open]");
|
||||||
|
if (popovers.length) {
|
||||||
|
popovers.forEach((popover) => {
|
||||||
|
popover.open = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
void invoke("hide_search").catch(showError);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearMedia();
|
clearMedia();
|
||||||
document
|
document
|
||||||
.querySelectorAll<HTMLDetailsElement>(".popover[open]")
|
.querySelectorAll<HTMLDetailsElement>(".popover[open]")
|
||||||
@@ -1618,6 +1631,11 @@ async function initializeSurface() {
|
|||||||
queueSemanticRefresh(0);
|
queueSemanticRefresh(0);
|
||||||
}).catch(showError);
|
}).catch(showError);
|
||||||
if (isNativeDesktop()) {
|
if (isNativeDesktop()) {
|
||||||
|
void listen("search-hidden", () => {
|
||||||
|
element("media-preview")
|
||||||
|
.querySelectorAll<HTMLMediaElement>("audio,video")
|
||||||
|
.forEach((player) => player.pause());
|
||||||
|
}).catch(showError);
|
||||||
void listen("folder-indexing-changed", () => {
|
void listen("folder-indexing-changed", () => {
|
||||||
void refreshDesktopStatus().catch(showError);
|
void refreshDesktopStatus().catch(showError);
|
||||||
queueSemanticRefresh(0);
|
queueSemanticRefresh(0);
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in new issue
Block a user