diff --git a/apps/desktop/src/settings/general/index.tsx b/apps/desktop/src/settings/general/index.tsx index cbf3b1452b..dbec4b0369 100644 --- a/apps/desktop/src/settings/general/index.tsx +++ b/apps/desktop/src/settings/general/index.tsx @@ -42,6 +42,9 @@ function useSettingsForm() { ignored_platforms: row.ignored_platforms ? JSON.stringify(row.ignored_platforms) : undefined, + included_platforms: row.included_platforms + ? JSON.stringify(row.included_platforms) + : undefined, ignored_recurring_series: row.ignored_recurring_series ? JSON.stringify(row.ignored_recurring_series) : undefined, diff --git a/apps/desktop/src/settings/general/notification-app-options.test.ts b/apps/desktop/src/settings/general/notification-app-options.test.ts index 6a203b8d68..af539119b5 100644 --- a/apps/desktop/src/settings/general/notification-app-options.test.ts +++ b/apps/desktop/src/settings/general/notification-app-options.test.ts @@ -1,34 +1,77 @@ import { describe, expect, test } from "vitest"; -import { getIgnoredAppOptions } from "./notification-app-options"; - -describe("getIgnoredAppOptions", () => { - test("returns installed app matches for partial searches", () => { - const options = getIgnoredAppOptions({ - allInstalledApps: [ - { id: "us.zoom.xos", name: "Zoom Workplace" }, - { id: "com.tinyspeck.slackmacgap", name: "Slack" }, - ], +import { + getEffectiveIgnoredPlatformIds, + getMicDetectionAppOptions, + orderIgnoredPlatformIdsForWrap, +} from "./notification-app-options"; + +describe("getMicDetectionAppOptions", () => { + test("hides default ignored apps from dropdown matches", () => { + const options = getMicDetectionAppOptions({ + allInstalledApps: [{ id: "com.microsoft.VSCode", name: "VS Code" }], + ignoredPlatforms: [], + includedPlatforms: [], + inputValue: "code", + defaultIgnoredBundleIds: ["com.microsoft.VSCode"], + }); + + expect(options).toEqual([]); + }); + + test("shows explicitly included default apps as excludable again", () => { + const options = getMicDetectionAppOptions({ + allInstalledApps: [{ id: "com.microsoft.VSCode", name: "VS Code" }], ignoredPlatforms: [], - inputValue: "zoom", - defaultIgnoredBundleIds: [], + includedPlatforms: ["com.microsoft.VSCode"], + inputValue: "code", + defaultIgnoredBundleIds: ["com.microsoft.VSCode"], }); - expect(options).toEqual([{ id: "us.zoom.xos", name: "Zoom Workplace" }]); + expect(options).toEqual([{ id: "com.microsoft.VSCode", name: "VS Code" }]); }); +}); + +describe("getEffectiveIgnoredPlatformIds", () => { + test("includes installed default ignored apps unless explicitly included", () => { + expect( + getEffectiveIgnoredPlatformIds({ + installedApps: [ + { id: "com.microsoft.VSCode", name: "VS Code" }, + { id: "us.zoom.xos", name: "Zoom Workplace" }, + ], + ignoredPlatforms: [], + includedPlatforms: [], + defaultIgnoredBundleIds: ["com.microsoft.VSCode"], + }), + ).toEqual(["com.microsoft.VSCode"]); + + expect( + getEffectiveIgnoredPlatformIds({ + installedApps: [{ id: "com.microsoft.VSCode", name: "VS Code" }], + ignoredPlatforms: [], + includedPlatforms: ["com.microsoft.VSCode"], + defaultIgnoredBundleIds: ["com.microsoft.VSCode"], + }), + ).toEqual([]); + }); +}); - test("filters out already ignored and default ignored apps", () => { - const options = getIgnoredAppOptions({ - allInstalledApps: [ - { id: "us.zoom.xos", name: "Zoom Workplace" }, - { id: "com.tinyspeck.slackmacgap", name: "Slack" }, - { id: "com.openai.chat", name: "ChatGPT" }, - ], - ignoredPlatforms: ["com.tinyspeck.slackmacgap"], - inputValue: "", - defaultIgnoredBundleIds: ["com.openai.chat"], +describe("orderIgnoredPlatformIdsForWrap", () => { + test("reorders chips to better fill each row", () => { + const ordered = orderIgnoredPlatformIdsForWrap({ + bundleIds: ["a", "b", "c", "d"], + availableWidth: 214, + bundleIdToName: (bundleId) => + ({ + a: "ABCDEFGHIJ", + b: "KLMNOPQRST", + c: "Tiny", + d: "Mini", + })[bundleId] ?? bundleId, + isDefaultIgnored: () => false, }); - expect(options).toEqual([{ id: "us.zoom.xos", name: "Zoom Workplace" }]); + expect(ordered).toEqual(["a", "d", "b", "c"]); }); }); diff --git a/apps/desktop/src/settings/general/notification-app-options.ts b/apps/desktop/src/settings/general/notification-app-options.ts index 41330ce440..e116a8b4aa 100644 --- a/apps/desktop/src/settings/general/notification-app-options.ts +++ b/apps/desktop/src/settings/general/notification-app-options.ts @@ -1,22 +1,161 @@ import type { InstalledApp } from "@hypr/plugin-detect"; -export function getIgnoredAppOptions({ +function isEffectivelyIgnored({ + bundleId, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, +}: { + bundleId: string; + ignoredPlatforms: string[]; + includedPlatforms: string[]; + defaultIgnoredBundleIds: string[] | undefined; +}) { + const isDefaultIgnored = defaultIgnoredBundleIds?.includes(bundleId) ?? false; + const isIncluded = includedPlatforms.includes(bundleId); + const isUserIgnored = ignoredPlatforms.includes(bundleId); + + return isUserIgnored || (isDefaultIgnored && !isIncluded); +} + +export function getMicDetectionAppOptions({ allInstalledApps, ignoredPlatforms, + includedPlatforms, inputValue, defaultIgnoredBundleIds, }: { allInstalledApps: InstalledApp[] | undefined; ignoredPlatforms: string[]; + includedPlatforms: string[]; inputValue: string; defaultIgnoredBundleIds: string[] | undefined; }) { return (allInstalledApps ?? []).filter((app) => { const matchesSearch = app.name .toLowerCase() - .includes(inputValue.toLowerCase()); - const notAlreadyAdded = !ignoredPlatforms.includes(app.id); - const notDefaultIgnored = !(defaultIgnoredBundleIds ?? []).includes(app.id); - return matchesSearch && notAlreadyAdded && notDefaultIgnored; + .includes(inputValue.trim().toLowerCase()); + const isIgnored = isEffectivelyIgnored({ + bundleId: app.id, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, + }); + return matchesSearch && !isIgnored; }); } + +export function getEffectiveIgnoredPlatformIds({ + installedApps, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, +}: { + installedApps: InstalledApp[] | undefined; + ignoredPlatforms: string[]; + includedPlatforms: string[]; + defaultIgnoredBundleIds: string[] | undefined; +}) { + const installedAppIds = new Set((installedApps ?? []).map((app) => app.id)); + const bundleIds = new Set(ignoredPlatforms); + + for (const bundleId of defaultIgnoredBundleIds ?? []) { + if (!installedAppIds.has(bundleId)) { + continue; + } + + if ( + isEffectivelyIgnored({ + bundleId, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, + }) + ) { + bundleIds.add(bundleId); + } + } + + return [...bundleIds]; +} + +function estimateIgnoredChipWidth({ + label, + isDefaultIgnored, +}: { + label: string; + isDefaultIgnored: boolean; +}) { + const labelWidth = label.length * 9; + const defaultWidth = isDefaultIgnored ? 78 : 0; + + return labelWidth + defaultWidth + 44; +} + +export function orderIgnoredPlatformIdsForWrap({ + bundleIds, + availableWidth, + bundleIdToName, + isDefaultIgnored, +}: { + bundleIds: string[]; + availableWidth: number | null; + bundleIdToName: (bundleId: string) => string; + isDefaultIgnored: (bundleId: string) => boolean; +}) { + const items = bundleIds.map((bundleId) => ({ + bundleId, + label: bundleIdToName(bundleId), + estimatedWidth: estimateIgnoredChipWidth({ + label: bundleIdToName(bundleId), + isDefaultIgnored: isDefaultIgnored(bundleId), + }), + })); + + if (!availableWidth || availableWidth <= 0) { + return items + .sort( + (left, right) => + right.estimatedWidth - left.estimatedWidth || + left.label.localeCompare(right.label), + ) + .map((item) => item.bundleId); + } + + const rows: { items: typeof items; remainingWidth: number }[] = []; + + for (const item of items.sort( + (left, right) => + right.estimatedWidth - left.estimatedWidth || + left.label.localeCompare(right.label), + )) { + let bestRow: { items: typeof items; remainingWidth: number } | undefined; + + for (const row of rows) { + if (row.remainingWidth < item.estimatedWidth) { + continue; + } + + if ( + !bestRow || + row.remainingWidth - item.estimatedWidth < + bestRow.remainingWidth - item.estimatedWidth + ) { + bestRow = row; + } + } + + if (bestRow) { + bestRow.items.push(item); + bestRow.remainingWidth -= item.estimatedWidth; + continue; + } + + rows.push({ + items: [item], + remainingWidth: Math.max(0, availableWidth - item.estimatedWidth), + }); + } + + return rows.flatMap((row) => row.items.map((item) => item.bundleId)); +} diff --git a/apps/desktop/src/settings/general/notification.tsx b/apps/desktop/src/settings/general/notification.tsx index 4801adbd62..40ed475d14 100644 --- a/apps/desktop/src/settings/general/notification.tsx +++ b/apps/desktop/src/settings/general/notification.tsx @@ -11,6 +11,19 @@ import { import { commands as notificationCommands } from "@hypr/plugin-notification"; import { Badge } from "@hypr/ui/components/ui/badge"; import { Button } from "@hypr/ui/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@hypr/ui/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@hypr/ui/components/ui/popover"; import { Select, SelectContent, @@ -21,23 +34,29 @@ import { import { Switch } from "@hypr/ui/components/ui/switch"; import { cn } from "@hypr/utils"; -import { getIgnoredAppOptions } from "./notification-app-options"; +import { + getEffectiveIgnoredPlatformIds, + getMicDetectionAppOptions, + orderIgnoredPlatformIdsForWrap, +} from "./notification-app-options"; import { useConfigValues } from "~/shared/config"; import * as settings from "~/store/tinybase/store/settings"; export function NotificationSettingsView() { - const [inputValue, setInputValue] = useState(""); - const [showDropdown, setShowDropdown] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(0); - const inputRef = useRef(null); - const containerRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const chipContainerRef = useRef(null); + const [chipContainerWidth, setChipContainerWidth] = useState( + null, + ); const configs = useConfigValues([ "notification_event", "notification_detect", "respect_dnd", "ignored_platforms", + "included_platforms", "mic_active_threshold", ] as const); @@ -106,6 +125,13 @@ export function NotificationSettingsView() { settings.STORE_ID, ); + const handleSetIncludedPlatforms = settings.UI.useSetValueCallback( + "included_platforms", + (value: string) => value, + [], + settings.STORE_ID, + ); + const handleSetMicActiveThreshold = settings.UI.useSetValueCallback( "mic_active_threshold", (value: number) => value, @@ -119,6 +145,7 @@ export function NotificationSettingsView() { notification_detect: configs.notification_detect, respect_dnd: configs.respect_dnd, ignored_platforms: configs.ignored_platforms, + included_platforms: configs.included_platforms, mic_active_threshold: configs.mic_active_threshold, }, listeners: { @@ -131,6 +158,7 @@ export function NotificationSettingsView() { handleSetNotificationDetect(value.notification_detect); handleSetRespectDnd(value.respect_dnd); handleSetIgnoredPlatforms(JSON.stringify(value.ignored_platforms)); + handleSetIncludedPlatforms(JSON.stringify(value.included_platforms)); handleSetMicActiveThreshold(value.mic_active_threshold); }, }); @@ -138,87 +166,70 @@ export function NotificationSettingsView() { const anyNotificationEnabled = configs.notification_event || configs.notification_detect; const ignoredPlatforms = form.getFieldValue("ignored_platforms"); + const includedPlatforms = form.getFieldValue("included_platforms"); - const dropdownOptions = getIgnoredAppOptions({ + const dropdownOptions = getMicDetectionAppOptions({ allInstalledApps, ignoredPlatforms, - inputValue, + includedPlatforms, + inputValue: searchQuery, defaultIgnoredBundleIds, }); + const effectiveIgnoredPlatformIds = orderIgnoredPlatformIdsForWrap({ + bundleIds: getEffectiveIgnoredPlatformIds({ + installedApps: allInstalledApps, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, + }), + availableWidth: chipContainerWidth, + bundleIdToName, + isDefaultIgnored, + }); - const handleAddIgnoredApp = (bundleId: string) => { - if ( - !bundleId || - ignoredPlatforms.includes(bundleId) || - isDefaultIgnored(bundleId) - ) { + useEffect(() => { + const element = chipContainerRef.current; + if (!element) { return; } - form.setFieldValue("ignored_platforms", [...ignoredPlatforms, bundleId]); - void form.handleSubmit(); - setInputValue(""); - setShowDropdown(false); - setSelectedIndex(0); - }; + const updateWidth = () => { + setChipContainerWidth(Math.max(0, element.clientWidth - 16)); + }; - const handleRemoveIgnoredApp = (bundleId: string) => { - const updated = ignoredPlatforms.filter( - (appId: string) => appId !== bundleId, - ); - form.setFieldValue("ignored_platforms", updated); - void form.handleSubmit(); - }; + updateWidth(); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && inputValue.trim()) { - e.preventDefault(); - const selectedApp = dropdownOptions[selectedIndex]; - if (selectedApp) { - handleAddIgnoredApp(selectedApp.id); - } - } else if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedIndex((prev) => - prev < dropdownOptions.length - 1 ? prev + 1 : prev, - ); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); - } else if (e.key === "Escape") { - setShowDropdown(false); - setSelectedIndex(0); - } else if ( - e.key === "Backspace" && - !inputValue && - ignoredPlatforms.length > 0 - ) { - const lastBundleId = ignoredPlatforms[ignoredPlatforms.length - 1]; - if (!isDefaultIgnored(lastBundleId)) { - handleRemoveIgnoredApp(lastBundleId); - } + const observer = new ResizeObserver(() => updateWidth()); + observer.observe(element); + + return () => observer.disconnect(); + }, []); + + const handleToggleIgnoredApp = (bundleId: string) => { + if (!bundleId) { + return; } - }; - const handleInputChange = (value: string) => { - setInputValue(value); - setShowDropdown(true); - setSelectedIndex(0); - }; + const defaultIgnored = isDefaultIgnored(bundleId); + const isCurrentlyIgnored = + ignoredPlatforms.includes(bundleId) || + (defaultIgnored && !includedPlatforms.includes(bundleId)); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setShowDropdown(false); - } - }; + const nextIgnoredPlatforms = isCurrentlyIgnored + ? ignoredPlatforms.filter((appId: string) => appId !== bundleId) + : [...ignoredPlatforms, bundleId]; + const nextIncludedPlatforms = isCurrentlyIgnored + ? defaultIgnored && !includedPlatforms.includes(bundleId) + ? [...includedPlatforms, bundleId] + : includedPlatforms + : includedPlatforms.filter((appId: string) => appId !== bundleId); - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); + form.setFieldValue("ignored_platforms", nextIgnoredPlatforms); + form.setFieldValue("included_platforms", nextIncludedPlatforms); + void form.handleSubmit(); + setSearchOpen(false); + setSearchQuery(""); + }; return (
@@ -296,87 +307,107 @@ export function NotificationSettingsView() { Exclude apps from detection

- These apps will not trigger detection. + Search installed apps to exclude them. Click an excluded app + to include it again.

-
-
inputRef.current?.focus()} - > - {ignoredPlatforms.map((bundleId: string) => { - const isDefault = isDefaultIgnored(bundleId); - return ( - - {bundleIdToName(bundleId)} - {isDefault && ( - - (default) - - )} - {!isDefault && ( - - )} - - ); - })} - handleInputChange(e.target.value)} - onKeyDown={handleKeyDown} - onFocus={() => setShowDropdown(true)} - /> -
- {showDropdown && dropdownOptions.length > 0 && ( -
-
- {dropdownOptions.map((app, index) => { +
+ + +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSearchOpen(true); + } + }} + > + {effectiveIgnoredPlatformIds.map((bundleId: string) => { + const isDefault = isDefaultIgnored(bundleId); return ( - + {bundleIdToName(bundleId)} + {isDefault && ( + + (default) + + )} + + ); })} + + Search installed apps... +
-
- )} + + + + + +
+ No apps found. +
+
+ + + {dropdownOptions.map((app) => ( + handleToggleIgnoredApp(app.id)} + className={cn([ + "cursor-pointer", + "hover:bg-neutral-200! focus:bg-neutral-200! aria-selected:bg-transparent", + ])} + > + + {app.name} + + + ))} + + +
+
+
)} diff --git a/apps/desktop/src/shared/config/index.ts b/apps/desktop/src/shared/config/index.ts index f066f36211..a9ec8179c8 100644 --- a/apps/desktop/src/shared/config/index.ts +++ b/apps/desktop/src/shared/config/index.ts @@ -1,7 +1,10 @@ import * as settings from "~/store/tinybase/store/settings"; import type { SettingsValueKey } from "~/store/tinybase/store/settings"; -type JsonParsedKeys = "spoken_languages" | "ignored_platforms"; +type JsonParsedKeys = + | "spoken_languages" + | "ignored_platforms" + | "included_platforms"; type ConfigValueType = K extends JsonParsedKeys ? string[] @@ -28,7 +31,11 @@ export function useConfigValue( const defaultValue = "default" in mapping ? mapping.default : undefined; if (storedValue !== undefined) { - if (key === "ignored_platforms" || key === "spoken_languages") { + if ( + key === "ignored_platforms" || + key === "included_platforms" || + key === "spoken_languages" + ) { return tryParseJSON( storedValue, JSON.parse(defaultValue as string), @@ -53,7 +60,11 @@ export function useConfigValues( const defaultValue = "default" in mapping ? mapping.default : undefined; if (storedValue !== undefined) { - if (key === "ignored_platforms" || key === "spoken_languages") { + if ( + key === "ignored_platforms" || + key === "included_platforms" || + key === "spoken_languages" + ) { result[key] = tryParseJSON( storedValue, defaultValue, diff --git a/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts b/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts index 34a7f46b5a..315ccea7be 100644 --- a/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts +++ b/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts @@ -94,6 +94,7 @@ describe("settingsPersister roundtrip", () => { detect: false, respect_dnd: true, ignored_platforms: ["zoom", "slack"], + included_platforms: ["code"], }, general: { autostart: true, @@ -154,6 +155,7 @@ describe("settingsPersister roundtrip", () => { notification_detect: false, respect_dnd: true, ignored_platforms: '["zoom"]', + included_platforms: '["code"]', autostart: true, save_recordings: false, telemetry_consent: false, @@ -289,6 +291,7 @@ describe("settingsPersister roundtrip", () => { }, notification: { ignored_platforms: '["zoom"]', + included_platforms: '["code"]', }, }; @@ -305,6 +308,7 @@ describe("settingsPersister roundtrip", () => { }); expect(result.notification).toEqual({ ignored_platforms: ["zoom"], + included_platforms: ["code"], }); }); @@ -315,6 +319,7 @@ describe("settingsPersister roundtrip", () => { }, notification: { ignored_platforms: "zoom,slack", + included_platforms: "code,terminal", }, }; @@ -331,6 +336,7 @@ describe("settingsPersister roundtrip", () => { }); expect(result.notification).toEqual({ ignored_platforms: ["zoom", "slack"], + included_platforms: ["code", "terminal"], }); }); diff --git a/apps/desktop/src/store/tinybase/persister/settings/transform.ts b/apps/desktop/src/store/tinybase/persister/settings/transform.ts index 387c6cc793..afca9c0575 100644 --- a/apps/desktop/src/store/tinybase/persister/settings/transform.ts +++ b/apps/desktop/src/store/tinybase/persister/settings/transform.ts @@ -9,6 +9,7 @@ type ProviderRow = { type: "llm" | "stt"; base_url: string; api_key: string }; const JSON_ARRAY_FIELDS = new Set([ "spoken_languages", "ignored_platforms", + "included_platforms", "ignored_recurring_series", ]); diff --git a/apps/desktop/src/store/tinybase/store/settings.ts b/apps/desktop/src/store/tinybase/store/settings.ts index e45394d126..0a6be51168 100644 --- a/apps/desktop/src/store/tinybase/store/settings.ts +++ b/apps/desktop/src/store/tinybase/store/settings.ts @@ -71,6 +71,11 @@ export const SETTINGS_MAPPING = { path: ["notification", "ignored_platforms"], default: "[]" as string, }, + included_platforms: { + type: "string", + path: ["notification", "included_platforms"], + default: "[]" as string, + }, mic_active_threshold: { type: "number", path: ["notification", "mic_active_threshold"], @@ -279,6 +284,12 @@ const SETTINGS_LISTENERS: SettingsListeners = { detectCommands.setIgnoredBundleIds(parsed).catch(console.error); } catch {} }, + included_platforms: (_store, newValue) => { + try { + const parsed = JSON.parse(newValue); + detectCommands.setIncludedBundleIds(parsed).catch(console.error); + } catch {} + }, mic_active_threshold: (_store, newValue) => { detectCommands.setMicActiveThreshold(newValue).catch(console.error); }, diff --git a/crates/db-user/src/config_types.rs b/crates/db-user/src/config_types.rs index 2110e48c8b..994b9ab1d7 100644 --- a/crates/db-user/src/config_types.rs +++ b/crates/db-user/src/config_types.rs @@ -73,6 +73,8 @@ user_common_derives! { pub auto: bool, #[serde(rename = "ignoredPlatforms")] pub ignored_platforms: Option>, + #[serde(rename = "includedPlatforms")] + pub included_platforms: Option>, } } @@ -82,6 +84,7 @@ impl Default for ConfigNotification { before: true, auto: true, ignored_platforms: None, + included_platforms: None, } } } diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts index 8f36b532e1..37f35e81bc 100644 --- a/packages/store/src/tinybase.ts +++ b/packages/store/src/tinybase.ts @@ -171,6 +171,7 @@ export const valueSchemaForTinybase = { ai_language: { type: "string" }, spoken_languages: { type: "string" }, ignored_platforms: { type: "string" }, + included_platforms: { type: "string" }, ignored_events: { type: "string" }, ignored_recurring_series: { type: "string" }, current_llm_provider: { type: "string" }, diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts index cba1072aa6..c463e77bf4 100644 --- a/packages/store/src/zod.ts +++ b/packages/store/src/zod.ts @@ -266,6 +266,7 @@ export const generalSchema = z.object({ ai_language: z.string().default("en"), spoken_languages: jsonObject(z.array(z.string()).default(["en"])), ignored_platforms: jsonObject(z.array(z.string()).default([])), + included_platforms: jsonObject(z.array(z.string()).default([])), ignored_events: jsonObject(z.array(ignoredEventEntrySchema).default([])), ignored_recurring_series: jsonObject( z.array(ignoredRecurringSeriesEntrySchema).default([]), diff --git a/plugins/detect/js/bindings.gen.ts b/plugins/detect/js/bindings.gen.ts index cdb85d70e7..628caf20b4 100644 --- a/plugins/detect/js/bindings.gen.ts +++ b/plugins/detect/js/bindings.gen.ts @@ -38,6 +38,14 @@ async setIgnoredBundleIds(bundleIds: string[]) : Promise> { else return { status: "error", error: e as any }; } }, +async setIncludedBundleIds(bundleIds: string[]) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:detect|set_included_bundle_ids", { bundleIds }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async listDefaultIgnoredBundleIds() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("plugin:detect|list_default_ignored_bundle_ids") }; diff --git a/plugins/detect/src/commands.rs b/plugins/detect/src/commands.rs index ca1f33fb17..4994fe1fd8 100644 --- a/plugins/detect/src/commands.rs +++ b/plugins/detect/src/commands.rs @@ -34,6 +34,16 @@ pub(crate) async fn set_ignored_bundle_ids( Ok(()) } +#[tauri::command] +#[specta::specta] +pub(crate) async fn set_included_bundle_ids( + app: tauri::AppHandle, + bundle_ids: Vec, +) -> Result<(), String> { + app.detect().set_included_bundle_ids(bundle_ids); + Ok(()) +} + #[tauri::command] #[specta::specta] pub(crate) async fn set_respect_do_not_disturb( diff --git a/plugins/detect/src/ext.rs b/plugins/detect/src/ext.rs index 4ed3d09799..46e181b6b4 100644 --- a/plugins/detect/src/ext.rs +++ b/plugins/detect/src/ext.rs @@ -25,6 +25,23 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Detect<'a, R, M> { state_guard.policy.user_ignored_bundle_ids = bundle_ids.into_iter().collect(); } + pub fn set_included_bundle_ids(&self, bundle_ids: Vec) { + let state = self.manager.state::(); + let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner()); + let next_ids = bundle_ids + .into_iter() + .collect::>(); + + let prev_ids = state_guard.policy.user_included_bundle_ids.clone(); + for id in &prev_ids { + if !next_ids.contains(id) { + state_guard.mic_usage_tracker.cancel_app(id); + } + } + + state_guard.policy.user_included_bundle_ids = next_ids; + } + pub fn set_respect_do_not_disturb(&self, enabled: bool) { let state = self.manager.state::(); let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/plugins/detect/src/lib.rs b/plugins/detect/src/lib.rs index 0e955049ac..a30c40baab 100644 --- a/plugins/detect/src/lib.rs +++ b/plugins/detect/src/lib.rs @@ -64,6 +64,7 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::list_mic_using_applications::, commands::set_respect_do_not_disturb::, commands::set_ignored_bundle_ids::, + commands::set_included_bundle_ids::, commands::list_default_ignored_bundle_ids::, commands::get_preferred_languages::, commands::get_current_locale_identifier::, diff --git a/plugins/detect/src/policy.rs b/plugins/detect/src/policy.rs index 3797908d5b..eb1cef6379 100644 --- a/plugins/detect/src/policy.rs +++ b/plugins/detect/src/policy.rs @@ -110,12 +110,17 @@ pub struct MicNotificationPolicy { pub respect_dnd: bool, pub ignored_categories: Vec, pub user_ignored_bundle_ids: HashSet, + pub user_included_bundle_ids: HashSet, } impl MicNotificationPolicy { pub fn should_track_app(&self, app_id: &str) -> bool { - AppCategory::find_category(app_id).is_none() - && !self.user_ignored_bundle_ids.contains(app_id) + if self.user_ignored_bundle_ids.contains(app_id) { + return false; + } + + self.user_included_bundle_ids.contains(app_id) + || AppCategory::find_category(app_id).is_none() } fn filter_apps( @@ -136,8 +141,12 @@ impl MicNotificationPolicy { let filtered_apps: Vec<_> = apps .iter() .filter(|app| { - !self.user_ignored_bundle_ids.contains(&app.id) - && !ignored_from_categories.contains(app.id.as_str()) + if self.user_ignored_bundle_ids.contains(&app.id) { + return false; + } + + self.user_included_bundle_ids.contains(&app.id) + || !ignored_from_categories.contains(app.id.as_str()) }) .cloned() .collect(); @@ -174,6 +183,7 @@ impl Default for MicNotificationPolicy { respect_dnd: false, ignored_categories: AppCategory::all().to_vec(), user_ignored_bundle_ids: HashSet::new(), + user_included_bundle_ids: HashSet::new(), } } } @@ -296,6 +306,15 @@ mod tests { assert!(!policy.should_track_app("us.zoom.xos")); } + #[test] + fn test_should_track_user_included_categorized_app() { + let policy = MicNotificationPolicy { + user_included_bundle_ids: HashSet::from(["com.microsoft.VSCode".to_string()]), + ..Default::default() + }; + assert!(policy.should_track_app("com.microsoft.VSCode")); + } + #[test] fn test_user_ignored_does_not_affect_other_apps() { let policy = MicNotificationPolicy { @@ -352,6 +371,33 @@ mod tests { ); } + #[test] + fn test_evaluate_keeps_user_included_default_app() { + let policy = MicNotificationPolicy { + user_included_bundle_ids: HashSet::from(["com.microsoft.VSCode".to_string()]), + ..Default::default() + }; + let apps = vec![app("com.microsoft.VSCode")]; + let ctx = PolicyContext { + apps: &apps, + is_dnd: false, + event_type: MicEventType::Started, + }; + let result = policy.evaluate(&ctx).unwrap(); + assert_eq!(result.filtered_apps.len(), 1); + assert_eq!(result.filtered_apps[0].id, "com.microsoft.VSCode"); + } + + #[test] + fn test_user_ignored_overrides_user_included() { + let policy = MicNotificationPolicy { + user_ignored_bundle_ids: HashSet::from(["com.microsoft.VSCode".to_string()]), + user_included_bundle_ids: HashSet::from(["com.microsoft.VSCode".to_string()]), + ..Default::default() + }; + assert!(!policy.should_track_app("com.microsoft.VSCode")); + } + #[test] fn test_evaluate_mixed_apps_keeps_unknown_only() { let policy = MicNotificationPolicy::default();