Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions apps/desktop/src/settings/general/notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
93 changes: 93 additions & 0 deletions apps/desktop/src/store/tinybase/store/settings.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
21 changes: 21 additions & 0 deletions apps/desktop/src/store/tinybase/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand All @@ -322,6 +342,7 @@ function registerSettingsListeners(store: Store): () => void {
}),
);
}
applyInitialSettingsSideEffects(store);

return () => {
for (const id of cleanups) {
Expand Down
72 changes: 70 additions & 2 deletions plugins/detect/src/ext.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
use std::collections::HashSet;

pub struct Detect<'a, R: tauri::Runtime, M: tauri::Manager<R>> {
manager: &'a M,
_runtime: std::marker::PhantomData<fn() -> R>,
}

fn resolve_ignored_bundle_ids(
bundle_ids: Vec<String>,
apps: &[hypr_detect::InstalledApp],
) -> HashSet<String> {
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<R>> Detect<'a, R, M> {
pub fn list_installed_applications(&self) -> Vec<hypr_detect::InstalledApp> {
hypr_detect::list_installed_apps()
Expand All @@ -19,10 +43,14 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager<R>> Detect<'a, R, M> {
pub fn set_ignored_bundle_ids(&self, bundle_ids: Vec<String>) {
let state = self.manager.state::<crate::ProcessorState>();
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) {
Expand Down Expand Up @@ -55,3 +83,43 @@ impl<R: tauri::Runtime, T: tauri::Manager<R>> DetectPluginExt<R> 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()]));
}
}
35 changes: 34 additions & 1 deletion plugins/detect/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 => &[
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading