From 6d37dda09efdc0e0bd2a4cbdddb143e4c42306bf Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Fri, 6 Mar 2026 17:48:28 +0900 Subject: [PATCH] feat: apply initial settings and improve app matching Add applyInitialSettingsSideEffects function to apply persisted settings on app startup. This ensures settings like DND respect and ignored platforms are properly loaded when the app starts. Improve app bundle ID matching by adding case-insensitive comparison and trimming whitespace in bundleIdToName and nameToBundleId functions. This provides more robust app identification for notification filtering. --- .../src/settings/general/notification.tsx | 16 +++- .../src/store/tinybase/store/settings.test.ts | 93 +++++++++++++++++++ .../src/store/tinybase/store/settings.ts | 21 +++++ plugins/detect/src/ext.rs | 72 +++++++++++++- plugins/detect/src/policy.rs | 35 ++++++- 5 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/store/tinybase/store/settings.test.ts diff --git a/apps/desktop/src/settings/general/notification.tsx b/apps/desktop/src/settings/general/notification.tsx index 7a632724ab..93d4ca6eb7 100644 --- a/apps/desktop/src/settings/general/notification.tsx +++ b/apps/desktop/src/settings/general/notification.tsx @@ -69,11 +69,23 @@ export function NotificationSettingsView() { }); const bundleIdToName = (bundleId: string) => { - return allInstalledApps?.find((a) => a.id === bundleId)?.name ?? bundleId; + const normalized = bundleId.trim(); + return ( + allInstalledApps?.find( + (a) => a.id.toLowerCase() === normalized.toLowerCase(), + )?.name ?? normalized + ); }; const nameToBundleId = (name: string) => { - return allInstalledApps?.find((a) => a.name === name)?.id ?? name; + const normalized = name.trim(); + return ( + allInstalledApps?.find( + (a) => + a.id.toLowerCase() === normalized.toLowerCase() || + a.name.toLowerCase() === normalized.toLowerCase(), + )?.id ?? normalized + ); }; const isDefaultIgnored = (appName: string) => { diff --git a/apps/desktop/src/store/tinybase/store/settings.test.ts b/apps/desktop/src/store/tinybase/store/settings.test.ts new file mode 100644 index 0000000000..ea27608842 --- /dev/null +++ b/apps/desktop/src/store/tinybase/store/settings.test.ts @@ -0,0 +1,93 @@ +import { createMergeableStore } from "tinybase/with-schemas"; +import { describe, expect, test, vi } from "vitest"; + +const { + setRespectDoNotDisturb, + setIgnoredBundleIds, + setMicActiveThreshold, + setDisabled, + startServer, + stopServer, +} = vi.hoisted(() => ({ + setRespectDoNotDisturb: vi + .fn() + .mockResolvedValue({ status: "ok", data: null }), + setIgnoredBundleIds: vi.fn().mockResolvedValue({ status: "ok", data: null }), + setMicActiveThreshold: vi + .fn() + .mockResolvedValue({ status: "ok", data: null }), + setDisabled: vi.fn().mockResolvedValue({ status: "ok", data: null }), + startServer: vi.fn().mockResolvedValue({ status: "ok", data: null }), + stopServer: vi.fn().mockResolvedValue({ status: "ok", data: null }), +})); + +vi.mock("@tauri-apps/plugin-autostart", () => ({ + enable: vi.fn(), + disable: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-process", () => ({ + relaunch: vi.fn(), +})); + +vi.mock("@hypr/plugin-analytics", () => ({ + commands: { + setDisabled, + }, +})); + +vi.mock("@hypr/plugin-detect", () => ({ + commands: { + setRespectDoNotDisturb, + setIgnoredBundleIds, + setMicActiveThreshold, + }, +})); + +vi.mock("@hypr/plugin-local-stt", () => ({ + commands: { + startServer, + stopServer, + }, +})); + +vi.mock("@hypr/plugin-windows", () => ({ + getCurrentWebviewWindowLabel: () => "main", +})); + +vi.mock("@hypr/plugin-store2", () => ({ + commands: { + save: vi.fn(), + }, +})); + +import { + applyInitialSettingsSideEffects, + SCHEMA, +} from "~/store/tinybase/store/settings"; + +describe("applyInitialSettingsSideEffects", () => { + test("applies persisted detect settings on startup", () => { + const store = createMergeableStore() + .setTablesSchema(SCHEMA.table) + .setValuesSchema(SCHEMA.value); + + store.setValues({ + respect_dnd: true, + ignored_platforms: '["Codex","app.spokenly"]', + mic_active_threshold: 30, + telemetry_consent: false, + current_stt_provider: "hyprnote", + current_stt_model: "base", + }); + + applyInitialSettingsSideEffects(store); + + expect(setRespectDoNotDisturb).toHaveBeenCalledWith(true); + expect(setIgnoredBundleIds).toHaveBeenCalledWith(["Codex", "app.spokenly"]); + expect(setMicActiveThreshold).toHaveBeenCalledWith(30); + expect(setDisabled).toHaveBeenCalledWith(true); + expect(startServer).toHaveBeenCalledWith("base"); + expect(stopServer).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/store/tinybase/store/settings.ts b/apps/desktop/src/store/tinybase/store/settings.ts index e45394d126..1f5d3ca9b9 100644 --- a/apps/desktop/src/store/tinybase/store/settings.ts +++ b/apps/desktop/src/store/tinybase/store/settings.ts @@ -309,6 +309,26 @@ const SETTINGS_LISTENERS: SettingsListeners = { }, }; +const INITIAL_SETTINGS_SIDE_EFFECT_KEYS = [ + "respect_dnd", + "ignored_platforms", + "mic_active_threshold", + "current_stt_model", + "telemetry_consent", +] as const satisfies SettingsValueKey[]; + +export function applyInitialSettingsSideEffects(store: Store) { + for (const key of INITIAL_SETTINGS_SIDE_EFFECT_KEYS) { + const handler = SETTINGS_LISTENERS[key] as + | ((store: Store, newValue: unknown) => void) + | undefined; + const value = store.getValue(key); + if (handler && value !== undefined) { + handler(store, value); + } + } +} + function registerSettingsListeners(store: Store): () => void { const cleanups: string[] = []; @@ -322,6 +342,7 @@ function registerSettingsListeners(store: Store): () => void { }), ); } + applyInitialSettingsSideEffects(store); return () => { for (const id of cleanups) { diff --git a/plugins/detect/src/ext.rs b/plugins/detect/src/ext.rs index 4ed3d09799..a2984b0acf 100644 --- a/plugins/detect/src/ext.rs +++ b/plugins/detect/src/ext.rs @@ -1,8 +1,32 @@ +use std::collections::HashSet; + pub struct Detect<'a, R: tauri::Runtime, M: tauri::Manager> { manager: &'a M, _runtime: std::marker::PhantomData R>, } +fn resolve_ignored_bundle_ids( + bundle_ids: Vec, + apps: &[hypr_detect::InstalledApp], +) -> HashSet { + bundle_ids + .into_iter() + .filter_map(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + apps.iter() + .find(|app| { + app.id.eq_ignore_ascii_case(trimmed) || app.name.eq_ignore_ascii_case(trimmed) + }) + .map(|app| app.id.clone()) + .or_else(|| Some(trimmed.to_string())) + }) + .collect() +} + impl<'a, R: tauri::Runtime, M: tauri::Manager> Detect<'a, R, M> { pub fn list_installed_applications(&self) -> Vec { hypr_detect::list_installed_apps() @@ -19,10 +43,14 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Detect<'a, R, M> { pub fn set_ignored_bundle_ids(&self, bundle_ids: Vec) { let state = self.manager.state::(); let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner()); - for id in &bundle_ids { + let mut apps = hypr_detect::list_installed_apps(); + apps.extend(hypr_detect::list_mic_using_apps()); + let resolved_bundle_ids = resolve_ignored_bundle_ids(bundle_ids, &apps); + + for id in &resolved_bundle_ids { state_guard.mic_usage_tracker.cancel_app(id); } - state_guard.policy.user_ignored_bundle_ids = bundle_ids.into_iter().collect(); + state_guard.policy.user_ignored_bundle_ids = resolved_bundle_ids; } pub fn set_respect_do_not_disturb(&self, enabled: bool) { @@ -55,3 +83,43 @@ impl> DetectPluginExt for T { } } } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::resolve_ignored_bundle_ids; + + fn app(id: &str, name: &str) -> hypr_detect::InstalledApp { + hypr_detect::InstalledApp { + id: id.to_string(), + name: name.to_string(), + } + } + + #[test] + fn resolves_app_names_to_bundle_ids() { + let apps = vec![app("com.openai.codex", "Codex")]; + let resolved = resolve_ignored_bundle_ids(vec!["Codex".to_string()], &apps); + + assert!(resolved.contains("com.openai.codex")); + } + + #[test] + fn resolves_case_insensitive_names_and_ids() { + let apps = vec![app("app.spokenly", "Spokenly")]; + let resolved = resolve_ignored_bundle_ids( + vec![" spokenly ".to_string(), "APP.SPOKENLY".to_string()], + &apps, + ); + + assert_eq!(resolved, HashSet::from(["app.spokenly".to_string()])); + } + + #[test] + fn preserves_unknown_values() { + let resolved = resolve_ignored_bundle_ids(vec!["com.example.custom".to_string()], &[]); + + assert_eq!(resolved, HashSet::from(["com.example.custom".to_string()])); + } +} diff --git a/plugins/detect/src/policy.rs b/plugins/detect/src/policy.rs index 3797908d5b..5719060ff6 100644 --- a/plugins/detect/src/policy.rs +++ b/plugins/detect/src/policy.rs @@ -40,6 +40,9 @@ impl AppCategory { "com.seewillow.WillowMac", "com.superduper.superwhisper", "com.prakashjoshipax.VoiceInk", + "app.spokenly", + "com.amical.desktop", + "com.goodsnooze.MacWhisper", "com.goodsnooze.macwhisper", "com.descript.beachcube", "com.apple.VoiceMemos", @@ -49,6 +52,8 @@ impl AppCategory { "dev.warp.Warp-Stable", "com.exafunction.windsurf", "com.microsoft.VSCode", + "com.microsoft.VSCodeInsiders", + "com.vscodium", "com.todesktop.230313mzl4w4u92", ], Self::ScreenRecording => &[ @@ -57,7 +62,11 @@ impl AppCategory { "com.loom.desktop", "com.obsproject.obs-studio", ], - Self::AIAssistant => &["com.openai.chat", "com.anthropic.claudefordesktop"], + Self::AIAssistant => &[ + "com.openai.chat", + "com.openai.codex", + "com.anthropic.claudefordesktop", + ], Self::Other => &[ "com.raycast.macos", "com.apple.garageband10", @@ -206,10 +215,30 @@ mod tests { AppCategory::find_category("com.electron.aqua-voice"), Some(AppCategory::Dictation) ); + assert_eq!( + AppCategory::find_category("app.spokenly"), + Some(AppCategory::Dictation) + ); + assert_eq!( + AppCategory::find_category("com.amical.desktop"), + Some(AppCategory::Dictation) + ); + assert_eq!( + AppCategory::find_category("com.goodsnooze.MacWhisper"), + Some(AppCategory::Dictation) + ); assert_eq!( AppCategory::find_category("com.microsoft.VSCode"), Some(AppCategory::IDE) ); + assert_eq!( + AppCategory::find_category("com.microsoft.VSCodeInsiders"), + Some(AppCategory::IDE) + ); + assert_eq!( + AppCategory::find_category("com.vscodium"), + Some(AppCategory::IDE) + ); assert_eq!( AppCategory::find_category("so.cap.desktop"), Some(AppCategory::ScreenRecording) @@ -218,6 +247,10 @@ mod tests { AppCategory::find_category("com.openai.chat"), Some(AppCategory::AIAssistant) ); + assert_eq!( + AppCategory::find_category("com.openai.codex"), + Some(AppCategory::AIAssistant) + ); assert_eq!( AppCategory::find_category("com.raycast.macos"), Some(AppCategory::Other)