fix: restore Windows desktop notifications

This commit is contained in:
Relakkes Yang 2026-05-08 23:22:58 +08:00
parent dfb69976fd
commit a0624eeb21
6 changed files with 148 additions and 9 deletions

View File

@ -830,6 +830,26 @@ async fn macos_send_notification(
.await
}
#[tauri::command]
fn open_windows_notification_settings() -> Result<bool, String> {
open_windows_notification_settings_impl()
}
#[cfg(target_os = "windows")]
fn open_windows_notification_settings_impl() -> Result<bool, String> {
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<bool, String> {
Ok(false)
}
async fn run_notification_bridge<T, F>(operation: F) -> Result<T, String>
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)

View File

@ -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.',
})
})

View File

@ -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',

View File

@ -657,7 +657,7 @@ export const zh: Record<TranslationKey, string> = {
'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 域名预检',

View File

@ -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,

View File

@ -26,6 +26,7 @@ type NativeNotificationPayload = {
type NativeNotificationSender = (options: NativeNotificationPayload) => Promise<boolean> | boolean
export type DesktopNotificationPermission = NotificationPermission | 'unsupported'
type PluginPermissionState = DesktopNotificationPermission | 'prompt' | 'prompt-with-rationale'
const TARGET_EXTRA_KEY = 'ccHahaTarget'
const notifiedKeys = new Set<string>()
@ -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<DesktopNotificationPermission | null> {
if (detectPlatform() !== 'win32') return null
try {
const { invoke } = await import('@tauri-apps/api/core')
const granted = await invoke<boolean | null>('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<DesktopNotificationPermission | null> {
if (detectPlatform() !== 'win32') return null
try {
const { invoke } = await import('@tauri-apps/api/core')
const permission = await invoke<PluginPermissionState>('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<DesktopNotificationPermission | null> {
if (detectPlatform() !== 'darwin') return null
@ -187,6 +230,9 @@ export async function getDesktopNotificationPermission(): Promise<DesktopNotific
const macPermission = await invokeMacNotificationPermissionState()
if (macPermission) return macPermission
const windowsPermission = await invokeWindowsNotificationPermissionState()
if (windowsPermission) return windowsPermission
try {
const { isPermissionGranted } = await import('@tauri-apps/plugin-notification')
if (await isPermissionGranted()) return 'granted'
@ -200,6 +246,9 @@ export async function requestDesktopNotificationPermission(): Promise<DesktopNot
const macPermission = await invokeMacNotificationPermissionRequest()
if (macPermission) return macPermission
const windowsPermission = await invokeWindowsNotificationPermissionRequest()
if (windowsPermission) return windowsPermission
try {
const {
isPermissionGranted,
@ -217,6 +266,16 @@ export async function openDesktopNotificationSettings(): Promise<boolean> {
const url = getNotificationSettingsUrl()
if (!url) return false
if (detectPlatform() === 'win32') {
try {
const { invoke } = await import('@tauri-apps/api/core')
const opened = await invoke<boolean>('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
}