fix(macos): acknowledge TCC notice after close (#11412)

* fix(macos): acknowledge TCC notice on close

* fix(macos): require fresh TCC detection
This commit is contained in:
Brennan Benson 2026-07-29 16:54:27 -07:00 committed by GitHub
parent 4f536ed601
commit 0678fd8a0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 188 additions and 37 deletions

View File

@ -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!)
}

View File

@ -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<TccPromptTally>
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()
}

View File

@ -8,6 +8,11 @@ afterEach(() => {
vi.restoreAllMocks()
})
function acknowledgeDisplayedNotice(onNotice: ReturnType<typeof vi.fn>, 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()
})
})

View File

@ -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<TccPromptNoticeClaim | null> => {
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, () => {})
}
}
)

View File

@ -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()
})

View File

@ -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 })
}