diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f409c..8997f67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,47 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); versions correspond to [GitHub Releases](https://github.com/archdex-art/SuperSearch/releases) and their published installers. +## [0.1.15] — 2026-07-17 + +Three fixes for "the hotkey doesn't reliably summon the palette," found while +chasing a settings-persistence report back to its actual root cause. + +### Fixed +- **Two competing processes could run at once, each with its own cached + settings.** A second launch (a stray `/Applications` install left running + alongside a `cargo tauri dev` session, or an overlapping dev restart) + started a fully independent process — its own `SettingsStore` loaded once + from disk at its own boot time, its own attempt at registering the same + global hotkey. Two processes racing for one hotkey meant summoning "the + palette" could nondeterministically show either one's window, and each had + its own stale view of settings (the `rev` guard added in 0.1.14 only + covers races *within* one process — it can't help across two with no + shared memory). Added `tauri-plugin-single-instance`, registered first per + Tauri's requirement: a second launch now just re-summons the one running + instance's palette instead of starting a competing process. Verified live + — launched the binary twice and confirmed via `ps` that the second + process exits (defunct, status 0) instead of staying alive. +- **The hotkey could show the window without it ever actually taking + keyboard focus.** The palette runs under `ActivationPolicy::Accessory` (no + Dock icon) — the one case where `WebviewWindow::set_focus()` alone is + unreliable on macOS. `set_focus()` calls `makeKeyAndOrderFront:` on the + *window*; it doesn't activate the *process*. Summoned from a background + global hotkey while a different app holds focus, an accessory app's window + can end up visually shown but not actually key, so it silently never + receives keyboard input — indistinguishable from "the hotkey did nothing." + `show_palette` now also calls `activateIgnoringOtherApps:` on + `NSApplication`, which `set_focus()` never did. +- **A hotkey press during the close animation could get silently dropped.** + `closingRef` (read by the toggle-hotkey listener to tell "genuinely idle" + apart from "still animating closed") was mirrored from React state via a + `useEffect`, which only runs *after* React commits the next render. A + hotkey re-summon landing in that gap — e.g. right after `hide_on_blur` + starts a close — read the *previous* value of `closingRef.current` and hit + the wrong branch: it called `hide()` again (a no-op, already closing) + instead of cancelling the close and reopening. `closingRef` is now updated + synchronously at the same call site as `setClosing`, closing that window + entirely rather than narrowing it. + ## [0.1.14] — 2026-07-17 Fixes an accent-color persistence race: a color chosen in Settings could @@ -369,7 +410,8 @@ First cross-platform release — macOS, Linux, and Windows. --- -[Unreleased]: https://github.com/archdex-art/SuperSearch/compare/v0.1.14...HEAD +[Unreleased]: https://github.com/archdex-art/SuperSearch/compare/v0.1.15...HEAD +[0.1.15]: https://github.com/archdex-art/SuperSearch/releases/tag/v0.1.15 [0.1.14]: https://github.com/archdex-art/SuperSearch/releases/tag/v0.1.14 [0.1.13]: https://github.com/archdex-art/SuperSearch/releases/tag/v0.1.13 [0.1.12]: https://github.com/archdex-art/SuperSearch/releases/tag/v0.1.12 diff --git a/Cargo.lock b/Cargo.lock index 8d87c54..df26b5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,126 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -239,6 +359,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.3" @@ -517,6 +650,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1188,6 +1330,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1215,6 +1384,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1379,6 +1569,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -2899,6 +3102,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "osakit" version = "0.3.1" @@ -2938,6 +3151,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3042,6 +3261,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -3115,6 +3345,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "postcard" version = "1.1.3" @@ -4094,6 +4338,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-global-shortcut", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", "tokio", @@ -4453,6 +4698,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -5092,6 +5353,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -6567,6 +6839,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.50" @@ -6692,3 +7025,43 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.3", +] diff --git a/react-command-palette/App.tsx b/react-command-palette/App.tsx index c807dff..f9dd7a9 100644 --- a/react-command-palette/App.tsx +++ b/react-command-palette/App.tsx @@ -50,24 +50,32 @@ export default function App() { const inputRef = useRef(null); const listRef = useRef(null); const baseId = useId(); - // Mirrors `closing` for the toggle-hotkey listener below, which is - // registered once and would otherwise read a stale value out of a closure - // captured on first mount. + // Mirrors `closing` for the toggle-hotkey listener below (registered once + // on mount, so a plain closure over `closing` would read a stale value). + // Updated synchronously by `setClosingBoth`, NOT via a `useEffect` mirror — + // an effect only runs after React commits the next render, which leaves a + // real gap: a hotkey re-summon landing in that gap during the ~150ms exit + // animation would read the *previous* value of `closingRef.current` and + // silently drop the summon (see the `toggle-request` listener) instead of + // cancelling the close and reopening. This is user-visible as "the hotkey + // doesn't do anything" whenever a press lands while the panel is still + // finishing a close. const closingRef = useRef(false); - useEffect(() => { - closingRef.current = closing; - }, [closing]); + const setClosingBoth = useCallback((value: boolean) => { + closingRef.current = value; + setClosing(value); + }, []); - const hide = useCallback(() => setClosing(true), []); + const hide = useCallback(() => setClosingBoth(true), [setClosingBoth]); /** Fires when the panel's `animate` variant finishes transitioning. Only * the "exit" variant should ever trigger the real window hide. */ const onPanelAnimationComplete = useCallback((definition: unknown) => { if (definition === "exit") { void invoke("hide_window"); - setClosing(false); + setClosingBoth(false); } - }, []); + }, [setClosingBoth]); // Debounced server-side search (native results + enabled extensions). useEffect(() => { @@ -229,7 +237,7 @@ export default function App() { let cancelled = false; listen("supersearch://toggle-request", () => { if (closingRef.current) { - setClosing(false); + setClosingBoth(false); requestAnimationFrame(() => inputRef.current?.focus()); } else { hide(); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc1efcf..de9bcca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "supersearch-app" -version = "0.1.14" +version = "0.1.15" edition = "2021" description = "SuperSearch — AI-Native Productivity Layer Desktop App" @@ -8,6 +8,7 @@ description = "SuperSearch — AI-Native Productivity Layer Desktop App" supersearch-runtime = { path = "../crates/supersearch-runtime" } tauri = { version = "2", features = ["macos-private-api"] } tauri-plugin-global-shortcut = "2" +tauri-plugin-single-instance = "2" tauri-plugin-dialog = "2" # Opt-in: auto-update requires signing keys (plugins.updater.pubkey), so it is # off by default and enabled for release builds via `--features updater`. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8eccf66..1013d66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -171,12 +171,44 @@ fn request_accessibility() { #[cfg(not(target_os = "macos"))] fn enable_fullscreen_overlay(_window: &tauri::WebviewWindow) {} +/// Bring the whole app to the front, not just the window. +/// +/// The palette runs under `ActivationPolicy::Accessory` (no Dock icon), and +/// accessory apps are the one case where `WebviewWindow::set_focus()` alone +/// is unreliable on macOS: `set_focus()` calls `makeKeyAndOrderFront:` on the +/// *window*, but doesn't activate the *process* — when a background global +/// hotkey summons an accessory app while a different app currently holds +/// focus, AppKit can leave the window visually shown yet not actually key, +/// so it never receives keyboard input (looks exactly like "the hotkey +/// didn't do anything"). `activateIgnoringOtherApps:` is the process-level +/// activation `set_focus()` doesn't do on its own. +#[cfg(target_os = "macos")] +fn activate_app() { + use objc2::msg_send; + use objc2::runtime::AnyObject; + + // SAFETY: `sharedApplication` returns the process-wide singleton + // `NSApplication` instance, always valid once AppKit is initialized + // (guaranteed by the time `setup`/hotkey handlers run). `true` is a + // plain `BOOL` argument; this call has no other side effects besides + // the documented app-activation behavior. + unsafe { + let cls = objc2::class!(NSApplication); + let app: *mut AnyObject = msg_send![cls, sharedApplication]; + let _: () = msg_send![app, activateIgnoringOtherApps: true]; + } +} + +#[cfg(not(target_os = "macos"))] +fn activate_app() {} + /// Show, center, and focus the palette, then tell the UI to reset its input. fn show_palette(window: &tauri::WebviewWindow) { // Re-assert the overlay collection-behavior + high window level every time: // macOS can drop them when the window is ordered out and back across // Spaces, which would make the palette fall behind a full-screen app. enable_fullscreen_overlay(window); + activate_app(); let _ = window.center(); let _ = window.show(); let _ = window.set_focus(); @@ -368,7 +400,32 @@ pub fn run() { let app_state = AppState::from_kernel(&kernel, boot_ms); // 4. Build the Tauri app. + // + // `single_instance` MUST be the first plugin registered (Tauri's own + // recommendation) and is the actual fix for a very real class of bug: a + // second `SuperSearch.app`/`cargo tauri dev` launch used to start a + // *second, fully independent process* — its own `SettingsStore` loaded + // once from disk at its own boot time, its own attempt at registering + // the same global hotkey. With two processes racing for one hotkey and + // each caching its own now-divergent settings snapshot in memory, + // picking a new accent color in one process's Settings window persisted + // correctly to disk, but the *other*, still-running process never + // re-read it — so summoning "the palette" could nondeterministically + // show either process's window depending on which one's hotkey + // registration the OS honored that time, and interacting with the stale + // one (e.g. toggling an unrelated setting) would spread its own outdated + // `accent_color` right back over the correct value on disk. This isn't + // a race *within* one process (see `SettingsStore::set`'s `rev` guard + // for that one) — it's two independent processes with no shared memory + // at all, which no amount of in-process ordering can fix. A second + // launch now just re-summons the one real instance's palette instead of + // starting a competing process. let builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + show_palette(&window); + } + })) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .manage(app_state) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4920a20..ceb68c1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "SuperSearch", - "version": "0.1.14", + "version": "0.1.15", "identifier": "com.supersearch.desktop", "build": { "frontendDist": "../react-command-palette/dist",