fix(macos): verify Full Disk Access and re-arm notices (#11716)
* fix(macos): verify full disk access status * perf(macos): keep full disk probe off main thread
This commit is contained in:
parent
4f00b21186
commit
48e52540d1
|
|
@ -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<unknown>
|
||||
}
|
||||
|
||||
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<unknown>) | 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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<DeveloperPermissionStatus> {
|
||||
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<DeveloperP
|
|||
case 'accessibility':
|
||||
return { id, status: getAccessibilityStatus() }
|
||||
case 'full-disk-access':
|
||||
return { id, status: await getFullDiskAccessStatus() }
|
||||
return { id, status: await getMacosFullDiskAccessStatus() }
|
||||
case 'automation':
|
||||
case 'local-network':
|
||||
return { id, status: unsupportedOffMac() ?? 'unknown' }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
getMacosFullDiskAccessStatus,
|
||||
probeMacosFullDiskAccess
|
||||
} from './macos-full-disk-access-status'
|
||||
|
||||
const originalPlatform = process.platform
|
||||
const homeDirectory = join('Users', 'tester')
|
||||
const databasePath = join(
|
||||
homeDirectory,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'com.apple.TCC',
|
||||
'TCC.db'
|
||||
)
|
||||
|
||||
function fileSystemError(code: string): NodeJS.ErrnoException {
|
||||
return Object.assign(new Error(code), { code })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<void>
|
||||
|
||||
async function openForRead(filePath: string): Promise<void> {
|
||||
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<DeveloperPermissionStatus> {
|
||||
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<DeveloperPermissionStatus> {
|
||||
return process.platform === 'darwin' ? probeMacosFullDiskAccess() : 'unsupported'
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue