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)