diff --git a/src/main/ipc/developer-permissions.test.ts b/src/main/ipc/developer-permissions.test.ts index ccb119d0e..78c8d6e0b 100644 --- a/src/main/ipc/developer-permissions.test.ts +++ b/src/main/ipc/developer-permissions.test.ts @@ -6,6 +6,7 @@ const { askForMediaAccessMock, getMediaAccessStatusMock, isTrustedAccessibilityClientMock, + getMacosFullDiskAccessStatusMock, execFileMock, createSocketMock, socketMock, @@ -28,6 +29,7 @@ const { askForMediaAccessMock: vi.fn(), getMediaAccessStatusMock: vi.fn(), isTrustedAccessibilityClientMock: vi.fn(), + getMacosFullDiskAccessStatusMock: vi.fn(), execFileMock: vi.fn(), createSocketMock: vi.fn(() => socketMock), socketMock, @@ -59,6 +61,10 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('../macos-full-disk-access-status', () => ({ + getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock +})) + import { registerDeveloperPermissionHandlers } from './developer-permissions' describe('registerDeveloperPermissionHandlers', () => { @@ -72,6 +78,8 @@ describe('registerDeveloperPermissionHandlers', () => { askForMediaAccessMock.mockReset() getMediaAccessStatusMock.mockReset() isTrustedAccessibilityClientMock.mockReset() + getMacosFullDiskAccessStatusMock.mockReset() + getMacosFullDiskAccessStatusMock.mockResolvedValue('denied') execFileMock.mockReset() execFileMock.mockImplementation((...args: unknown[]) => { const callback = args.at(-1) @@ -119,6 +127,21 @@ describe('registerDeveloperPermissionHandlers', () => { return call[1] as (_event: unknown, args: { id: string }) => Promise } + it('returns the Full Disk Access read-probe status', async () => { + getMacosFullDiskAccessStatusMock.mockResolvedValue('granted') + registerDeveloperPermissionHandlers() + + const call = handleMock.mock.calls.find( + (registration: unknown[]) => registration[0] === 'developerPermissions:getStatus' + ) + const handler = call?.[1] as (() => Promise) | undefined + + await expect(handler?.()).resolves.toContainEqual({ + id: 'full-disk-access', + status: 'granted' + }) + }) + it('clears the local-network prompt fallback timer when UDP send settles first', async () => { registerDeveloperPermissionHandlers() diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index d0d079bd0..dc1171b8c 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -1,9 +1,7 @@ import { execFile } from 'node:child_process' import dgram from 'node:dgram' -import { access } from 'node:fs/promises' -import { homedir } from 'node:os' -import path from 'node:path' import { ipcMain, shell, systemPreferences } from 'electron' +import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' import type { DeveloperPermissionId, DeveloperPermissionRequestResult, @@ -50,21 +48,6 @@ function getMediaStatus(mediaType: 'microphone' | 'camera' | 'screen'): Develope } } -async function getFullDiskAccessStatus(): Promise { - const unsupported = unsupportedOffMac() - if (unsupported) { - return unsupported - } - try { - // Why: Safari bookmarks are TCC-protected, so read access is a practical - // Full Disk Access signal without touching user project contents. - await access(path.join(homedir(), 'Library', 'Safari', 'Bookmarks.plist')) - return 'granted' - } catch { - return 'unknown' - } -} - function getAccessibilityStatus(): DeveloperPermissionStatus { const unsupported = unsupportedOffMac() if (unsupported) { @@ -165,7 +148,7 @@ async function getPermissionState(id: DeveloperPermissionId): Promise { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +describe('probeMacosFullDiskAccess', () => { + it('reports granted only when the TCC database opens for reading', async () => { + const readProbe = vi.fn().mockResolvedValue(undefined) + + await expect(probeMacosFullDiskAccess({ homeDirectory, readProbe })).resolves.toBe('granted') + expect(readProbe).toHaveBeenCalledWith(databasePath) + }) + + it.each(['EACCES', 'EPERM'])('reports denied for %s', async (code) => { + await expect( + probeMacosFullDiskAccess({ + homeDirectory, + readProbe: async () => { + throw fileSystemError(code) + } + }) + ).resolves.toBe('denied') + }) + + it.each(['ENOENT', 'ENOTDIR', 'EBUSY'])('keeps %s failures unknown', async (code) => { + await expect( + probeMacosFullDiskAccess({ + homeDirectory, + readProbe: async () => { + throw fileSystemError(code) + } + }) + ).resolves.toBe('unknown') + }) +}) + +describe('getMacosFullDiskAccessStatus', () => { + it('is unsupported off macOS', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + await expect(getMacosFullDiskAccessStatus()).resolves.toBe('unsupported') + }) +}) diff --git a/src/main/macos-full-disk-access-status.ts b/src/main/macos-full-disk-access-status.ts new file mode 100644 index 000000000..aea18b956 --- /dev/null +++ b/src/main/macos-full-disk-access-status.ts @@ -0,0 +1,44 @@ +import { open } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { DeveloperPermissionStatus } from '../shared/developer-permissions-types' + +type ReadProbe = (filePath: string) => Promise + +async function openForRead(filePath: string): Promise { + const handle = await open(filePath, 'r') + await handle.close() +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined +} + +export async function probeMacosFullDiskAccess({ + homeDirectory = homedir(), + readProbe = openForRead +}: { + homeDirectory?: string + readProbe?: ReadProbe +} = {}): Promise { + const databasePath = join( + homeDirectory, + 'Library', + 'Application Support', + 'com.apple.TCC', + 'TCC.db' + ) + try { + await readProbe(databasePath) + return 'granted' + } catch (error) { + const code = errorCode(error) + return code === 'EACCES' || code === 'EPERM' ? 'denied' : 'unknown' + } +} + +export async function getMacosFullDiskAccessStatus(): Promise { + return process.platform === 'darwin' ? probeMacosFullDiskAccess() : 'unsupported' +} diff --git a/src/main/macos-tcc-prompt-notice.test.ts b/src/main/macos-tcc-prompt-notice.test.ts index 1c268a7e2..e8745b67c 100644 --- a/src/main/macos-tcc-prompt-notice.test.ts +++ b/src/main/macos-tcc-prompt-notice.test.ts @@ -30,6 +30,7 @@ vi.mock('node:fs', async (importOriginal) => ({ const { TCC_PROMPT_NOTICE_THRESHOLD, + TCC_PROMPT_NOTICE_VERSION, TCC_PROMPT_WATCH_START_FALLBACK_MS, acknowledgePendingTccPromptNotice, consumePendingTccPromptNotice, @@ -67,7 +68,11 @@ describe('tcc prompt notice threshold', () => { handleTccPromptForTests() expect(writeFileAtomically).toHaveBeenCalledTimes(1) const [, contents] = writeFileAtomically.mock.calls[0] as [string, string] - expect(JSON.parse(contents)).toMatchObject({ promptCount: 1, notified: false }) + expect(JSON.parse(contents)).toMatchObject({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, + promptCount: 1, + notified: false + }) }) it('never fires again once dismissed, even past the threshold', () => { @@ -303,9 +308,9 @@ describe('tcc prompt notice threshold', () => { }) it.each([ - { promptCount: 2, notified: false }, - { promptCount: 1, notified: true } - ])('requires a fresh detection for a legacy tally: $promptCount/$notified', (persisted) => { + { promptCount: 2, notified: false, acknowledgedAfterClose: false }, + { promptCount: 1, notified: true, acknowledgedAfterClose: true } + ])('re-arms a legacy tally after a fresh detection: $promptCount/$notified', (persisted) => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue(JSON.stringify({ ...persisted, dismissed: false })) @@ -329,11 +334,35 @@ describe('tcc prompt notice threshold', () => { } }) + it('preserves a legacy permanent opt-out', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readTallyFile.mockReturnValue( + JSON.stringify({ + promptCount: 1, + notified: true, + dismissed: true, + 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!) + } + }) + 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({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 1, notified: false, dismissed: false, @@ -359,6 +388,7 @@ describe('tcc prompt notice threshold', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue( JSON.stringify({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 1, notified: true, dismissed: false, diff --git a/src/main/macos-tcc-prompt-notice.ts b/src/main/macos-tcc-prompt-notice.ts index 9d7dea359..c51367d8c 100644 --- a/src/main/macos-tcc-prompt-notice.ts +++ b/src/main/macos-tcc-prompt-notice.ts @@ -14,6 +14,9 @@ import { MacosTccPromptWatch } from './macos-tcc-prompt-watch' /** Why: the first detected dialog identifies an affected user; the notice remains one-time. */ export const TCC_PROMPT_NOTICE_THRESHOLD = 1 +/** Why: re-arm users who may have trusted the former false-positive Full Disk Access badge. */ +export const TCC_PROMPT_NOTICE_VERSION = 2 + /** Why: preserve detection when Electron never emits `ready-to-show` without competing with startup. */ export const TCC_PROMPT_WATCH_START_FALLBACK_MS = 10_000 @@ -28,6 +31,7 @@ export type TccPromptNoticeClaim = TccPromptNoticePayload & { } type TccPromptTally = { + noticeVersion: number promptCount: number notified: boolean dismissed: boolean @@ -35,6 +39,7 @@ type TccPromptTally = { } const EMPTY_TALLY: TccPromptTally = { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 0, notified: false, dismissed: false, @@ -57,15 +62,28 @@ 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. + if (dismissed) { + return { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, + promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0, + notified: true, + dismissed: true, + acknowledgedAfterClose: true + } + } + if (parsed.noticeVersion !== TCC_PROMPT_NOTICE_VERSION) { + return { ...EMPTY_TALLY } + } + if (typeof parsed.acknowledgedAfterClose !== 'boolean') { + // Why: an incomplete tally proves only a past prompt, not that access is still missing. return { ...EMPTY_TALLY } } const acknowledgedAfterClose = parsed.acknowledgedAfterClose === true return { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0, - notified: parsed.notified === true && (dismissed || acknowledgedAfterClose), - dismissed, + notified: parsed.notified === true && acknowledgedAfterClose, + dismissed: false, acknowledgedAfterClose } } catch {