diff --git a/src/hooks/useGlobalEvents.test.tsx b/src/hooks/useGlobalEvents.test.tsx index 83f8c30b..088695b8 100644 --- a/src/hooks/useGlobalEvents.test.tsx +++ b/src/hooks/useGlobalEvents.test.tsx @@ -19,16 +19,19 @@ const { childBelongsToSessionMock, getFocusedSessionIdMock, notificationPushMock, - playNotificationSoundDedupedMock, + playNotificationSoundClaimedMock, getSoundSnapshotMock, isSystemEnabledMock, activeSessionStoreMock, applyServerConnectedTimestampMock, getActiveServerIdMock, autoApproveStoreMock, + claimGlobalSideEffectMock, } = vi.hoisted(() => ({ subscribeToEventsMock: vi.fn(), - getSessionStatusMock: vi.fn<(directory?: string) => Promise>>(() => Promise.resolve({})), + getSessionStatusMock: vi.fn<(directory?: string) => Promise>>(() => + Promise.resolve({}), + ), getPendingPermissionsMock: vi.fn(() => Promise.resolve([] as Array<{ id: string; sessionID: string; permission: string; patterns?: string[] }>), ), @@ -37,7 +40,7 @@ const { childBelongsToSessionMock: vi.fn<(sessionId: string, rootSessionId: string) => boolean>(() => false), getFocusedSessionIdMock: vi.fn<() => string | null>(() => null), notificationPushMock: vi.fn(), - playNotificationSoundDedupedMock: vi.fn(), + playNotificationSoundClaimedMock: vi.fn(), isSystemEnabledMock: vi.fn((type: string) => type !== 'permission'), applyServerConnectedTimestampMock: vi.fn(), getActiveServerIdMock: vi.fn(() => 'local'), @@ -64,6 +67,7 @@ const { claimAutoReply: vi.fn((_requestId: string) => true), releaseAutoReply: vi.fn((_requestId: string) => undefined), }, + claimGlobalSideEffectMock: vi.fn(() => true), })) vi.mock('../api', () => ({ @@ -126,13 +130,17 @@ vi.mock('../store/notificationEventSettingsStore', () => ({ })) vi.mock('../utils/notificationSoundBridge', () => ({ - playNotificationSoundDeduped: playNotificationSoundDedupedMock, + playNotificationSoundClaimed: playNotificationSoundClaimedMock, })) vi.mock('../store/autoApproveStore', () => ({ autoApproveStore: autoApproveStoreMock, })) +vi.mock('../utils/globalEventSideEffects', () => ({ + claimGlobalSideEffect: claimGlobalSideEffectMock, +})) + describe('useGlobalEvents', () => { beforeEach(() => { subscribeToEventsMock.mockReset() @@ -143,7 +151,7 @@ describe('useGlobalEvents', () => { childBelongsToSessionMock.mockReset() getFocusedSessionIdMock.mockReset() notificationPushMock.mockReset() - playNotificationSoundDedupedMock.mockReset() + playNotificationSoundClaimedMock.mockReset() getSoundSnapshotMock.mockReset() isSystemEnabledMock.mockReset() applyServerConnectedTimestampMock.mockReset() @@ -152,6 +160,7 @@ describe('useGlobalEvents', () => { autoApproveStoreMock.approvePendingOnFullAuto = false autoApproveStoreMock.subscribe.mockReset() autoApproveStoreMock.claimAutoReply.mockReset() + claimGlobalSideEffectMock.mockReset() autoApproveStoreMock.releaseAutoReply.mockReset() Object.values(activeSessionStoreMock).forEach(value => { if (typeof value === 'function' && 'mockClear' in value) value.mockClear() @@ -165,6 +174,7 @@ describe('useGlobalEvents', () => { getActiveServerIdMock.mockReturnValue('local') autoApproveStoreMock.subscribe.mockReturnValue(vi.fn()) autoApproveStoreMock.claimAutoReply.mockReturnValue(true) + claimGlobalSideEffectMock.mockReturnValue(true) activeSessionStoreMock.getSessionMeta.mockReturnValue({ title: 'Child Session', directory: '/workspace' }) activeSessionStoreMock.getSnapshot.mockReturnValue({ statusMap: {} }) }) @@ -342,7 +352,7 @@ describe('useGlobalEvents', () => { }) expect(notificationPushMock).not.toHaveBeenCalled() - expect(playNotificationSoundDedupedMock).not.toHaveBeenCalled() + expect(playNotificationSoundClaimedMock).not.toHaveBeenCalled() }) it('keeps later pending question requests for the same session after one reply arrives', async () => { @@ -424,6 +434,54 @@ describe('useGlobalEvents', () => { ) }) expect(autoApproveStoreMock.claimAutoReply).toHaveBeenCalledWith('perm-global') + expect(claimGlobalSideEffectMock).toHaveBeenCalledWith('auto-approve:perm-global', 10000) + }) + + it('does not auto approve a request already claimed by another window', async () => { + autoApproveStoreMock.fullAutoMode = 'global' + autoApproveStoreMock.approvePendingOnFullAuto = true + claimGlobalSideEffectMock.mockReturnValue(false) + getPendingPermissionsMock.mockResolvedValue([ + { + id: 'perm-global', + sessionID: 'background-session', + permission: 'bash', + patterns: ['npm test'], + }, + ]) + + renderHook(() => useGlobalEvents(['/workspace'])) + + await waitFor(() => expect(getPendingPermissionsMock).toHaveBeenCalled()) + + expect(autoApproveStoreMock.claimAutoReply).toHaveBeenCalledWith('perm-global') + expect(autoApproveStoreMock.releaseAutoReply).toHaveBeenCalledWith('perm-global') + expect(replyPermissionMock).not.toHaveBeenCalled() + }) + + it('releases a global full-auto permission event already claimed by another window', async () => { + let callbacks: Parameters[0] | undefined + subscribeToEventsMock.mockImplementation(cb => { + callbacks = cb + return vi.fn() + }) + autoApproveStoreMock.fullAutoMode = 'global' + claimGlobalSideEffectMock.mockReturnValue(false) + + renderHook(() => useGlobalEvents()) + + await waitFor(() => expect(callbacks).toBeDefined()) + + callbacks!.onPermissionAsked?.({ + id: 'perm-global-claimed', + sessionID: 'background-session', + permission: 'bash', + patterns: [], + }) + + expect(autoApproveStoreMock.claimAutoReply).toHaveBeenCalledWith('perm-global-claimed') + expect(autoApproveStoreMock.releaseAutoReply).toHaveBeenCalledWith('perm-global-claimed') + expect(replyPermissionMock).not.toHaveBeenCalled() }) it('does not approve already waiting permissions when the pending sweep is disabled', async () => { @@ -464,7 +522,78 @@ describe('useGlobalEvents', () => { }) expect(notificationPushMock).not.toHaveBeenCalled() - expect(playNotificationSoundDedupedMock).toHaveBeenCalledWith('permission') + expect(playNotificationSoundClaimedMock).toHaveBeenCalledWith('permission', 'sound:permission:perm-2') + }) + + it('delegates current-session sound to the locked sound bridge', async () => { + let callbacks: Parameters[0] | undefined + subscribeToEventsMock.mockImplementation(cb => { + callbacks = cb + return vi.fn() + }) + getFocusedSessionIdMock.mockReturnValue('child-session') + + renderHook(() => useGlobalEvents()) + + await waitFor(() => expect(callbacks).toBeDefined()) + + callbacks!.onPermissionAsked?.({ + id: 'perm-claimed', + sessionID: 'child-session', + permission: 'bash', + patterns: [], + }) + + expect(activeSessionStoreMock.addPendingRequest).toHaveBeenCalledWith( + 'perm-claimed', + 'child-session', + 'permission', + 'bash', + ) + expect(playNotificationSoundClaimedMock).toHaveBeenCalledWith('permission', 'sound:permission:perm-claimed') + }) + + it('uses the same sound claim key for current-session sound and background notification sound', async () => { + let callbacks: Parameters[0] | undefined + subscribeToEventsMock.mockImplementation(cb => { + callbacks = cb + return vi.fn() + }) + getFocusedSessionIdMock.mockReturnValue('child-session') + + renderHook(() => useGlobalEvents()) + + await waitFor(() => expect(callbacks).toBeDefined()) + + callbacks!.onPermissionAsked?.({ + id: 'perm-mixed-window', + sessionID: 'child-session', + permission: 'bash', + patterns: [], + }) + + expect(playNotificationSoundClaimedMock).toHaveBeenCalledWith('permission', 'sound:permission:perm-mixed-window') + + notificationPushMock.mockClear() + playNotificationSoundClaimedMock.mockClear() + getFocusedSessionIdMock.mockReturnValue(null) + + callbacks!.onPermissionAsked?.({ + id: 'perm-mixed-window', + sessionID: 'child-session', + permission: 'bash', + patterns: [], + }) + + expect(notificationPushMock).toHaveBeenCalledWith( + 'permission', + 'Child Session — Permission', + 'bash', + 'child-session', + '/workspace', + { soundKey: 'sound:permission:perm-mixed-window' }, + ) + expect(playNotificationSoundClaimedMock).not.toHaveBeenCalled() }) it('still plays current-session sound when the matching system notification toggle is disabled', async () => { @@ -488,7 +617,7 @@ describe('useGlobalEvents', () => { }) expect(notificationPushMock).not.toHaveBeenCalled() - expect(playNotificationSoundDedupedMock).toHaveBeenCalledWith('permission') + expect(playNotificationSoundClaimedMock).toHaveBeenCalledWith('permission', 'sound:permission:perm-sound') }) it.each([ @@ -537,7 +666,35 @@ describe('useGlobalEvents', () => { callbacks![trigger as keyof typeof callbacks]?.(payload as never) expect(notificationPushMock).toHaveBeenCalledTimes(1) - expect(playNotificationSoundDedupedMock).not.toHaveBeenCalled() + expect(playNotificationSoundClaimedMock).not.toHaveBeenCalled() }, ) + + it('keeps state updates but skips background notification already claimed by another window', async () => { + let callbacks: Parameters[0] | undefined + subscribeToEventsMock.mockImplementation(cb => { + callbacks = cb + return vi.fn() + }) + claimGlobalSideEffectMock.mockReturnValue(false) + + renderHook(() => useGlobalEvents()) + + await waitFor(() => expect(callbacks).toBeDefined()) + + callbacks!.onPermissionAsked?.({ + id: 'perm-background-claimed', + sessionID: 'background-session', + permission: 'bash', + patterns: [], + }) + + expect(activeSessionStoreMock.addPendingRequest).toHaveBeenCalledWith( + 'perm-background-claimed', + 'background-session', + 'permission', + 'bash', + ) + expect(notificationPushMock).not.toHaveBeenCalled() + }) }) diff --git a/src/hooks/useGlobalEvents.ts b/src/hooks/useGlobalEvents.ts index fc8890e7..889d2193 100644 --- a/src/hooks/useGlobalEvents.ts +++ b/src/hooks/useGlobalEvents.ts @@ -13,10 +13,11 @@ import { messageStore, childSessionStore, paneLayoutStore, serverStore } from '. import { activeSessionStore } from '../store/activeSessionStore' import { notificationStore } from '../store/notificationStore' import { soundStore } from '../store/soundStore' -import { playNotificationSoundDeduped } from '../utils/notificationSoundBridge' +import { playNotificationSoundClaimed } from '../utils/notificationSoundBridge' import { subscribeToEvents, getSessionStatus, getPendingPermissions, getPendingQuestions } from '../api' import { replyPermission } from '../api/permission' import { autoApproveStore } from '../store/autoApproveStore' +import { claimGlobalSideEffect } from '../utils/globalEventSideEffects' import type { ApiMessage, ApiPart, ApiPermissionRequest, ApiQuestionRequest } from '../api/types' import type { SessionStatusMap } from '../types/api/session' @@ -120,6 +121,7 @@ const pendingQuestions = new Map[]>() // 5秒后过期,防止内存泄漏 const PENDING_TIMEOUT = 5000 +const AUTO_APPROVE_SIDE_EFFECT_TTL = 10000 function cleanupExpired(map: Map[]>) { const now = Date.now() @@ -260,6 +262,33 @@ function isSessionDirectlyOpen(sessionId: string): boolean { return false } +function runGlobalSideEffect(key: string, sideEffect: () => void) { + if (!claimGlobalSideEffect(key)) return + sideEffect() +} + +function pushGlobalNotification( + key: string, + soundKey: string, + type: Parameters[0], + title: string, + body: string, + sessionId: string, + directory?: string, +) { + runGlobalSideEffect(key, () => { + notificationStore.push(type, title, body, sessionId, directory, { soundKey }) + }) +} + +function claimAutoApproveSideEffect(requestId: string): boolean { + if (!autoApproveStore.claimAutoReply(requestId)) return false + if (claimGlobalSideEffect(`auto-approve:${requestId}`, AUTO_APPROVE_SIDE_EFFECT_TTL)) return true + + autoApproveStore.releaseAutoReply(requestId) + return false +} + export function useGlobalEvents(directories?: string[]) { const directoriesRef = useRef(directories) const refreshRef = useRef<((strategy?: 'replace' | 'merge') => void) | null>(null) @@ -323,7 +352,12 @@ export function useGlobalEvents(directories?: string[]) { ? !currentDirectories || currentDirectories.length === 0 || currentDirectories.includes(pending.directory) : pending.scopeKey === currentScopeKey if (!matchesScope) continue - activeSessionStore.addPendingRequest(pending.requestId, pending.sessionId, pending.type, pending.description) + activeSessionStore.addPendingRequest( + pending.requestId, + pending.sessionId, + pending.type, + pending.description, + ) } activeSessionStore.setSessionMetaBulk(sessionMetaEntries) }) @@ -342,7 +376,8 @@ export function useGlobalEvents(directories?: string[]) { const approveGlobalPendingPermissions = () => { if (!autoApproveStore.approvePendingOnFullAuto || autoApproveStore.fullAutoMode !== 'global') return - const directoriesToFetch = directoriesRef.current && directoriesRef.current.length > 0 ? directoriesRef.current : [undefined] + const directoriesToFetch = + directoriesRef.current && directoriesRef.current.length > 0 ? directoriesRef.current : [undefined] void Promise.all( directoriesToFetch.map(async directory => { @@ -350,7 +385,7 @@ export function useGlobalEvents(directories?: string[]) { await Promise.all( permissions.map(async request => { - if (!autoApproveStore.claimAutoReply(request.id)) return + if (!claimAutoApproveSideEffect(request.id)) return const dir = directory ?? activeSessionStore.getSessionMeta(request.sessionID)?.directory try { @@ -442,9 +477,17 @@ export function useGlobalEvents(directories?: string[]) { if (!belongsToCurrentSession(error.sessionID)) { const meta = activeSessionStore.getSessionMeta(error.sessionID) const sessionLabel = meta?.title || error.sessionID.slice(0, 8) - notificationStore.push('error', sessionLabel, 'Session error', error.sessionID, meta?.directory) + pushGlobalNotification( + `notification:error:${error.sessionID}`, + `sound:error:${error.sessionID}`, + 'error', + sessionLabel, + 'Session error', + error.sessionID, + meta?.directory, + ) } else if (isSessionDirectlyOpen(error.sessionID) && soundStore.getSnapshot().currentSessionEnabled) { - playNotificationSoundDeduped('error') + playNotificationSoundClaimed('error', `sound:error:${error.sessionID}`) } } dispatchToConsumers(error.sessionID, cb => cb.onSessionError?.(error.sessionID)) @@ -477,7 +520,7 @@ export function useGlobalEvents(directories?: string[]) { // Full Auto 全局模式拦截 — 所有会话的权限请求直接放行 if (autoApproveStore.fullAutoMode === 'global') { const dir = activeSessionStore.getSessionMeta(request.sessionID)?.directory - if (autoApproveStore.claimAutoReply(request.id)) { + if (claimAutoApproveSideEffect(request.id)) { replyPermission(request.id, 'once', undefined, dir, request.sessionID).catch(() => { autoApproveStore.releaseAutoReply(request.id) }) @@ -504,10 +547,18 @@ export function useGlobalEvents(directories?: string[]) { // Toast 通知 — 不属于当前 session family 的才弹 if (!belongsToCurrentSession(request.sessionID)) { - notificationStore.push('permission', `${sessionLabel} — Permission`, desc, request.sessionID, meta?.directory) + pushGlobalNotification( + `notification:permission:${request.id}`, + `sound:permission:${request.id}`, + 'permission', + `${sessionLabel} — Permission`, + desc, + request.sessionID, + meta?.directory, + ) } else if (isSessionDirectlyOpen(request.sessionID) && soundStore.getSnapshot().currentSessionEnabled) { // 当前会话:如果开启了当前会话提示音 - playNotificationSoundDeduped('permission') + playNotificationSoundClaimed('permission', `sound:permission:${request.id}`) } if (belongsToCurrentSession(request.sessionID)) { @@ -551,9 +602,17 @@ export function useGlobalEvents(directories?: string[]) { // Toast 通知 if (!belongsToCurrentSession(request.sessionID)) { - notificationStore.push('question', `${sessionLabel} — Question`, desc, request.sessionID, meta?.directory) + pushGlobalNotification( + `notification:question:${request.id}`, + `sound:question:${request.id}`, + 'question', + `${sessionLabel} — Question`, + desc, + request.sessionID, + meta?.directory, + ) } else if (isSessionDirectlyOpen(request.sessionID) && soundStore.getSnapshot().currentSessionEnabled) { - playNotificationSoundDeduped('question') + playNotificationSoundClaimed('question', `sound:question:${request.id}`) } if (belongsToCurrentSession(request.sessionID)) { @@ -597,14 +656,22 @@ export function useGlobalEvents(directories?: string[]) { if (wasBusy && data.status.type === 'idle' && !belongsToCurrentSession(data.sessionID)) { const meta = activeSessionStore.getSessionMeta(data.sessionID) const sessionLabel = meta?.title || data.sessionID.slice(0, 8) - notificationStore.push('completed', sessionLabel, 'Session completed', data.sessionID, meta?.directory) + pushGlobalNotification( + `notification:completed:${data.sessionID}`, + `sound:completed:${data.sessionID}`, + 'completed', + sessionLabel, + 'Session completed', + data.sessionID, + meta?.directory, + ) } else if ( wasBusy && data.status.type === 'idle' && isSessionDirectlyOpen(data.sessionID) && soundStore.getSnapshot().currentSessionEnabled ) { - playNotificationSoundDeduped('completed') + playNotificationSoundClaimed('completed', `sound:completed:${data.sessionID}`) } }, diff --git a/src/store/notificationStore.ts b/src/store/notificationStore.ts index 1bc456f6..50b46b06 100644 --- a/src/store/notificationStore.ts +++ b/src/store/notificationStore.ts @@ -17,7 +17,11 @@ import { useSyncExternalStore } from 'react' export type NotificationType = 'permission' | 'question' | 'completed' | 'error' /** push 后的回调,用于声音播放等扩展 */ -export type NotificationPushListener = (type: NotificationType) => void +export interface NotificationPushOptions { + soundKey?: string +} + +export type NotificationPushListener = (type: NotificationType, options?: NotificationPushOptions) => void export interface NotificationEntry { id: string @@ -107,7 +111,9 @@ class NotificationStore { } private notify() { - this.subscribers.forEach(cb => cb()) + this.subscribers.forEach(cb => { + cb() + }) } private persist() { @@ -137,7 +143,14 @@ class NotificationStore { // 推送通知(加历史 + 弹 toast) // ============================================ - push(type: NotificationType, title: string, body: string, sessionId: string, directory?: string) { + push( + type: NotificationType, + title: string, + body: string, + sessionId: string, + directory?: string, + options?: NotificationPushOptions, + ) { const entry: NotificationEntry = { id: `notif_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, type, @@ -173,7 +186,7 @@ class NotificationStore { // 触发 push 后回调(声音播放等) this.pushListeners.forEach(fn => { try { - fn(type) + fn(type, options) } catch { // 回调异常不影响通知流程 } @@ -271,7 +284,9 @@ class NotificationStore { } dismissAllToasts() { - this.toastTimers.forEach(timer => clearTimeout(timer)) + this.toastTimers.forEach(timer => { + clearTimeout(timer) + }) this.toastTimers.clear() this.state = { ...this.state, toasts: [] } this.notify() diff --git a/src/utils/globalEventSideEffects.test.ts b/src/utils/globalEventSideEffects.test.ts new file mode 100644 index 00000000..34279182 --- /dev/null +++ b/src/utils/globalEventSideEffects.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimGlobalSideEffect, claimGlobalSideEffectLocked } from './globalEventSideEffects' + +describe('claimGlobalSideEffect', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + localStorage.clear() + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() + localStorage.clear() + }) + + it('claims an unclaimed side effect and blocks duplicates during the ttl', () => { + expect(claimGlobalSideEffect('sound:permission:request-1', 1000)).toBe(true) + expect(claimGlobalSideEffect('sound:permission:request-1', 1000)).toBe(false) + }) + + it('allows a side effect again after the ttl expires', () => { + expect(claimGlobalSideEffect('notification:completed:session-1', 1000)).toBe(true) + + vi.setSystemTime(new Date('2026-01-01T00:00:01.001Z')) + + expect(claimGlobalSideEffect('notification:completed:session-1', 1000)).toBe(true) + }) + + it('removes the stored claim after the ttl when this instance still owns it', () => { + expect(claimGlobalSideEffect('notification:error:session-1', 1000)).toBe(true) + expect(localStorage.getItem('opencode:global-side-effect:notification:error:session-1')).not.toBeNull() + + vi.advanceTimersByTime(1000) + + expect(localStorage.getItem('opencode:global-side-effect:notification:error:session-1')).toBeNull() + }) + + it('does not let delayed cleanup remove a newer claim for the same key', () => { + expect(claimGlobalSideEffect('sound:completed:session-1', 1000)).toBe(true) + + vi.setSystemTime(new Date('2026-01-01T00:00:01.001Z')) + expect(claimGlobalSideEffect('sound:completed:session-1', 5000)).toBe(true) + + vi.advanceTimersByTime(1000) + + expect(localStorage.getItem('opencode:global-side-effect:sound:completed:session-1')).not.toBeNull() + }) + + it('treats invalid stored data as unclaimed', () => { + localStorage.setItem('opencode:global-side-effect:sound:question:request-1', '{not-json') + + expect(claimGlobalSideEffect('sound:question:request-1', 1000)).toBe(true) + }) + + it('preserves the side effect when localStorage is unavailable', () => { + vi.stubGlobal('localStorage', { + getItem: () => { + throw new Error('storage unavailable') + }, + setItem: () => { + throw new Error('storage unavailable') + }, + removeItem: () => { + throw new Error('storage unavailable') + }, + clear: () => undefined, + }) + + expect(claimGlobalSideEffect('sound:error:session-1', 1000)).toBe(true) + }) + + it('serializes sound claims through Web Locks when available', async () => { + const requestMock = vi.fn((_name, _options, callback) => Promise.resolve(callback(null))) + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request: requestMock }, + }) + + const first = await claimGlobalSideEffectLocked('sound:permission:request-1', 1000) + const second = await claimGlobalSideEffectLocked('sound:permission:request-1', 1000) + + expect(first).toBe(true) + expect(second).toBe(false) + expect(requestMock).toHaveBeenCalledTimes(2) + expect(requestMock).toHaveBeenCalledWith( + 'opencode:global-side-effect-lock:sound:permission:request-1', + { mode: 'exclusive' }, + expect.any(Function), + ) + }) + + it('falls back to localStorage claims when Web Locks are unavailable', async () => { + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: undefined, + }) + + const first = claimGlobalSideEffectLocked('sound:question:request-1', 1000) + vi.advanceTimersByTime(25) + + await expect(first).resolves.toBe(true) + await expect(claimGlobalSideEffectLocked('sound:question:request-1', 1000)).resolves.toBe(false) + }) + + it('does not claim locked fallback sound when another window overwrites the token before confirmation', async () => { + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: undefined, + }) + + const claim = claimGlobalSideEffectLocked('sound:completed:session-1', 1000) + localStorage.setItem( + 'opencode:global-side-effect:sound:completed:session-1', + JSON.stringify({ token: 'other-window', expiresAt: Date.now() + 1000 }), + ) + vi.advanceTimersByTime(25) + + await expect(claim).resolves.toBe(false) + }) +}) diff --git a/src/utils/globalEventSideEffects.ts b/src/utils/globalEventSideEffects.ts new file mode 100644 index 00000000..b066cffe --- /dev/null +++ b/src/utils/globalEventSideEffects.ts @@ -0,0 +1,127 @@ +// ============================================ +// Global Event Side Effects +// ============================================ +// +// Multiple app windows subscribe to the same global SSE stream. State updates +// must still happen in every window, but one-shot side effects such as audio, +// toasts, and automatic permission replies should only run once per event. + +interface SideEffectClaim { + token: string + expiresAt: number +} + +const STORAGE_PREFIX = 'opencode:global-side-effect:' +const LOCK_PREFIX = 'opencode:global-side-effect-lock:' +const DEFAULT_TTL_MS = 3000 +const FALLBACK_CONFIRM_DELAY_MS = 25 + +function createClaimToken(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +function parseClaim(raw: string | null): SideEffectClaim | null { + if (!raw) return null + + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return null + + const record = parsed as Record + if (typeof record.token !== 'string') return null + if (typeof record.expiresAt !== 'number') return null + return { token: record.token, expiresAt: record.expiresAt } + } catch { + return null + } +} + +function removeStoredClaim(storageKey: string, token: string) { + try { + const stored = parseClaim(localStorage.getItem(storageKey)) + if (stored?.token === token) { + localStorage.removeItem(storageKey) + } + } catch { + // Ignore storage cleanup failures. + } +} + +function scheduleClaimCleanup(storageKey: string, token: string, ttlMs: number) { + setTimeout(() => removeStoredClaim(storageKey, token), ttlMs) +} + +function waitForFallbackConfirmation(): Promise { + return new Promise(resolve => { + setTimeout(resolve, FALLBACK_CONFIRM_DELAY_MS) + }) +} + +export function claimGlobalSideEffect(key: string, ttlMs: number = DEFAULT_TTL_MS): boolean { + const storageKey = `${STORAGE_PREFIX}${key}` + const now = Date.now() + + try { + const existing = parseClaim(localStorage.getItem(storageKey)) + if (existing && existing.expiresAt > now) return false + + const token = createClaimToken() + const claim: SideEffectClaim = { + token, + expiresAt: now + ttlMs, + } + + localStorage.setItem(storageKey, JSON.stringify(claim)) + const stored = parseClaim(localStorage.getItem(storageKey)) + const claimed = stored?.token === token + if (claimed) { + scheduleClaimCleanup(storageKey, claim.token, ttlMs) + } + return claimed + } catch { + // If storage is unavailable, prefer preserving the side effect over + // dropping notifications/sounds entirely. + return true + } +} + +async function claimGlobalSideEffectAfterConfirmation(key: string, ttlMs: number): Promise { + const storageKey = `${STORAGE_PREFIX}${key}` + const now = Date.now() + + try { + const existing = parseClaim(localStorage.getItem(storageKey)) + if (existing && existing.expiresAt > now) return false + + const token = createClaimToken() + const claim: SideEffectClaim = { + token, + expiresAt: now + ttlMs, + } + + localStorage.setItem(storageKey, JSON.stringify(claim)) + await waitForFallbackConfirmation() + + const stored = parseClaim(localStorage.getItem(storageKey)) + const claimed = stored?.token === token && stored.expiresAt > Date.now() + if (claimed) { + scheduleClaimCleanup(storageKey, claim.token, ttlMs) + } + return claimed + } catch { + // If storage is unavailable, prefer preserving the side effect over + // dropping notifications/sounds entirely. + return true + } +} + +export async function claimGlobalSideEffectLocked(key: string, ttlMs: number = DEFAULT_TTL_MS): Promise { + const lockManager = typeof navigator === 'undefined' ? undefined : navigator.locks + if (!lockManager) return claimGlobalSideEffectAfterConfirmation(key, ttlMs) + + try { + return await lockManager.request(`${LOCK_PREFIX}${key}`, { mode: 'exclusive' }, () => claimGlobalSideEffect(key, ttlMs)) + } catch { + return claimGlobalSideEffectAfterConfirmation(key, ttlMs) + } +} diff --git a/src/utils/notificationSoundBridge.test.ts b/src/utils/notificationSoundBridge.test.ts new file mode 100644 index 00000000..c7ebe52d --- /dev/null +++ b/src/utils/notificationSoundBridge.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { NotificationPushListener } from '../store/notificationStore' + +const { claimGlobalSideEffectLockedMock, getSoundSnapshotMock, getCustomAudioBlobMock, playSoundMock, onPushMock } = + vi.hoisted(() => ({ + claimGlobalSideEffectLockedMock: vi.fn(() => Promise.resolve(true)), + getSoundSnapshotMock: vi.fn(() => ({ + enabled: true, + volume: 0.5, + events: { + permission: { soundId: 'ding' }, + question: { soundId: 'ding' }, + completed: { soundId: 'ding' }, + error: { soundId: 'ding' }, + }, + })), + getCustomAudioBlobMock: vi.fn(() => null), + playSoundMock: vi.fn(), + onPushMock: vi.fn(), + })) + +vi.mock('./globalEventSideEffects', () => ({ + claimGlobalSideEffectLocked: claimGlobalSideEffectLockedMock, +})) + +vi.mock('../store/notificationStore', () => ({ + notificationStore: { + onPush: onPushMock, + }, +})) + +vi.mock('../store/soundStore', () => ({ + soundStore: { + getSnapshot: () => getSoundSnapshotMock(), + getCustomAudioBlob: getCustomAudioBlobMock, + }, +})) + +vi.mock('./soundPlayer', () => ({ + playSound: playSoundMock, +})) + +describe('notificationSoundBridge', () => { + beforeEach(() => { + claimGlobalSideEffectLockedMock.mockReset() + getSoundSnapshotMock.mockClear() + getCustomAudioBlobMock.mockClear() + playSoundMock.mockReset() + onPushMock.mockReset() + claimGlobalSideEffectLockedMock.mockResolvedValue(true) + getSoundSnapshotMock.mockReturnValue({ + enabled: true, + volume: 0.5, + events: { + permission: { soundId: 'ding' }, + question: { soundId: 'ding' }, + completed: { soundId: 'ding' }, + error: { soundId: 'ding' }, + }, + }) + }) + + it('plays notification sound when the push sound key is claimed', async () => { + const { initNotificationSound } = await import('./notificationSoundBridge') + let listener: NotificationPushListener | undefined + onPushMock.mockImplementation(cb => { + listener = cb + return vi.fn() + }) + + initNotificationSound() + + listener?.('permission', { soundKey: 'sound:permission:perm-1' }) + + await vi.waitFor(() => expect(playSoundMock).toHaveBeenCalled()) + expect(claimGlobalSideEffectLockedMock).toHaveBeenCalledWith('sound:permission:perm-1') + expect(playSoundMock).toHaveBeenCalledWith({ + soundId: 'ding', + customAudioData: null, + volume: 0.5, + }) + }) + + it('skips notification sound when another window already claimed the same sound key', async () => { + const { initNotificationSound } = await import('./notificationSoundBridge') + let listener: NotificationPushListener | undefined + onPushMock.mockImplementation(cb => { + listener = cb + return vi.fn() + }) + claimGlobalSideEffectLockedMock.mockResolvedValue(false) + + initNotificationSound() + + listener?.('permission', { soundKey: 'sound:permission:perm-1' }) + + await vi.waitFor(() => expect(claimGlobalSideEffectLockedMock).toHaveBeenCalledWith('sound:permission:perm-1')) + expect(playSoundMock).not.toHaveBeenCalled() + }) + + it('uses the locked claim for current-session sounds', async () => { + const { playNotificationSoundClaimed } = await import('./notificationSoundBridge') + + playNotificationSoundClaimed('completed', 'sound:completed:session-1') + + await vi.waitFor(() => expect(playSoundMock).toHaveBeenCalledTimes(1)) + expect(claimGlobalSideEffectLockedMock).toHaveBeenCalledWith('sound:completed:session-1') + }) +}) diff --git a/src/utils/notificationSoundBridge.ts b/src/utils/notificationSoundBridge.ts index 282fa275..a95b74cc 100644 --- a/src/utils/notificationSoundBridge.ts +++ b/src/utils/notificationSoundBridge.ts @@ -14,6 +14,7 @@ import type { NotificationType } from '../store/notificationStore' import { notificationStore } from '../store/notificationStore' import { soundStore } from '../store/soundStore' +import { claimGlobalSideEffectLocked } from './globalEventSideEffects' import { playSound } from './soundPlayer' /** @@ -57,16 +58,26 @@ export function playNotificationSoundDeduped(type: NotificationType): void { playNotificationSound(type) } +export function playNotificationSoundClaimed(type: NotificationType, soundKey: string): void { + void claimGlobalSideEffectLocked(soundKey).then(claimed => { + if (!claimed) return + playNotificationSoundDeduped(type) + }) +} + /** * 初始化通知声音系统 * 在 App 层调用一次即可,注册 notificationStore.push 的声音回调 */ export function initNotificationSound(): () => void { - const unsubscribe = notificationStore.onPush((type: NotificationType) => { + const unsubscribe = notificationStore.onPush((type: NotificationType, options) => { // notificationStore.push 只在后台会话触发(非当前 session family) - // 记录播放时间用于去重 - recentPlays.set(type, Date.now()) - playNotificationSound(type) + if (options?.soundKey) { + playNotificationSoundClaimed(type, options.soundKey) + return + } + + playNotificationSoundDeduped(type) }) return unsubscribe