From 0678fd8a0df26794eb8aafb3c04531070388cbcf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:54:27 -0700 Subject: [PATCH] fix(macos): acknowledge TCC notice after close (#11412) * fix(macos): acknowledge TCC notice on close * fix(macos): require fresh TCC detection --- src/main/macos-tcc-prompt-notice.test.ts | 73 +++++++++++++++++-- src/main/macos-tcc-prompt-notice.ts | 23 ++++-- ...cos-tcc-prompt-notice-subscription.test.ts | 37 +++++++--- .../macos-tcc-prompt-notice-subscription.ts | 24 +++--- .../hooks/useMacosTccPromptNotice.test.tsx | 57 ++++++++++++++- .../src/hooks/useMacosTccPromptNotice.ts | 11 +-- 6 files changed, 188 insertions(+), 37 deletions(-) diff --git a/src/main/macos-tcc-prompt-notice.test.ts b/src/main/macos-tcc-prompt-notice.test.ts index 764ca1539..1c268a7e2 100644 --- a/src/main/macos-tcc-prompt-notice.test.ts +++ b/src/main/macos-tcc-prompt-notice.test.ts @@ -106,7 +106,8 @@ describe('tcc prompt notice threshold', () => { const [, contents] = writeFileAtomically.mock.calls.at(-1) as [string, string] expect(JSON.parse(contents)).toMatchObject({ promptCount: TCC_PROMPT_NOTICE_THRESHOLD, - notified: true + notified: true, + acknowledgedAfterClose: true }) }) @@ -142,7 +143,11 @@ describe('tcc prompt notice threshold', () => { expect(consumePendingTccPromptNotice(2)).toBeNull() const [, contents] = writeFileAtomically.mock.calls.at(-1) as [string, string] - expect(JSON.parse(contents)).toMatchObject({ dismissed: true, notified: true }) + expect(JSON.parse(contents)).toMatchObject({ + dismissed: true, + notified: true, + acknowledgedAfterClose: true + }) }) it('routes a later prompt to the replacement main window', () => { @@ -297,11 +302,43 @@ describe('tcc prompt notice threshold', () => { } }) - it('delivers a tally persisted below the old threshold without respawning the watcher', () => { + it.each([ + { promptCount: 2, notified: false }, + { promptCount: 1, notified: true } + ])('requires a fresh detection for a legacy tally: $promptCount/$notified', (persisted) => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readTallyFile.mockReturnValue(JSON.stringify({ ...persisted, dismissed: false })) + try { + const mainWindow = createWindowStub() + initTccPromptNotice(mainWindow as never) + + expect(watchStart).toHaveBeenCalledOnce() + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(consumePendingTccPromptNotice(1)).toBeNull() + + watchOptions[0].onPrompt() + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('macosTccPrompts:threshold', { + promptCount: 1 + }) + expect(watchStop).toHaveBeenCalledOnce() + expect(consumePendingTccPromptNotice(1)).toEqual({ claimId: 1, promptCount: 1 }) + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + + it('replays an unclosed notice from the new delivery contract', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue( - JSON.stringify({ promptCount: 2, notified: false, dismissed: false }) + JSON.stringify({ + promptCount: 1, + notified: false, + dismissed: false, + acknowledgedAfterClose: false + }) ) try { const mainWindow = createWindowStub() @@ -309,10 +346,32 @@ describe('tcc prompt notice threshold', () => { expect(watchStart).not.toHaveBeenCalled() expect(mainWindow.webContents.send).toHaveBeenCalledWith('macosTccPrompts:threshold', { - promptCount: 2 + promptCount: 1 }) - expect(mainWindow.once).not.toHaveBeenCalled() - expect(consumePendingTccPromptNotice(1)).toEqual({ claimId: 1, promptCount: 2 }) + expect(consumePendingTccPromptNotice(1)).toEqual({ claimId: 1, promptCount: 1 }) + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + + it('does not replay a notice acknowledged after close', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readTallyFile.mockReturnValue( + JSON.stringify({ + promptCount: 1, + notified: true, + dismissed: false, + acknowledgedAfterClose: true + }) + ) + try { + const mainWindow = createWindowStub() + initTccPromptNotice(mainWindow as never) + + expect(watchStart).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(consumePendingTccPromptNotice(1)).toBeNull() } finally { Object.defineProperty(process, 'platform', platform!) } diff --git a/src/main/macos-tcc-prompt-notice.ts b/src/main/macos-tcc-prompt-notice.ts index d76b04704..9d7dea359 100644 --- a/src/main/macos-tcc-prompt-notice.ts +++ b/src/main/macos-tcc-prompt-notice.ts @@ -31,9 +31,15 @@ type TccPromptTally = { promptCount: number notified: boolean dismissed: boolean + acknowledgedAfterClose: boolean } -const EMPTY_TALLY: TccPromptTally = { promptCount: 0, notified: false, dismissed: false } +const EMPTY_TALLY: TccPromptTally = { + promptCount: 0, + notified: false, + dismissed: false, + acknowledgedAfterClose: false +} let tally: TccPromptTally = { ...EMPTY_TALLY } let mainWindowRef: BrowserWindow | null = null @@ -50,10 +56,17 @@ function tallyPath(): string { function loadTally(): TccPromptTally { try { const parsed = JSON.parse(readFileSync(tallyPath(), 'utf-8')) as Partial + const dismissed = parsed.dismissed === true + if (!dismissed && typeof parsed.acknowledgedAfterClose !== 'boolean') { + // Why: a legacy tally only proves a past prompt, not that Full Disk Access is still missing. + return { ...EMPTY_TALLY } + } + const acknowledgedAfterClose = parsed.acknowledgedAfterClose === true return { promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0, - notified: parsed.notified === true, - dismissed: parsed.dismissed === true + notified: parsed.notified === true && (dismissed || acknowledgedAfterClose), + dismissed, + acknowledgedAfterClose } } catch { return { ...EMPTY_TALLY } @@ -108,7 +121,7 @@ export function acknowledgePendingTccPromptNotice(ownerToken: number, claimId: n return } pendingClaim = null - tally = { ...tally, notified: true } + tally = { ...tally, notified: true, acknowledgedAfterClose: true } saveTally() } @@ -124,7 +137,7 @@ export function releasePendingTccPromptNotice(ownerToken: number, claimId?: numb /** Permanently stops the notice for this user; the watcher shuts down with it. */ export function dismissTccPromptNotice(): void { pendingClaim = null - tally = { ...tally, dismissed: true, notified: true } + tally = { ...tally, dismissed: true, notified: true, acknowledgedAfterClose: true } saveTally() stopTccPromptNotice() } diff --git a/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts b/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts index 9a22d982e..34369c176 100644 --- a/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts +++ b/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts @@ -8,6 +8,11 @@ afterEach(() => { vi.restoreAllMocks() }) +function acknowledgeDisplayedNotice(onNotice: ReturnType, callIndex = 0): void { + const acknowledge = onNotice.mock.calls[callIndex]?.[1] as (() => void) | undefined + acknowledge?.() +} + describe('subscribeToMacosTccPromptNotice', () => { it('contains synchronous and rejected dismissal failures', async () => { const synchronousFailure = vi.fn(() => { @@ -35,11 +40,15 @@ describe('subscribeToMacosTccPromptNotice', () => { await Promise.resolve() - expect(onNotice).toHaveBeenCalledWith({ promptCount: 3 }) + expect(onNotice).toHaveBeenCalledWith({ promptCount: 3 }, expect.any(Function)) + expect(acknowledgePending).not.toHaveBeenCalled() + acknowledgeDisplayedNotice(onNotice) expect(acknowledgePending).toHaveBeenCalledWith(7) expect(onNotice.mock.invocationCallOrder[0]).toBeLessThan( acknowledgePending.mock.invocationCallOrder[0] ) + acknowledgeDisplayedNotice(onNotice) + expect(acknowledgePending).toHaveBeenCalledOnce() unsubscribe() }) @@ -68,6 +77,8 @@ describe('subscribeToMacosTccPromptNotice', () => { expect(consumePending).toHaveBeenCalledTimes(2) expect(onNotice).toHaveBeenCalledOnce() + expect(acknowledgePending).not.toHaveBeenCalled() + acknowledgeDisplayedNotice(onNotice) expect(acknowledgePending).toHaveBeenCalledOnce() unsubscribe() }) @@ -94,6 +105,8 @@ describe('subscribeToMacosTccPromptNotice', () => { await Promise.resolve() expect(onNotice).toHaveBeenCalledOnce() + expect(acknowledgePending).not.toHaveBeenCalled() + acknowledgeDisplayedNotice(onNotice) expect(acknowledgePending).toHaveBeenCalledWith(9) }) @@ -106,9 +119,12 @@ describe('subscribeToMacosTccPromptNotice', () => { .mockResolvedValueOnce({ claimId: 11, promptCount: 3 }) const releasePending = vi.fn().mockResolvedValue(undefined) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - const onNotice = vi.fn().mockImplementationOnce(() => { - throw error - }) + const onNotice = vi + .fn() + .mockImplementationOnce(() => { + throw error + }) + .mockImplementationOnce((_, acknowledge: () => void) => acknowledge()) subscribeToMacosTccPromptNotice( { @@ -180,7 +196,7 @@ describe('subscribeToMacosTccPromptNotice', () => { consumePending: vi.fn().mockResolvedValue({ claimId: 11, promptCount: 3 }), releasePending: failedRelease }, - vi.fn() + (_, acknowledge) => acknowledge() ) await Promise.resolve() await Promise.resolve() @@ -192,7 +208,7 @@ describe('subscribeToMacosTccPromptNotice', () => { consumePending: vi.fn().mockResolvedValue({ claimId: 12, promptCount: 3 }), releasePending: unavailableRelease }, - vi.fn() + (_, acknowledge) => acknowledge() ) await Promise.resolve() expect(unavailableRelease).toHaveBeenCalledWith(12) @@ -222,7 +238,7 @@ describe('subscribeToMacosTccPromptNotice', () => { await Promise.resolve() expect(consumePending).toHaveBeenCalledTimes(2) - expect(onNotice).toHaveBeenCalledWith({ promptCount: 3 }) + expect(onNotice).toHaveBeenCalledWith({ promptCount: 3 }, expect.any(Function)) }) it('releases the claim when acknowledgement throws synchronously', async () => { @@ -237,7 +253,10 @@ describe('subscribeToMacosTccPromptNotice', () => { consumePending: vi.fn().mockResolvedValue({ claimId: 13, promptCount: 3 }), releasePending }, - onNotice + (payload, acknowledge) => { + onNotice(payload) + acknowledge() + } ) await new Promise((resolve) => { setImmediate(resolve) @@ -262,7 +281,7 @@ describe('subscribeToMacosTccPromptNotice', () => { listenerState.listener?.({ promptCount: 3 }) - expect(onNotice).toHaveBeenCalledWith({ promptCount: 3 }) + expect(onNotice).toHaveBeenCalledWith({ promptCount: 3 }, expect.any(Function)) unsubscribe() }) }) diff --git a/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.ts b/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.ts index a738a4865..c11b56055 100644 --- a/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.ts +++ b/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.ts @@ -22,7 +22,7 @@ export async function dismissMacosTccPromptNotice( export function subscribeToMacosTccPromptNotice( api: MacosTccPromptNoticeApi | undefined, - onNotice: (payload: TccPromptNoticePayload) => void + onNotice: (payload: TccPromptNoticePayload, acknowledge: () => void) => void ): () => void { const pullPending = (): Promise => { if (!api?.consumePending) { @@ -58,9 +58,9 @@ export function subscribeToMacosTccPromptNotice( void releaseClaim(claimId) } } - const showNotice = (payload: TccPromptNoticePayload): boolean => { + const showNotice = (payload: TccPromptNoticePayload, acknowledge: () => void): boolean => { try { - onNotice(payload) + onNotice(payload, acknowledge) return true } catch (error) { console.error('[macos-tcc-prompts] Failed to show notice:', error) @@ -72,7 +72,7 @@ export function subscribeToMacosTccPromptNotice( const consume = (fallback?: TccPromptNoticePayload): void => { if (!api?.consumePending) { if (fallback) { - showNotice(fallback) + showNotice(fallback, () => {}) } return } @@ -80,7 +80,15 @@ export function subscribeToMacosTccPromptNotice( (pending) => { if (pending) { const claimId = pending.claimId - if (!showNotice({ promptCount: pending.promptCount })) { + let acknowledged = false + const acknowledge = (): void => { + if (acknowledged || typeof claimId !== 'number') { + return + } + acknowledged = true + acknowledgeClaim(claimId) + } + if (!showNotice({ promptCount: pending.promptCount }, acknowledge)) { if (typeof claimId === 'number') { const shouldRetry = displayRetryAvailable displayRetryAvailable = false @@ -90,16 +98,12 @@ export function subscribeToMacosTccPromptNotice( } }) } - return - } - if (typeof claimId === 'number') { - acknowledgeClaim(claimId) } } }, () => { if (fallback) { - showNotice(fallback) + showNotice(fallback, () => {}) } } ) diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx index a31e97d2b..14ae07e6b 100644 --- a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx @@ -12,12 +12,18 @@ import { i18n } from '@/i18n/i18n' import { MacosTccPromptNoticeHost } from './MacosTccPromptNoticeHost' import { useMacosTccPromptNotice } from './useMacosTccPromptNotice' -const subscribeToMacosTccPromptNotice = vi.hoisted(() => vi.fn(() => vi.fn())) +type NoticeCallback = (payload: { promptCount: number }, acknowledge: () => void) => void + +const subscribeToMacosTccPromptNotice = vi.hoisted(() => + vi.fn<(_: unknown, onNotice: NoticeCallback) => () => void>(() => vi.fn()) +) +const toastWarning = vi.hoisted(() => vi.fn()) vi.mock('./macos-tcc-prompt-notice-subscription', () => ({ dismissMacosTccPromptNotice: vi.fn(), subscribeToMacosTccPromptNotice })) +vi.mock('sonner', () => ({ toast: { warning: toastWarning } })) const initialAppState = useAppStore.getInitialState() const initialPluginLanguagePackState = usePluginLanguagePackStore.getInitialState() @@ -32,6 +38,7 @@ beforeEach(async () => { useAppStore.setState(initialAppState, true) usePluginLanguagePackStore.setState(initialPluginLanguagePackState, true) subscribeToMacosTccPromptNotice.mockClear() + toastWarning.mockClear() await i18n.changeLanguage('en') }) @@ -87,3 +94,51 @@ it('isolates plugin language-pack discovery from its parent render path', async expect(parentRenderCount).toBe(1) expect(subscribeToMacosTccPromptNotice).toHaveBeenCalledOnce() }) + +it('keeps the notice open until the user closes it', async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'en' } + }) + const container = document.createElement('div') + root = createRoot(container) + await act(async () => { + root?.render(createElement(I18nextProvider, { i18n }, createElement(NoticeProbe))) + }) + const showNotice = subscribeToMacosTccPromptNotice.mock.calls[0]?.[1] as + | NoticeCallback + | undefined + const acknowledge = vi.fn() + + showNotice?.({ promptCount: 1 }, acknowledge) + + const options = toastWarning.mock.calls[0]?.[1] as + | { duration?: number; onDismiss?: () => void } + | undefined + expect(options?.duration).toBe(Infinity) + expect(acknowledge).not.toHaveBeenCalled() + options?.onDismiss?.() + expect(acknowledge).toHaveBeenCalledOnce() +}) + +it('acknowledges when opening Settings closes the notice', async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'en' } + }) + const container = document.createElement('div') + root = createRoot(container) + await act(async () => { + root?.render(createElement(I18nextProvider, { i18n }, createElement(NoticeProbe))) + }) + const showNotice = subscribeToMacosTccPromptNotice.mock.calls[0]?.[1] as + | NoticeCallback + | undefined + const acknowledge = vi.fn() + + showNotice?.({ promptCount: 1 }, acknowledge) + + const options = toastWarning.mock.calls[0]?.[1] as + | { action?: { onClick: () => void } } + | undefined + options?.action?.onClick() + expect(acknowledge).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.ts b/src/renderer/src/hooks/useMacosTccPromptNotice.ts index d94d70de5..5da212cab 100644 --- a/src/renderer/src/hooks/useMacosTccPromptNotice.ts +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.ts @@ -12,9 +12,8 @@ import { } from './macos-tcc-prompt-notice-subscription' /** - * Shows the Full Disk Access hint only after macOS has repeatedly raised its - * consent dialog naming Orca (#9756). The main process counts the dialogs, so - * users who never see one never see this. + * Shows the Full Disk Access hint after macOS raises a consent dialog naming + * Orca (#9756). Users who never see one never see this. */ export function useMacosTccPromptNotice(): void { const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -38,7 +37,7 @@ export function useMacosTccPromptNotice(): void { if (!localeReady) { return } - return subscribeToMacosTccPromptNotice(window.api?.macosTccPrompts, () => { + return subscribeToMacosTccPromptNotice(window.api?.macosTccPrompts, (_, acknowledge) => { toast.warning( translate( 'auto.hooks.useMacosTccPromptNotice.title', @@ -49,10 +48,12 @@ export function useMacosTccPromptNotice(): void { 'auto.hooks.useMacosTccPromptNotice.description', 'macOS attributes file access by your agents and terminal tools to Orca. Granting Full Disk Access reduces these prompts.' ), - duration: 12_000, + duration: Infinity, + onDismiss: acknowledge, action: { label: translate('auto.hooks.useMacosTccPromptNotice.openSettings', 'Open Settings'), onClick: () => { + acknowledge() openSettingsPage() openSettingsTarget({ pane: 'developer-permissions', repoId: null }) }