mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: route desktop notifications to target sessions
Desktop notifications now carry a narrow target payload so clicking a permission, completion, or scheduled-task notification can reopen the matching tab and reconnect the session. macOS uses the native notification bridge for tap callbacks, while the shared desktop notification layer also accepts plugin action payloads where the platform exposes them. Constraint: Notification clicks need to activate existing desktop tabs without adding a new navigation state channel. Rejected: Store only notification ids and infer the active session later | stale notifications would point at the wrong session after tab changes. Confidence: high Scope-risk: moderate Directive: Keep notification targets serializable and versioned through desktopNotifications before adding new target types. Tested: bun run quality:pr with ALLOW_CLI_CORE_CHANGE=1; desktop unit tests; server tests; native sidecar build and cargo check; docs build. Not-tested: Packaged Windows toast activation runtime; current fallback depends on Tauri notification action delivery.
This commit is contained in:
parent
578a967c5a
commit
b8e335f7bd
@ -31,6 +31,10 @@ use tauri_plugin_shell::{
|
||||
mod macos_notifications {
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::{c_char, c_int};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
const ERROR_BUFFER_LEN: usize = 1024;
|
||||
|
||||
@ -46,11 +50,23 @@ mod macos_notifications {
|
||||
fn cchh_send_user_notification(
|
||||
title: *const c_char,
|
||||
body: *const c_char,
|
||||
target: *const c_char,
|
||||
error_buffer: *mut c_char,
|
||||
error_buffer_len: usize,
|
||||
) -> bool;
|
||||
fn cchh_set_notification_response_callback(
|
||||
callback: Option<extern "C" fn(*const c_char)>,
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct NotificationClickPayload {
|
||||
target: Option<String>,
|
||||
}
|
||||
|
||||
static NOTIFICATION_APP_HANDLE: OnceLock<Mutex<Option<AppHandle>>> = OnceLock::new();
|
||||
|
||||
fn new_error_buffer() -> [c_char; ERROR_BUFFER_LEN] {
|
||||
[0; ERROR_BUFFER_LEN]
|
||||
}
|
||||
@ -106,13 +122,51 @@ mod macos_notifications {
|
||||
permission_state()
|
||||
}
|
||||
|
||||
pub fn send_notification(title: String, body: Option<String>) -> Result<bool, String> {
|
||||
extern "C" fn handle_notification_response(target: *const c_char) {
|
||||
let target = if target.is_null() {
|
||||
None
|
||||
} else {
|
||||
let value = unsafe { CStr::from_ptr(target) }
|
||||
.to_string_lossy()
|
||||
.trim()
|
||||
.to_string();
|
||||
(!value.is_empty()).then_some(value)
|
||||
};
|
||||
|
||||
let Some(app) = NOTIFICATION_APP_HANDLE
|
||||
.get()
|
||||
.and_then(|handle| handle.lock().ok().and_then(|guard| guard.clone()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
super::show_main_window(&app);
|
||||
let _ = app.emit("desktop-notification-clicked", NotificationClickPayload { target });
|
||||
}
|
||||
|
||||
pub fn install_click_handler(app: AppHandle) {
|
||||
let handle = NOTIFICATION_APP_HANDLE.get_or_init(|| Mutex::new(None));
|
||||
if let Ok(mut guard) = handle.lock() {
|
||||
*guard = Some(app);
|
||||
}
|
||||
unsafe { cchh_set_notification_response_callback(Some(handle_notification_response)) };
|
||||
}
|
||||
|
||||
pub fn send_notification(
|
||||
title: String,
|
||||
body: Option<String>,
|
||||
target: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let title = CString::new(title)
|
||||
.map_err(|_| "notification title contains an unsupported NUL byte".to_string())?;
|
||||
let body = body
|
||||
.map(CString::new)
|
||||
.transpose()
|
||||
.map_err(|_| "notification body contains an unsupported NUL byte".to_string())?;
|
||||
let target = target
|
||||
.map(CString::new)
|
||||
.transpose()
|
||||
.map_err(|_| "notification target contains an unsupported NUL byte".to_string())?;
|
||||
|
||||
let mut error_buffer = new_error_buffer();
|
||||
let sent = unsafe {
|
||||
@ -120,6 +174,8 @@ mod macos_notifications {
|
||||
title.as_ptr(),
|
||||
body.as_ref()
|
||||
.map_or(std::ptr::null(), |value| value.as_ptr()),
|
||||
target.as_ref()
|
||||
.map_or(std::ptr::null(), |value| value.as_ptr()),
|
||||
error_buffer.as_mut_ptr(),
|
||||
ERROR_BUFFER_LEN,
|
||||
)
|
||||
@ -138,6 +194,8 @@ mod macos_notifications {
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod macos_notifications {
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub fn permission_state() -> Result<String, String> {
|
||||
Ok("unsupported".to_string())
|
||||
}
|
||||
@ -146,7 +204,13 @@ mod macos_notifications {
|
||||
Ok("unsupported".to_string())
|
||||
}
|
||||
|
||||
pub fn send_notification(_title: String, _body: Option<String>) -> Result<bool, String> {
|
||||
pub fn install_click_handler(_app: AppHandle) {}
|
||||
|
||||
pub fn send_notification(
|
||||
_title: String,
|
||||
_body: Option<String>,
|
||||
_target: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@ -741,8 +805,12 @@ fn macos_request_notification_permission() -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn macos_send_notification(title: String, body: Option<String>) -> Result<bool, String> {
|
||||
macos_notifications::send_notification(title, body)
|
||||
fn macos_send_notification(
|
||||
title: String,
|
||||
body: Option<String>,
|
||||
target: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
macos_notifications::send_notification(title, body, target)
|
||||
}
|
||||
|
||||
fn decode_terminal_output(pending: &mut Vec<u8>, chunk: &[u8]) -> String {
|
||||
@ -1487,6 +1555,7 @@ pub fn run() {
|
||||
let app = builder
|
||||
.setup(|app| {
|
||||
setup_system_tray(app)?;
|
||||
macos_notifications::install_click_handler(app.handle().clone());
|
||||
restore_main_window_state(&app.handle());
|
||||
|
||||
let state = app.state::<ServerState>();
|
||||
|
||||
@ -5,9 +5,22 @@
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef void (*CCHHNotificationResponseCallback)(const char *target_json);
|
||||
|
||||
@interface CCHHNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
|
||||
@end
|
||||
|
||||
static CCHHNotificationResponseCallback cchhNotificationResponseCallback = NULL;
|
||||
|
||||
static void cchh_emit_notification_response(NSString *target) {
|
||||
if (cchhNotificationResponseCallback == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char *targetJson = target != nil ? [target UTF8String] : NULL;
|
||||
cchhNotificationResponseCallback(targetJson);
|
||||
}
|
||||
|
||||
@implementation CCHHNotificationDelegate
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
|
||||
willPresentNotification:(UNNotification *)notification
|
||||
@ -18,6 +31,14 @@
|
||||
completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
|
||||
didReceiveNotificationResponse:(UNNotificationResponse *)response
|
||||
withCompletionHandler:(void (^)(void))completionHandler {
|
||||
NSString *target = response.notification.request.content.userInfo[@"cchh_target"];
|
||||
cchh_emit_notification_response(target);
|
||||
completionHandler();
|
||||
}
|
||||
@end
|
||||
|
||||
@interface CCHHUserNotificationDelegate : NSObject <NSUserNotificationCenterDelegate>
|
||||
@ -27,6 +48,11 @@
|
||||
- (BOOL)userNotificationCenter:(NSUserNotificationCenter *)center shouldPresentNotification:(NSUserNotification *)notification {
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)userNotificationCenter:(NSUserNotificationCenter *)center didActivateNotification:(NSUserNotification *)notification {
|
||||
NSString *target = notification.userInfo[@"cchh_target"];
|
||||
cchh_emit_notification_response(target);
|
||||
}
|
||||
@end
|
||||
|
||||
static CCHHNotificationDelegate *cchhNotificationDelegate = nil;
|
||||
@ -52,6 +78,10 @@ static void cchh_copy_error(char *buffer, uintptr_t buffer_len, NSString *messag
|
||||
buffer[max_len] = '\0';
|
||||
}
|
||||
|
||||
void cchh_set_notification_response_callback(CCHHNotificationResponseCallback callback) {
|
||||
cchhNotificationResponseCallback = callback;
|
||||
}
|
||||
|
||||
static UNUserNotificationCenter *cchh_notification_center(void) {
|
||||
static dispatch_once_t onceToken;
|
||||
static UNUserNotificationCenter *center = nil;
|
||||
@ -136,7 +166,7 @@ bool cchh_request_notification_authorization(char *error_buffer, uintptr_t error
|
||||
}
|
||||
}
|
||||
|
||||
bool cchh_send_user_notification(const char *title, const char *body, char *error_buffer, uintptr_t error_buffer_len) {
|
||||
bool cchh_send_user_notification(const char *title, const char *body, const char *target, char *error_buffer, uintptr_t error_buffer_len) {
|
||||
@autoreleasepool {
|
||||
cchh_copy_error(error_buffer, error_buffer_len, nil);
|
||||
|
||||
@ -155,6 +185,9 @@ bool cchh_send_user_notification(const char *title, const char *body, char *erro
|
||||
if (body != NULL && strlen(body) > 0) {
|
||||
content.body = [NSString stringWithUTF8String:body];
|
||||
}
|
||||
if (target != NULL && strlen(target) > 0) {
|
||||
content.userInfo = @{ @"cchh_target": [NSString stringWithUTF8String:target] };
|
||||
}
|
||||
content.sound = [UNNotificationSound defaultSound];
|
||||
|
||||
NSString *identifier = [[NSUUID UUID] UUIDString];
|
||||
@ -185,6 +218,9 @@ bool cchh_send_user_notification(const char *title, const char *body, char *erro
|
||||
if (body != NULL && strlen(body) > 0) {
|
||||
notification.informativeText = [NSString stringWithUTF8String:body];
|
||||
}
|
||||
if (target != NULL && strlen(target) > 0) {
|
||||
notification.userInfo = @{ @"cchh_target": [NSString stringWithUTF8String:target] };
|
||||
}
|
||||
notification.soundName = NSUserNotificationDefaultSoundName;
|
||||
|
||||
[cchh_user_notification_center() deliverNotification:notification];
|
||||
|
||||
@ -1,7 +1,26 @@
|
||||
import { AppShell } from './components/layout/AppShell'
|
||||
import { useScheduledTaskDesktopNotifications } from './hooks/useScheduledTaskDesktopNotifications'
|
||||
import { installDesktopNotificationNavigation } from './lib/desktopNotificationNavigation'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function App() {
|
||||
useScheduledTaskDesktopNotifications()
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined
|
||||
let cancelled = false
|
||||
installDesktopNotificationNavigation()
|
||||
.then((fn) => {
|
||||
if (cancelled) {
|
||||
fn()
|
||||
} else {
|
||||
cleanup = fn
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
cleanup?.()
|
||||
}
|
||||
}, [])
|
||||
return <AppShell />
|
||||
}
|
||||
|
||||
@ -94,6 +94,52 @@ describe('useScheduledTaskDesktopNotifications', () => {
|
||||
dedupeKey: 'scheduled-task:run-new',
|
||||
title: '定时任务 Daily review',
|
||||
body: '失败: provider timeout',
|
||||
target: { type: 'scheduled' },
|
||||
})
|
||||
})
|
||||
|
||||
it('targets the run session when a scheduled task run has a session id', async () => {
|
||||
listMock.mockResolvedValue({
|
||||
tasks: [{
|
||||
id: 'task-1',
|
||||
name: 'Daily review',
|
||||
cron: '* * * * *',
|
||||
prompt: 'review',
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
notification: { enabled: true, channels: ['desktop'] },
|
||||
}],
|
||||
})
|
||||
getRecentRunsMock
|
||||
.mockResolvedValueOnce({ runs: [] })
|
||||
.mockResolvedValueOnce({
|
||||
runs: [{
|
||||
id: 'run-new',
|
||||
taskId: 'task-1',
|
||||
taskName: 'Daily review',
|
||||
startedAt: '2026-05-03T00:01:00.000Z',
|
||||
completedAt: '2026-05-03T00:01:01.000Z',
|
||||
status: 'completed',
|
||||
prompt: 'review',
|
||||
output: 'done',
|
||||
sessionId: 'session-task-run',
|
||||
}],
|
||||
})
|
||||
|
||||
render(<Harness />)
|
||||
await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(1))
|
||||
expect(notifyDesktopMock).toHaveBeenCalledWith({
|
||||
dedupeKey: 'scheduled-task:run-new',
|
||||
title: '定时任务 Daily review',
|
||||
body: '完成: done',
|
||||
target: {
|
||||
type: 'session',
|
||||
sessionId: 'session-task-run',
|
||||
title: 'Daily review',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -93,6 +93,9 @@ export function useScheduledTaskDesktopNotifications(): void {
|
||||
dedupeKey: `scheduled-task:${run.id}`,
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
target: run.sessionId
|
||||
? { type: 'session', sessionId: run.sessionId, title: run.taskName || run.taskId }
|
||||
: { type: 'scheduled' },
|
||||
})
|
||||
if (sent) notifiedRunIds.add(run.id)
|
||||
}
|
||||
|
||||
73
desktop/src/lib/desktopNotificationNavigation.test.ts
Normal file
73
desktop/src/lib/desktopNotificationNavigation.test.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { openDesktopNotificationTarget } from './desktopNotificationNavigation'
|
||||
import { SCHEDULED_TAB_ID, useTabStore } from '../stores/tabStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
|
||||
const initialTabState = useTabStore.getInitialState()
|
||||
const initialChatState = useChatStore.getInitialState()
|
||||
const initialSessionState = useSessionStore.getInitialState()
|
||||
|
||||
describe('desktopNotificationNavigation', () => {
|
||||
beforeEach(() => {
|
||||
useTabStore.setState(initialTabState, true)
|
||||
useChatStore.setState(initialChatState, true)
|
||||
useSessionStore.setState(initialSessionState, true)
|
||||
})
|
||||
|
||||
it('opens and connects the session referenced by a notification target', () => {
|
||||
const connectToSession = vi.fn()
|
||||
useChatStore.setState({ connectToSession })
|
||||
|
||||
openDesktopNotificationTarget({
|
||||
type: 'session',
|
||||
sessionId: 'session-1',
|
||||
title: 'Build fix',
|
||||
})
|
||||
|
||||
expect(useTabStore.getState().tabs).toEqual([
|
||||
{ sessionId: 'session-1', title: 'Build fix', type: 'session', status: 'idle' },
|
||||
])
|
||||
expect(useTabStore.getState().activeTabId).toBe('session-1')
|
||||
expect(connectToSession).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
|
||||
it('uses the known session title when the notification omits one', () => {
|
||||
const connectToSession = vi.fn()
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: 'session-2',
|
||||
title: 'Known Session',
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-06T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
})
|
||||
useChatStore.setState({ connectToSession })
|
||||
|
||||
openDesktopNotificationTarget({ type: 'session', sessionId: 'session-2' })
|
||||
|
||||
expect(useTabStore.getState().tabs[0]).toMatchObject({
|
||||
sessionId: 'session-2',
|
||||
title: 'Known Session',
|
||||
type: 'session',
|
||||
})
|
||||
expect(connectToSession).toHaveBeenCalledWith('session-2')
|
||||
})
|
||||
|
||||
it('opens the scheduled tasks tab for scheduled notification targets', () => {
|
||||
const connectToSession = vi.fn()
|
||||
useChatStore.setState({ connectToSession })
|
||||
|
||||
openDesktopNotificationTarget({ type: 'scheduled' })
|
||||
|
||||
expect(useTabStore.getState().tabs).toEqual([
|
||||
{ sessionId: SCHEDULED_TAB_ID, title: 'Scheduled Tasks', type: 'scheduled', status: 'idle' },
|
||||
])
|
||||
expect(useTabStore.getState().activeTabId).toBe(SCHEDULED_TAB_ID)
|
||||
expect(connectToSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
28
desktop/src/lib/desktopNotificationNavigation.ts
Normal file
28
desktop/src/lib/desktopNotificationNavigation.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { SCHEDULED_TAB_ID, useTabStore } from '../stores/tabStore'
|
||||
import {
|
||||
installDesktopNotificationClickListener,
|
||||
type DesktopNotificationTarget,
|
||||
} from './desktopNotifications'
|
||||
|
||||
const SCHEDULED_TAB_TITLE = 'Scheduled Tasks'
|
||||
|
||||
export function openDesktopNotificationTarget(target: DesktopNotificationTarget): void {
|
||||
if (target.type === 'scheduled') {
|
||||
useTabStore.getState().openTab(SCHEDULED_TAB_ID, SCHEDULED_TAB_TITLE, 'scheduled')
|
||||
return
|
||||
}
|
||||
|
||||
const knownTitle = useSessionStore
|
||||
.getState()
|
||||
.sessions
|
||||
.find((session) => session.id === target.sessionId)
|
||||
?.title
|
||||
useTabStore.getState().openTab(target.sessionId, target.title || knownTitle || 'Session', 'session')
|
||||
useChatStore.getState().connectToSession(target.sessionId)
|
||||
}
|
||||
|
||||
export function installDesktopNotificationNavigation(): Promise<() => void> {
|
||||
return installDesktopNotificationClickListener(openDesktopNotificationTarget)
|
||||
}
|
||||
@ -4,10 +4,14 @@ const notificationPluginMock = vi.hoisted(() => ({
|
||||
isPermissionGranted: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
sendNotification: vi.fn(),
|
||||
onAction: vi.fn(),
|
||||
}))
|
||||
const coreApiMock = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
const eventApiMock = vi.hoisted(() => ({
|
||||
listen: vi.fn(),
|
||||
}))
|
||||
const requestUserAttentionMock = vi.hoisted(() => vi.fn())
|
||||
const windowApiMock = vi.hoisted(() => ({
|
||||
requestUserAttention: requestUserAttentionMock,
|
||||
@ -22,10 +26,12 @@ const windowApiMock = vi.hoisted(() => ({
|
||||
|
||||
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)
|
||||
|
||||
import {
|
||||
getDesktopNotificationPermission,
|
||||
installDesktopNotificationClickListener,
|
||||
notifyDesktop,
|
||||
requestDesktopNotificationPermission,
|
||||
resetDesktopNotificationsForTests,
|
||||
@ -38,9 +44,11 @@ describe('desktopNotifications', () => {
|
||||
vi.useRealTimers()
|
||||
resetDesktopNotificationsForTests()
|
||||
coreApiMock.invoke.mockReset()
|
||||
eventApiMock.listen.mockReset()
|
||||
notificationPluginMock.isPermissionGranted.mockReset()
|
||||
notificationPluginMock.requestPermission.mockReset()
|
||||
notificationPluginMock.sendNotification.mockReset()
|
||||
notificationPluginMock.onAction.mockReset()
|
||||
windowApiMock.getCurrentWindow.mockClear()
|
||||
windowApiMock.requestUserAttention.mockReset()
|
||||
useSettingsStore.setState({ desktopNotificationsEnabled: true })
|
||||
@ -68,6 +76,27 @@ describe('desktopNotifications', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes notification targets through the Tauri plugin payload', async () => {
|
||||
notificationPluginMock.isPermissionGranted.mockResolvedValue(true)
|
||||
const target = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' }
|
||||
|
||||
await expect(notifyDesktop({
|
||||
dedupeKey: 'permission:targeted',
|
||||
title: 'Permission required',
|
||||
body: 'Approve command execution',
|
||||
target,
|
||||
})).resolves.toBe(true)
|
||||
|
||||
expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: 'Permission required',
|
||||
body: 'Approve command execution',
|
||||
id: expect.any(Number),
|
||||
extra: {
|
||||
ccHahaTarget: JSON.stringify(target),
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not request notification permission from a blocking permission prompt', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
notificationPluginMock.isPermissionGranted.mockResolvedValue(false)
|
||||
@ -103,6 +132,27 @@ describe('desktopNotifications', () => {
|
||||
expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes notification targets through the macOS native bridge', async () => {
|
||||
Object.defineProperty(navigator, 'platform', {
|
||||
configurable: true,
|
||||
value: 'MacIntel',
|
||||
})
|
||||
coreApiMock.invoke.mockResolvedValueOnce(true)
|
||||
const target = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' }
|
||||
|
||||
await expect(notifyDesktop({
|
||||
title: 'Permission required',
|
||||
body: 'Approve command execution',
|
||||
target,
|
||||
})).resolves.toBe(true)
|
||||
|
||||
expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_send_notification', {
|
||||
title: 'Permission required',
|
||||
body: 'Approve command execution',
|
||||
target: JSON.stringify(target),
|
||||
})
|
||||
})
|
||||
|
||||
it('does not request macOS permission from a blocking permission prompt', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
Object.defineProperty(navigator, 'platform', {
|
||||
@ -235,6 +285,49 @@ describe('desktopNotifications', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards notification click targets from native and plugin listeners', async () => {
|
||||
const unlistenNative = vi.fn()
|
||||
const unregisterPlugin = vi.fn()
|
||||
type NativeClickCallback = (event: { payload: unknown }) => void
|
||||
type PluginClickCallback = (notification: unknown) => void
|
||||
let nativeCallback: NativeClickCallback = () => {
|
||||
throw new Error('native listener was not registered')
|
||||
}
|
||||
let pluginCallback: PluginClickCallback = () => {
|
||||
throw new Error('plugin listener was not registered')
|
||||
}
|
||||
let nativeRegistered = false
|
||||
let pluginRegistered = false
|
||||
const sessionTarget = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' }
|
||||
const scheduledTarget = { type: 'scheduled' as const }
|
||||
|
||||
eventApiMock.listen.mockImplementation(async (_eventName: string, callback: (event: { payload: unknown }) => void) => {
|
||||
nativeCallback = callback
|
||||
nativeRegistered = true
|
||||
return unlistenNative
|
||||
})
|
||||
notificationPluginMock.onAction.mockImplementation(async (callback: (notification: unknown) => void) => {
|
||||
pluginCallback = callback
|
||||
pluginRegistered = true
|
||||
return { unregister: unregisterPlugin }
|
||||
})
|
||||
|
||||
const onTarget = vi.fn()
|
||||
const cleanup = await installDesktopNotificationClickListener(onTarget)
|
||||
|
||||
expect(nativeRegistered).toBe(true)
|
||||
expect(pluginRegistered).toBe(true)
|
||||
nativeCallback({ payload: { target: JSON.stringify(sessionTarget) } })
|
||||
pluginCallback({ extra: { ccHahaTarget: JSON.stringify(scheduledTarget) } })
|
||||
|
||||
expect(onTarget).toHaveBeenCalledWith(sessionTarget)
|
||||
expect(onTarget).toHaveBeenCalledWith(scheduledTarget)
|
||||
|
||||
cleanup()
|
||||
expect(unlistenNative).toHaveBeenCalledTimes(1)
|
||||
expect(unregisterPlugin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('requests OS-level window attention for blocking prompts', async () => {
|
||||
const sender = vi.fn(async () => true)
|
||||
setNativeNotificationSenderForTests(sender)
|
||||
|
||||
@ -9,16 +9,32 @@ export type DesktopNotificationOptions = {
|
||||
cooldownScope?: string
|
||||
cooldownMs?: number
|
||||
requestAttention?: boolean
|
||||
target?: DesktopNotificationTarget
|
||||
}
|
||||
|
||||
type NativeNotificationSender = (options: { title: string; body?: string }) => Promise<boolean> | boolean
|
||||
export type DesktopNotificationTarget =
|
||||
| { type: 'session'; sessionId: string; title?: string }
|
||||
| { type: 'scheduled' }
|
||||
|
||||
type NativeNotificationPayload = {
|
||||
title: string
|
||||
body?: string
|
||||
id?: number
|
||||
extra?: Record<string, unknown>
|
||||
target?: DesktopNotificationTarget
|
||||
}
|
||||
|
||||
type NativeNotificationSender = (options: NativeNotificationPayload) => Promise<boolean> | boolean
|
||||
export type DesktopNotificationPermission = NotificationPermission | 'unsupported'
|
||||
|
||||
const TARGET_EXTRA_KEY = 'ccHahaTarget'
|
||||
const notifiedKeys = new Set<string>()
|
||||
const pendingKeys = new Set<string>()
|
||||
const lastNotificationAtByScope = new Map<string, number>()
|
||||
const pendingCooldownScopes = new Set<string>()
|
||||
const notificationTargetById = new Map<number, DesktopNotificationTarget>()
|
||||
let overrideNativeNotificationSender: NativeNotificationSender | null = null
|
||||
let nextNotificationId = 1
|
||||
|
||||
function readBrowserNotificationPermission(): DesktopNotificationPermission {
|
||||
if (typeof window === 'undefined' || !('Notification' in window)) {
|
||||
@ -78,12 +94,86 @@ async function invokeMacNotificationPermissionRequest(): Promise<DesktopNotifica
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMacNotification(options: { title: string; body?: string }): Promise<boolean | null> {
|
||||
function isNotificationTarget(value: unknown): value is DesktopNotificationTarget {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const target = value as Partial<DesktopNotificationTarget>
|
||||
if (target.type === 'scheduled') return true
|
||||
if (target.type === 'session') {
|
||||
return typeof target.sessionId === 'string' && target.sessionId.length > 0 &&
|
||||
(target.title === undefined || typeof target.title === 'string')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function parseTargetJson(value: unknown): DesktopNotificationTarget | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
return isNotificationTarget(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function notificationTargetFromPayload(payload: unknown): DesktopNotificationTarget | null {
|
||||
if (isNotificationTarget(payload)) return payload
|
||||
const jsonTarget = parseTargetJson(payload)
|
||||
if (jsonTarget) return jsonTarget
|
||||
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const record = payload as Record<string, unknown>
|
||||
|
||||
const directTarget = notificationTargetFromPayload(record.target)
|
||||
if (directTarget) return directTarget
|
||||
|
||||
const extra = record.extra && typeof record.extra === 'object'
|
||||
? record.extra as Record<string, unknown>
|
||||
: null
|
||||
const extraTarget = extra ? notificationTargetFromPayload(extra[TARGET_EXTRA_KEY]) : null
|
||||
if (extraTarget) return extraTarget
|
||||
|
||||
const data = record.data && typeof record.data === 'object'
|
||||
? record.data as Record<string, unknown>
|
||||
: null
|
||||
const dataTarget = data ? notificationTargetFromPayload(data[TARGET_EXTRA_KEY]) : null
|
||||
if (dataTarget) return dataTarget
|
||||
|
||||
const id = typeof record.id === 'number' ? record.id : null
|
||||
return id !== null ? notificationTargetById.get(id) ?? null : null
|
||||
}
|
||||
|
||||
function buildNativeNotificationPayload(options: {
|
||||
title: string
|
||||
body?: string
|
||||
target?: DesktopNotificationTarget
|
||||
}): NativeNotificationPayload {
|
||||
const payload: NativeNotificationPayload = {
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
}
|
||||
|
||||
if (options.target) {
|
||||
const id = nextNotificationId++
|
||||
notificationTargetById.set(id, options.target)
|
||||
payload.id = id
|
||||
payload.extra = { [TARGET_EXTRA_KEY]: JSON.stringify(options.target) }
|
||||
payload.target = options.target
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function sendMacNotification(options: { title: string; body?: string; target?: DesktopNotificationTarget }): Promise<boolean | null> {
|
||||
if (detectPlatform() !== 'darwin') return null
|
||||
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const sent = await invoke<boolean>('macos_send_notification', options)
|
||||
const target = options.target ? JSON.stringify(options.target) : undefined
|
||||
const sent = await invoke<boolean>('macos_send_notification', {
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
...(target ? { target } : {}),
|
||||
})
|
||||
return typeof sent === 'boolean' ? sent : false
|
||||
} catch (err) {
|
||||
if (typeof console !== 'undefined') {
|
||||
@ -141,7 +231,7 @@ export async function openDesktopNotificationSettings(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNativeNotification(options: { title: string; body?: string }): Promise<boolean> {
|
||||
async function sendNativeNotification(options: { title: string; body?: string; target?: DesktopNotificationTarget }): Promise<boolean> {
|
||||
const macSent = await sendMacNotification(options)
|
||||
if (macSent !== null) return macSent
|
||||
|
||||
@ -154,7 +244,8 @@ async function sendNativeNotification(options: { title: string; body?: string })
|
||||
return false
|
||||
}
|
||||
|
||||
sendNotification(options)
|
||||
const payload = buildNativeNotificationPayload(options)
|
||||
sendNotification(payload)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -168,6 +259,66 @@ async function requestWindowAttention(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function focusCurrentWindow(): Promise<void> {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
const win = getCurrentWindow()
|
||||
await (win as unknown as { show?: () => Promise<void> | void }).show?.()
|
||||
await (win as unknown as { setFocus?: () => Promise<void> | void }).setFocus?.()
|
||||
} catch {
|
||||
// Best effort only: the notification target can still be opened in the UI.
|
||||
}
|
||||
}
|
||||
|
||||
function disposePluginListener(listener: unknown): void {
|
||||
if (typeof listener === 'function') {
|
||||
listener()
|
||||
return
|
||||
}
|
||||
const unregister = listener && typeof listener === 'object'
|
||||
? (listener as { unregister?: () => Promise<void> | void }).unregister
|
||||
: undefined
|
||||
if (unregister) void unregister()
|
||||
}
|
||||
|
||||
export async function installDesktopNotificationClickListener(
|
||||
onTarget: (target: DesktopNotificationTarget) => void,
|
||||
): Promise<() => void> {
|
||||
const cleanups: Array<() => void> = []
|
||||
const handlePayload = (payload: unknown) => {
|
||||
const target = notificationTargetFromPayload(payload)
|
||||
if (!target) return
|
||||
void focusCurrentWindow()
|
||||
onTarget(target)
|
||||
}
|
||||
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
const unlisten = await listen<unknown>('desktop-notification-clicked', (event) => {
|
||||
handlePayload(event.payload)
|
||||
})
|
||||
cleanups.push(unlisten)
|
||||
} catch {
|
||||
// Non-Tauri browser tests and unsupported runtimes do not expose native events.
|
||||
}
|
||||
|
||||
try {
|
||||
const { onAction } = await import('@tauri-apps/plugin-notification') as {
|
||||
onAction?: (cb: (notification: unknown) => void) => Promise<unknown>
|
||||
}
|
||||
if (onAction) {
|
||||
const listener = await onAction(handlePayload)
|
||||
cleanups.push(() => disposePluginListener(listener))
|
||||
}
|
||||
} catch {
|
||||
// The desktop plugin does not expose click actions on every platform.
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyDesktop(options: DesktopNotificationOptions): Promise<boolean> {
|
||||
if (!useSettingsStore.getState().desktopNotificationsEnabled) {
|
||||
return false
|
||||
@ -197,7 +348,11 @@ export async function notifyDesktop(options: DesktopNotificationOptions): Promis
|
||||
|
||||
const sender = overrideNativeNotificationSender ?? sendNativeNotification
|
||||
try {
|
||||
const sent = await Promise.resolve(sender({ title: options.title, body: options.body }))
|
||||
const sent = await Promise.resolve(sender({
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
...(options.target ? { target: options.target } : {}),
|
||||
}))
|
||||
if (options.dedupeKey) {
|
||||
pendingKeys.delete(options.dedupeKey)
|
||||
if (sent) notifiedKeys.add(options.dedupeKey)
|
||||
@ -225,7 +380,9 @@ export function resetDesktopNotificationsForTests(): void {
|
||||
pendingKeys.clear()
|
||||
lastNotificationAtByScope.clear()
|
||||
pendingCooldownScopes.clear()
|
||||
notificationTargetById.clear()
|
||||
overrideNativeNotificationSender = null
|
||||
nextNotificationId = 1
|
||||
}
|
||||
|
||||
export function setNativeNotificationSenderForTests(sender: NativeNotificationSender | null): void {
|
||||
|
||||
@ -699,6 +699,7 @@ describe('chatStore history mapping', () => {
|
||||
requestAttention: true,
|
||||
title: 'Claude Code Haha 需要你的确认',
|
||||
body: 'AskUserQuestion 请求执行,正在等待允许。',
|
||||
target: { type: 'session', sessionId: TEST_SESSION_ID },
|
||||
})
|
||||
})
|
||||
|
||||
@ -1013,6 +1014,7 @@ describe('chatStore history mapping', () => {
|
||||
requestAttention: true,
|
||||
title: 'Claude Code Haha 需要你的确认',
|
||||
body: 'Open Finder and inspect a file',
|
||||
target: { type: 'session', sessionId: TEST_SESSION_ID },
|
||||
})
|
||||
})
|
||||
|
||||
@ -1189,6 +1191,7 @@ describe('chatStore history mapping', () => {
|
||||
cooldownScope: 'agent-completion',
|
||||
title: 'Claude Code Haha 已完成回复',
|
||||
body: '结果 修复完成 bun test 已通过',
|
||||
target: { type: 'session', sessionId: TEST_SESSION_ID },
|
||||
}))
|
||||
expect(notifyDesktopMock.mock.calls[0]?.[0].dedupeKey).toMatch(
|
||||
/^agent-completion:test-session-1:msg-/,
|
||||
|
||||
@ -708,6 +708,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
body: msg.toolName
|
||||
? `${msg.toolName} 请求执行,正在等待允许。`
|
||||
: '有一个工具请求正在等待允许。',
|
||||
target: { type: 'session', sessionId },
|
||||
})
|
||||
update((s) => ({
|
||||
pendingPermission: {
|
||||
@ -743,6 +744,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
requestAttention: true,
|
||||
title: 'Claude Code Haha 需要你的确认',
|
||||
body: msg.request.reason || 'Computer Use 正在等待允许。',
|
||||
target: { type: 'session', sessionId },
|
||||
})
|
||||
update(() => ({
|
||||
pendingComputerUsePermission: {
|
||||
@ -788,6 +790,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
cooldownScope: 'agent-completion',
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
target: { type: 'session', sessionId },
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user