From 46874efcb5c2dccd94e8228613e7822ae051b823 Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 15:47:59 +0800 Subject: [PATCH 1/2] fix(search): observe app changes outside search lock --- docs/explanation/architecture.md | 10 +- docs/reference/features/apps.md | 10 +- src-tauri/src/launcher/app_watch.rs | 269 +++++++++++++++++++++++++--- 3 files changed, 261 insertions(+), 28 deletions(-) diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 1794685..95a5aa1 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -5,13 +5,15 @@ The path is `query → SearchManager → providers → ranking → top 30 result - One Rust crate owns discovery, matching, ranking, usage persistence, indexed app IDs, launch actions, window lifecycle, settings, and shortcuts. - `SearchManager` reuses a `nucleo-matcher` instance. Names, aliases, and paths are prepared when the app index changes. Matching ignores case and supports Unicode normalization. Match and usage bonuses are applied in `ranking/mod.rs` before selecting the top 30. Unused apps keep a stable alphabetical order when the query is empty. - `AppProvider`, `FileProvider`, `ClipboardProvider`, `EmojiProvider`, `CalculatorProvider`, `SystemCommandProvider`, and `ToolProvider` return the same result model. Rust parses search modes and prefixes. The emoji index and small system command catalog load on their first search. Calculations use a fresh `fend-core` context with random values disabled and a cooperative 50 ms time limit. Tool results use a bounded cache of 64 issued IDs. Password refreshes keep the displayed value; a new password search or **Generate another** produces new values. -- Discovery builds a new index off the UI thread. The old index remains available during refresh. Tauri's existing async runtime runs application scans, searches, launch work, and currency requests. A file worker owns the scanner and watcher. A clipboard worker handles observations and database writes. Only macOS and Windows use the one-second clipboard counter timer. +- Discovery builds a new index off the UI thread. The old index remains available during refresh. Tauri's existing async runtime runs application scans, searches, launch work, and currency requests. On macOS and Windows, the application watcher coalesces event bursts into a bounded set of paths. It probes filesystem metadata before taking the search mutex, then compares observed membership with the in-memory index. Only a confirmed missing path implies removal; other metadata errors leave membership unknown. A file worker owns the scanner and watcher. A clipboard worker handles observations and database writes. Only macOS and Windows use the one-second clipboard counter timer. - Currency lookup reads a shared, immutable rate table in memory. Network requests and SQLite writes run outside the search lock. `currency.rs` contains the source request and parser; the calculator does not depend on the HTTP response format. The HTTP client reuses the reqwest version already required by Tauri and uses the OS TLS stack. -- Clipboard capture, copy, delete, and clear use the same storage lock. A generation number rejects reads already in progress when an entry is removed. Search never waits for disk access. Linux coalesces pending clipboard observations in a bounded queue. Results include short text summaries; a separate request fetches the selected entry's preview. -- SolidJS keeps UI state and sends queries without a debounce timer. It sends one search request at a time and retains only the newest waiting query. This prevents IPC arrival order from cancelling the current query when startup events overlap. Request numbers prevent late replies from replacing newer results. `src/search.ts` holds this queue and the rule that keeps the selected result across refreshes; it has no Solid dependency and has its own tests. Enter cannot execute an old result while a new query is pending. +- Clipboard capture, copy, delete, and clear use the same storage lock. A generation number rejects reads already in progress when an entry is removed. Clipboard matching reads its in-memory index rather than fetching previews or querying SQLite. This is not a blanket disk-latency guarantee for every search-lock holder. Linux coalesces pending clipboard observations in a bounded queue. Results include short text summaries; a separate request fetches the selected entry's preview. +- SolidJS keeps UI state and sends queries without a debounce timer. [`src/searchQueue.ts`](../../src/searchQueue.ts) sends one expensive search at a time and retains only the newest waiting query. Superseded work receives an out-of-band `cancel_search` command, which marks its request ID without acquiring the search mutex. The queue drains cancellation acknowledgement before dispatching the next search; sequence checks independently reject stale replies. [`src/search.ts`](../../src/search.ts) holds result reconciliation, including selection preservation across refreshes, not the request queue. Both modules have no Solid dependency and have their own tests. Enter cannot execute an old result while a new query is pending. +- A shared cooperative 250 ms request budget covers worker queueing, search-lock wait, provider matching, and pinned queries. Calculator evaluation also retains its 50 ms cap within the remaining request budget. Cancellation and deadline checks stop work at checkpoints; they do not preempt an individual operation or guarantee a hard wall-clock response bound. +- Settings and index publication acquire search before the brief settings write lock. Applying app preferences also precedes that write lock, so launcher shortcuts, blur handling, and clipboard callbacks can read previous settings while publication waits for search. See [`launcher/mod.rs`](../../src-tauri/src/launcher/mod.rs) for publication and cancellation. - `src/launcherController.ts` owns the frontend search session, result reconciliation, selection, pending state, and visibility gating. The view keeps focus, keyboard navigation, dialogs, and rendering. `src/nativeSubscriptions.ts` owns native listeners, including registrations that finish after a window is disposed; both windows use it. `src/settingsDraft.ts` merges saved settings into local drafts without discarding local field edits or incomplete folder input. - Hiding the window stops frontend search requests, drops waiting input, and ignores any late search reply. File indexing and clipboard capture continue in Rust. Reopening requests current results. A background refresh preserves the user's latest selection for the same query; a new query selects its first result. -- The frontend sends a result ID and an action. Rust resolves app paths, indexed file paths, emoji values, and calculation values. It checks that a file still exists before opening it. A bounded cache holds the last 32 calculation results so copying an issued result does not evaluate it again. The webview has no general shell, opener, filesystem, clipboard, or global-shortcut permissions. +- The frontend sends a result ID and an action. Rust resolves app paths, indexed file paths, emoji values, and calculation values. It checks that a file still exists before opening it. A bounded cache holds the last 32 calculation results so copying an issued result does not evaluate it again. Application IPC uses explicit per-window ACL sets in [`permissions/windows.toml`](../../src-tauri/permissions/windows.toml), with command permissions generated by [`build.rs`](../../src-tauri/build.rs). The [main capability](../../src-tauri/capabilities/main.json) grants `launcher` and `shared`; the [settings capability](../../src-tauri/capabilities/settings.json) grants `settings` and `shared`. The webview has no general shell, opener, filesystem, clipboard, or global-shortcut permissions. - System command IDs resolve against the Rust catalog. Rust owns command aliases, confirmation text, and the confirmation rule. The frontend supplies explicit consent after the dialog. Platform modules contain the native API calls; no shell command text comes from the webview. - The official global shortcut, opener, and clipboard manager plugins supply desktop integration through Rust. The official single-instance plugin brings the existing process forward when the user starts TinyDash again. - macOS app icons come from NSWorkspace. Unavailable icons use initials. The UI uses bundled fonts and local CSS. It makes no network requests. diff --git a/docs/reference/features/apps.md b/docs/reference/features/apps.md index b397f4b..800d80c 100644 --- a/docs/reference/features/apps.md +++ b/docs/reference/features/apps.md @@ -4,7 +4,7 @@ Select Apps to browse installed applications, or type an application name in All Settings, Search adds aliases and hides applications. App icons and descriptions depend on platform metadata. A fallback appears when an icon or description is missing. New and removed applications update automatically, usually within a few seconds after an installer finishes. Actions and the tray menu can still refresh discovery. -On macOS and Windows, TinyDash watches the application folders. It compares each changed bundle or shortcut with the index and scans again only when an application was added, removed, or renamed. An app update or launch does not start a scan. A new or removed folder in these locations also starts a scan. On Linux, GIO reports changes to desktop entries in all XDG data folders, including Flatpak and Snap exports. +On macOS and Windows, TinyDash watches the application folders. It compares each changed bundle or shortcut with the index and scans again only when an application was added, removed, or renamed. An app update or launch does not start a scan. Filesystem metadata is observed outside the search mutex; only confirmed absence is treated as removal, not a permission or other metadata error. A new or removed folder in these locations also starts a scan. On Linux, GIO reports changes to desktop entries in all XDG data folders, including Flatpak and Snap exports. Settings loads the app catalog on a blocking worker, so a competing search does not block the event thread. Settings publication waits for search before taking its brief settings write lock; launcher shortcuts, blur handling, and clipboard callbacks can still read the previous settings while publication waits. Rust contention regressions cover these lock paths; they are not native responsiveness measurements. @@ -24,6 +24,14 @@ For affected backend behavior: bun run test:rust -- providers::apps ``` +On macOS or Windows, also run the application-folder watcher regressions: + +```sh +bun run test:rust -- launcher::app_watch::tests +``` + +These cover installs, removals, renames, metadata failures, and bounded event coalescing. A controlled metadata-probe latch checks that an actual `SearchManager` search completes while filesystem observation is blocked. They do not establish native watcher delivery or desktop latency. + Use Apps and All. Find an application by name, abbreviation, and alias. Press Enter on the selected fixture and check its marker. Verify reveal and refresh through each changed entry point. A hidden application must remain hidden after refresh and restart. Run `bun run verify:native` on Windows and Linux X11 for real discovery and launch. Use the application and Settings desktop checks for reveal, tray refresh, aliases, and macOS. Platform discovery changes need a check on the affected OS. diff --git a/src-tauri/src/launcher/app_watch.rs b/src-tauri/src/launcher/app_watch.rs index 7518461..48e8350 100644 --- a/src-tauri/src/launcher/app_watch.rs +++ b/src-tauri/src/launcher/app_watch.rs @@ -29,7 +29,9 @@ fn wait_for_scan(app: &AppHandle) { mod folders { use std::{ collections::BTreeSet, - path::PathBuf, + fs::Metadata, + io, + path::{Path, PathBuf}, sync::{Arc, Mutex, mpsc}, }; @@ -44,12 +46,13 @@ mod folders { LauncherState, file_watch::{Request, settle}, scan_apps, + search::SearchManager, }, platform::{self, AppChange}, }; // Bound memory during a large install. More paths cause a full scan. - const PATH_LIMIT: usize = 256; + pub(super) const PATH_LIMIT: usize = 256; #[derive(Default)] pub(super) struct Pending { @@ -96,17 +99,57 @@ mod folders { relevant } - // Compare each changed application with the index. An app update - // or launch changes files inside a bundle but not the result list. + // Observe the filesystem before acquiring search. Metadata can block, + // even for one path; a failed probe is not evidence of a removal. + pub fn observe(&self, mut metadata: impl FnMut(&Path) -> io::Result) -> Observed { + let mut observed = Observed { + apps: Vec::new(), + scan: self.scan, + }; + if !self.scan { + for path in &self.apps { + let exists = match metadata(path) { + Ok(_) => true, + Err(error) if error.kind() == io::ErrorKind::NotFound => false, + Err(error) => { + tracing::debug!(%error, path = %path.display(), "Cannot observe application change"); + continue; + } + }; + observed + .apps + .push((format!("app:{}", path.to_string_lossy()), exists)); + } + } + observed + } + } + + pub(super) struct Observed { + apps: Vec<(String, bool)>, + scan: bool, + } + + impl Observed { + // Only in-memory comparisons while search is locked. An update or + // launch changes bundle contents, but not membership in the index. pub fn needs_scan(&self, indexed: impl Fn(&str) -> bool) -> bool { - self.scan - || self.apps.iter().any(|path| { - let exists = std::fs::symlink_metadata(path).is_ok(); - exists != indexed(&format!("app:{}", path.to_string_lossy())) - }) + self.scan || self.apps.iter().any(|(id, exists)| *exists != indexed(id)) } } + pub(super) fn scan_needed( + changes: &Pending, + search: &Mutex, + metadata: impl FnMut(&Path) -> io::Result, + ) -> bool { + let observed = changes.observe(metadata); + search + .lock() + .map(|search| observed.needs_scan(|id| search.has_app(id))) + .unwrap_or(false) + } + pub fn start(app: &AppHandle) { let roots: Vec = platform::app_folders() .into_iter() @@ -161,12 +204,10 @@ mod folders { &mut *pending.lock().unwrap_or_else(|error| error.into_inner()), ); wait_for_scan(&app); - let needed = app - .state::() - .search - .lock() - .map(|search| changes.needs_scan(|id| search.has_app(id))) - .unwrap_or(false); + let needed = + scan_needed(&changes, &app.state::().search, |path| { + std::fs::symlink_metadata(path) + }); if needed { scan_apps(&app); } @@ -235,7 +276,23 @@ mod tests { event::{AccessKind, CreateKind, DataChange, MetadataKind, ModifyKind, RemoveKind}, }; - use super::folders::Pending; + use std::{ + io, + sync::{Arc, Mutex, mpsc}, + time::Duration, + }; + + use super::folders::{PATH_LIMIT, Pending, scan_needed}; + use crate::{ + launcher::{query::SearchMode, search::SearchManager}, + providers::apps::{AppEntry, AppProvider}, + }; + + fn needs_scan(pending: &Pending, indexed: impl Fn(&str) -> bool) -> bool { + pending + .observe(|path| std::fs::symlink_metadata(path)) + .needs_scan(indexed) + } const APP: &str = if cfg!(windows) { "Editor.lnk" @@ -264,12 +321,13 @@ mod tests { let mut pending = Pending::default(); let modified = EventKind::Modify(ModifyKind::Data(DataChange::Content)); assert!(pending.record(&event(modified, app.clone()), &roots)); - assert!(!pending.needs_scan(indexed(true))); + assert!(!needs_scan(&pending, indexed(true))); // A new application, or one that has not been indexed yet, needs a scan. - assert!(pending.needs_scan(indexed(false))); + assert!(needs_scan(&pending, indexed(false))); // So does a removed application that is still indexed. std::fs::remove_dir(&app).expect("remove"); - assert!(pending.needs_scan(indexed(true))); + assert!(needs_scan(&pending, indexed(true))); + assert!(!needs_scan(&pending, indexed(false))); let mut pending = Pending::default(); for ignored in [ @@ -283,16 +341,181 @@ mod tests { ] { assert!(!pending.record(&ignored, &roots), "{ignored:?}"); } - assert!(!pending.needs_scan(|_| false)); + assert!(!needs_scan(&pending, |_| false)); let folder = event(EventKind::Remove(RemoveKind::Folder), root.join("Tools")); assert!(pending.record(&folder, &roots)); - assert!(pending.needs_scan(|_| true)); + assert!(needs_scan(&pending, |_| true)); let mut pending = Pending::default(); let dropped = Event::new(EventKind::Other).set_flag(notify::event::Flag::Rescan); assert!(pending.record(&dropped, &roots)); - assert!(pending.needs_scan(|_| true)); + assert!(needs_scan(&pending, |_| true)); + } + + #[test] + fn metadata_errors_are_not_confirmed_removals() { + let pending = Pending { + apps: [std::path::PathBuf::from(APP)].into(), + scan: false, + }; + for kind in [ + io::ErrorKind::PermissionDenied, + io::ErrorKind::Interrupted, + io::ErrorKind::Other, + ] { + let observed = pending.observe(|_| Err(io::Error::from(kind))); + // Unknown membership cannot imply either an install or a removal. + assert!(!observed.needs_scan(|_| true), "{kind:?}"); + assert!(!observed.needs_scan(|_| false), "{kind:?}"); + } + let absent = pending.observe(|_| Err(io::Error::from(io::ErrorKind::NotFound))); + assert!(absent.needs_scan(|_| true)); + assert!(!absent.needs_scan(|_| false)); + + // One failed probe must not hide another confirmed change in the burst. + let mut mixed = pending; + let removed = std::path::PathBuf::from(format!("Removed-{APP}")); + mixed.apps.insert(removed.clone()); + let observed = mixed.observe(|path| { + Err(io::Error::from(if path == removed.as_path() { + io::ErrorKind::NotFound + } else { + io::ErrorKind::PermissionDenied + })) + }); + assert!(observed.needs_scan(|_| true)); + } + + #[test] + fn rename_observes_both_old_and_new_application_ids() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().canonicalize().expect("root"); + let old = root.join(APP); + let new = root.join(format!("Renamed-{APP}")); + std::fs::write(&old, "").unwrap(); + let search = Mutex::new(SearchManager::default()); + search + .lock() + .unwrap() + .replace_apps(AppProvider::new(vec![AppEntry::new( + "Editor".into(), + old.clone(), + vec![], + )])); + std::fs::rename(&old, &new).unwrap(); + let mut pending = Pending::default(); + assert!( + pending.record( + &Event::new(EventKind::Modify(ModifyKind::Name( + notify::event::RenameMode::Both, + ))) + .add_path(old) + .add_path(new.clone()), + &[root], + ) + ); + assert_eq!(pending.apps.len(), 2); + assert!(scan_needed(&pending, &search, |path| { + std::fs::symlink_metadata(path) + })); + search + .lock() + .unwrap() + .replace_apps(AppProvider::new(vec![AppEntry::new( + "Editor".into(), + new, + vec![], + )])); + assert!(!scan_needed(&pending, &search, |path| { + std::fs::symlink_metadata(path) + })); + } + + #[test] + fn event_bursts_deduplicate_paths_and_overflow_to_one_scan() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().canonicalize().expect("root"); + let roots = [root.clone()]; + let mut pending = Pending::default(); + let changed = event(EventKind::Create(CreateKind::Any), root.join(APP)); + for _ in 0..PATH_LIMIT * 2 { + assert!(pending.record(&changed, &roots)); + } + assert_eq!(pending.apps.len(), 1); + assert!(!pending.scan); + let mut probes = 0; + pending.observe(|_| { + probes += 1; + Err(io::Error::from(io::ErrorKind::NotFound)) + }); + assert_eq!(probes, 1); + for index in 0..PATH_LIMIT * 2 { + assert!(pending.record( + &event( + EventKind::Create(CreateKind::Any), + root.join(format!("{index}-{APP}")), + ), + &roots, + )); + } + assert_eq!(pending.apps.len(), PATH_LIMIT); + assert!(pending.scan); + assert!( + pending + .observe(|_| panic!("a full scan needs no metadata probes")) + .needs_scan(|_| false) + ); + } + + #[test] + fn search_completes_while_application_metadata_probe_is_blocked() { + let dir = tempfile::tempdir().expect("tempdir"); + let app = dir.path().join(APP); + std::fs::write(&app, "").unwrap(); + let entry = AppEntry::new("Editor".into(), app.clone(), vec![]); + let id = entry.id.clone(); + let mut manager = SearchManager::default(); + manager.replace_apps(AppProvider::new(vec![entry])); + let search = Arc::new(Mutex::new(manager)); + let pending = Pending { + apps: [app].into(), + scan: false, + }; + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let watcher_search = search.clone(); + let watcher = std::thread::spawn(move || { + // Exercise the same observation/lock orchestration used by start. + scan_needed(&pending, &watcher_search, |path| { + let _ = entered_tx.send(()); + release_rx + .recv_timeout(Duration::from_secs(15)) + .expect("test must release the probe before its safety timeout"); + std::fs::symlink_metadata(path) + }) + }); + let entered = entered_rx.recv_timeout(Duration::from_secs(5)); + let (done_tx, done_rx) = mpsc::channel(); + let searching = std::thread::spawn(move || { + let result = search.lock().unwrap().search("Editor", SearchMode::Apps); + let _ = done_tx.send(result); + }); + let responsive = done_rx.recv_timeout(Duration::from_secs(5)); + // Always release and join before asserting. Moving observe inside the + // search lock must fail, not strand either worker on a latch or mutex. + let _ = release_tx.send(()); + let needed = watcher.join(); + let searched = searching.join(); + entered.expect("watcher must reach the controlled metadata probe"); + assert!(!needed.expect("watcher thread")); + searched.expect("search thread"); + let outcome = responsive + .expect("search must complete while the metadata probe is blocked") + .expect("search outcome"); + assert!(outcome.notice.is_none()); + assert_eq!(outcome.results.len(), 1); + assert_eq!(outcome.results[0].id, id); } #[test] @@ -330,6 +553,6 @@ mod tests { assert!(std::time::Instant::now() < deadline, "No event for {app:?}"); std::thread::sleep(std::time::Duration::from_millis(50)); } - assert!(pending.lock().expect("pending").needs_scan(|_| false)); + assert!(needs_scan(&pending.lock().expect("pending"), |_| false)); } } From da0345aa8c348cd31f4cf5839d9c2da6e9167965 Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 15:49:28 +0800 Subject: [PATCH 2/2] fix(privacy): omit application paths from probe diagnostics --- src-tauri/src/launcher/app_watch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/launcher/app_watch.rs b/src-tauri/src/launcher/app_watch.rs index 48e8350..5c79d42 100644 --- a/src-tauri/src/launcher/app_watch.rs +++ b/src-tauri/src/launcher/app_watch.rs @@ -112,7 +112,7 @@ mod folders { Ok(_) => true, Err(error) if error.kind() == io::ErrorKind::NotFound => false, Err(error) => { - tracing::debug!(%error, path = %path.display(), "Cannot observe application change"); + tracing::debug!(%error, "Cannot observe application change"); continue; } };