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:
parent
4f536ed601
commit
0678fd8a0d
|
|
@ -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!)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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, () => {})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue