From a0624eeb2173c04b8fc790e3d6ed6a6ccca876f0 Mon Sep 17 00:00:00 2001 From: Relakkes Yang Date: Fri, 8 May 2026 23:22:58 +0800 Subject: [PATCH] fix: restore Windows desktop notifications --- desktop/src-tauri/src/lib.rs | 23 +++++- .../src/__tests__/generalSettings.test.tsx | 2 +- desktop/src/i18n/locales/en.ts | 2 +- desktop/src/i18n/locales/zh.ts | 2 +- desktop/src/lib/desktopNotifications.test.ts | 57 +++++++++++++++ desktop/src/lib/desktopNotifications.ts | 71 +++++++++++++++++-- 6 files changed, 148 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b2bccacf..b7e70b8f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -830,6 +830,26 @@ async fn macos_send_notification( .await } +#[tauri::command] +fn open_windows_notification_settings() -> Result { + open_windows_notification_settings_impl() +} + +#[cfg(target_os = "windows")] +fn open_windows_notification_settings_impl() -> Result { + StdCommand::new("explorer.exe") + .arg("ms-settings:notifications") + .spawn() + .map_err(|err| format!("open Windows notification settings: {err}"))?; + + Ok(true) +} + +#[cfg(not(target_os = "windows"))] +fn open_windows_notification_settings_impl() -> Result { + Ok(false) +} + async fn run_notification_bridge(operation: F) -> Result where T: Send + 'static, @@ -1533,7 +1553,8 @@ pub fn run() { terminal_kill, macos_notification_permission_state, macos_request_notification_permission, - macos_send_notification + macos_send_notification, + open_windows_notification_settings ]); // macOS: native menu bar (traffic-light overlay style) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 47b7d6fe..e03dbe24 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -218,7 +218,7 @@ describe('Settings > General tab', () => { }) expect(desktopNotificationsMock.notifyDesktop).toHaveBeenCalledWith({ title: 'Claude Code Haha notifications are enabled', - body: 'Permission prompts and completed agent replies will now use macOS notifications.', + body: 'Permission prompts and completed agent replies will now use system notifications.', }) }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 5db0007d..45920002 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -655,7 +655,7 @@ export const en = { 'settings.general.notificationsAuthorize': 'Authorize', 'settings.general.notificationsOpenSettings': 'Open Settings', 'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled', - 'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use macOS notifications.', + 'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use system notifications.', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', 'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.', 'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index cea5abaf..801f69ae 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -657,7 +657,7 @@ export const zh: Record = { 'settings.general.notificationsAuthorize': '授权通知', 'settings.general.notificationsOpenSettings': '打开系统设置', 'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用', - 'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过 macOS 系统通知提醒。', + 'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过系统通知提醒。', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', 'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检', diff --git a/desktop/src/lib/desktopNotifications.test.ts b/desktop/src/lib/desktopNotifications.test.ts index f55276c2..69783946 100644 --- a/desktop/src/lib/desktopNotifications.test.ts +++ b/desktop/src/lib/desktopNotifications.test.ts @@ -12,6 +12,9 @@ const coreApiMock = vi.hoisted(() => ({ const eventApiMock = vi.hoisted(() => ({ listen: vi.fn(), })) +const shellApiMock = vi.hoisted(() => ({ + open: vi.fn(), +})) const requestUserAttentionMock = vi.hoisted(() => vi.fn()) const windowApiMock = vi.hoisted(() => ({ requestUserAttention: requestUserAttentionMock, @@ -28,11 +31,13 @@ vi.mock('@tauri-apps/plugin-notification', () => notificationPluginMock) vi.mock('@tauri-apps/api/core', () => coreApiMock) vi.mock('@tauri-apps/api/event', () => eventApiMock) vi.mock('@tauri-apps/api/window', () => windowApiMock) +vi.mock('@tauri-apps/plugin-shell', () => shellApiMock) import { getDesktopNotificationPermission, installDesktopNotificationClickListener, notifyDesktop, + openDesktopNotificationSettings, requestDesktopNotificationPermission, resetDesktopNotificationsForTests, setNativeNotificationSenderForTests, @@ -45,6 +50,7 @@ describe('desktopNotifications', () => { resetDesktopNotificationsForTests() coreApiMock.invoke.mockReset() eventApiMock.listen.mockReset() + shellApiMock.open.mockReset() notificationPluginMock.isPermissionGranted.mockReset() notificationPluginMock.requestPermission.mockReset() notificationPluginMock.sendNotification.mockReset() @@ -231,6 +237,57 @@ describe('desktopNotifications', () => { expect(notificationPluginMock.requestPermission).toHaveBeenCalledTimes(1) }) + it('reads and requests Windows notification permission through the native plugin command', async () => { + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'Win32', + }) + coreApiMock.invoke + .mockResolvedValueOnce(true) + .mockResolvedValueOnce('granted') + + await expect(getDesktopNotificationPermission()).resolves.toBe('granted') + await expect(requestDesktopNotificationPermission()).resolves.toBe('granted') + + expect(coreApiMock.invoke).toHaveBeenNthCalledWith(1, 'plugin:notification|is_permission_granted') + expect(coreApiMock.invoke).toHaveBeenNthCalledWith(2, 'plugin:notification|request_permission') + expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled() + expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled() + }) + + it('sends Windows notifications when the native plugin reports permission granted', async () => { + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'Win32', + }) + coreApiMock.invoke.mockResolvedValueOnce(true) + + await expect(notifyDesktop({ + title: 'Permission required', + body: 'Approve command execution', + })).resolves.toBe(true) + + expect(coreApiMock.invoke).toHaveBeenCalledWith('plugin:notification|is_permission_granted') + expect(notificationPluginMock.isPermissionGranted).not.toHaveBeenCalled() + expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith({ + title: 'Permission required', + body: 'Approve command execution', + }) + }) + + it('opens Windows notification settings through the native command', async () => { + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'Win32', + }) + coreApiMock.invoke.mockResolvedValueOnce(true) + + await expect(openDesktopNotificationSettings()).resolves.toBe(true) + + expect(coreApiMock.invoke).toHaveBeenCalledWith('open_windows_notification_settings') + expect(shellApiMock.open).not.toHaveBeenCalled() + }) + it('reports and requests macOS notification permission through the native bridge', async () => { Object.defineProperty(navigator, 'platform', { configurable: true, diff --git a/desktop/src/lib/desktopNotifications.ts b/desktop/src/lib/desktopNotifications.ts index 27015a82..26450d76 100644 --- a/desktop/src/lib/desktopNotifications.ts +++ b/desktop/src/lib/desktopNotifications.ts @@ -26,6 +26,7 @@ type NativeNotificationPayload = { type NativeNotificationSender = (options: NativeNotificationPayload) => Promise | boolean export type DesktopNotificationPermission = NotificationPermission | 'unsupported' +type PluginPermissionState = DesktopNotificationPermission | 'prompt' | 'prompt-with-rationale' const TARGET_EXTRA_KEY = 'ccHahaTarget' const notifiedKeys = new Set() @@ -46,10 +47,12 @@ function readBrowserNotificationPermission(): DesktopNotificationPermission { function detectPlatform(): 'darwin' | 'win32' | 'linux' | 'unknown' { const platform = typeof navigator !== 'undefined' ? navigator.platform.toLowerCase() : '' const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : '' - const raw = `${platform} ${userAgent}` - if (raw.includes('mac')) return 'darwin' - if (raw.includes('win')) return 'win32' - if (raw.includes('linux')) return 'linux' + if (platform.includes('mac')) return 'darwin' + if (platform.includes('win')) return 'win32' + if (platform.includes('linux')) return 'linux' + if (userAgent.includes('mac')) return 'darwin' + if (userAgent.includes('win')) return 'win32' + if (userAgent.includes('linux')) return 'linux' return 'unknown' } @@ -64,6 +67,46 @@ function getNotificationSettingsUrl(): string | null { } } +function normalizePermission(value: unknown): DesktopNotificationPermission { + if (value === true) return 'granted' + if (value === false) return 'denied' + if (value === null) return 'default' + if (value === 'prompt' || value === 'prompt-with-rationale') return 'default' + return ['default', 'denied', 'granted', 'unsupported'].includes(value as string) + ? value as DesktopNotificationPermission + : 'unsupported' +} + +async function invokeWindowsNotificationPermissionState(): Promise { + if (detectPlatform() !== 'win32') return null + + try { + const { invoke } = await import('@tauri-apps/api/core') + const granted = await invoke('plugin:notification|is_permission_granted') + return normalizePermission(granted) + } catch (err) { + if (typeof console !== 'undefined') { + console.warn('[desktopNotifications] failed to read Windows notification permission:', err) + } + return 'unsupported' + } +} + +async function invokeWindowsNotificationPermissionRequest(): Promise { + if (detectPlatform() !== 'win32') return null + + try { + const { invoke } = await import('@tauri-apps/api/core') + const permission = await invoke('plugin:notification|request_permission') + return normalizePermission(permission) + } catch (err) { + if (typeof console !== 'undefined') { + console.warn('[desktopNotifications] failed to request Windows notification permission:', err) + } + return 'unsupported' + } +} + async function invokeMacNotificationPermissionState(): Promise { if (detectPlatform() !== 'darwin') return null @@ -187,6 +230,9 @@ export async function getDesktopNotificationPermission(): Promise { const url = getNotificationSettingsUrl() if (!url) return false + if (detectPlatform() === 'win32') { + try { + const { invoke } = await import('@tauri-apps/api/core') + const opened = await invoke('open_windows_notification_settings') + if (opened) return true + } catch { + // Fall back to shell.open/window.open below. + } + } + try { const { open } = await import('@tauri-apps/plugin-shell') await open(url) @@ -240,7 +299,9 @@ async function sendNativeNotification(options: { title: string; body?: string; t sendNotification, } = await import('@tauri-apps/plugin-notification') - if (!(await isPermissionGranted())) { + const windowsPermission = await invokeWindowsNotificationPermissionState() + const permissionGranted = windowsPermission ? windowsPermission === 'granted' : await isPermissionGranted() + if (!permissionGranted) { return false }