From 344d466b885c32d54c133d9c42df5e58882fe6bd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:25:21 -0700 Subject: [PATCH] fix(macos): add a Full Disk Access nudge to reduce recurring TCC prompts (#9756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS shows the "Orca wants to access other apps' data" (kTCCServiceSystemPolicyAppData) prompt and it can keep reappearing. The reappearing loop is not a fixable app bug: it is TCC identity churn — an unsigned local rebuild mints a new code identity each build, so macOS treats each as a new app — and Orca's other-app reads are already gated behind opt-in settings or explicit user actions. The durable remedy for the population we can help (release users) is Full Disk Access, a superset macOS grant that stops these prompts for a stable identity. Surface it with an ambient, dismissable sidebar card that reuses the existing developer-permissions IPC. macOS-only; probes FDA status at most once per renderer session (the probe itself reads protected data, so it must not repeat on focus/remount); "Open System Settings" opens the Full Disk Access pane; permanent localStorage dismissal. --- .../sidebar/FullDiskAccessNudge.test.tsx | 370 ++++++++++++++++++ .../sidebar/FullDiskAccessNudge.tsx | 279 +++++++++++++ src/renderer/src/components/sidebar/index.tsx | 3 + src/renderer/src/i18n/locales/en.json | 11 + src/renderer/src/i18n/locales/es.json | 11 + src/renderer/src/i18n/locales/ja.json | 11 + src/renderer/src/i18n/locales/ko.json | 11 + src/renderer/src/i18n/locales/zh.json | 11 + 8 files changed, 707 insertions(+) create mode 100644 src/renderer/src/components/sidebar/FullDiskAccessNudge.test.tsx create mode 100644 src/renderer/src/components/sidebar/FullDiskAccessNudge.tsx diff --git a/src/renderer/src/components/sidebar/FullDiskAccessNudge.test.tsx b/src/renderer/src/components/sidebar/FullDiskAccessNudge.test.tsx new file mode 100644 index 00000000000..1d9a084b5a8 --- /dev/null +++ b/src/renderer/src/components/sidebar/FullDiskAccessNudge.test.tsx @@ -0,0 +1,370 @@ +// @vitest-environment happy-dom + +import * as React from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { + DeveloperPermissionRequestResult, + DeveloperPermissionState, + DeveloperPermissionStatus +} from '../../../../shared/developer-permissions-types' +import { TooltipProvider } from '@/components/ui/tooltip' +import { + FullDiskAccessNudge, + resetFullDiskAccessProbeForTests, + shouldShowFullDiskAccessNudge +} from './FullDiskAccessNudge' + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: vi.fn(), + success: vi.fn() + } +})) + +const MAC_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' +const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + +function setUserAgent(userAgent: string): void { + Object.defineProperty(window.navigator, 'userAgent', { + value: userAgent, + configurable: true + }) +} + +function installDeveloperPermissionsApi(args: { + getStatus: () => Promise + request?: () => Promise +}): { getStatus: ReturnType; request: ReturnType } { + const getStatus = vi.fn(args.getStatus) + const request = vi.fn( + args.request ?? + (async () => ({ + id: 'full-disk-access', + status: 'unknown', + openedSystemSettings: true + })) + ) + Object.assign(window, { api: { developerPermissions: { getStatus, request } } }) + return { getStatus, request } +} + +function fdaStatus(status: DeveloperPermissionStatus): DeveloperPermissionState[] { + return [{ id: 'full-disk-access', status }] +} + +async function renderNudge(): Promise<{ container: HTMLDivElement; root: Root }> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render( + React.createElement(TooltipProvider, null, React.createElement(FullDiskAccessNudge)) + ) + }) + // Flush the one-shot getStatus() probe microtasks. + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + return { container, root } +} + +afterEach(() => { + vi.restoreAllMocks() + document.body.innerHTML = '' + resetFullDiskAccessProbeForTests() + try { + window.localStorage.clear() + } catch { + // ignore + } +}) + +describe('shouldShowFullDiskAccessNudge', () => { + it('shows on macOS when Full Disk Access is not yet granted', () => { + expect( + shouldShowFullDiskAccessNudge({ isMac: true, dismissed: false, status: 'unknown' }) + ).toBe(true) + }) + + it('hides when granted, ready, unsupported, or unresolved', () => { + for (const status of ['granted', 'ready', 'unsupported', undefined] as const) { + expect(shouldShowFullDiskAccessNudge({ isMac: true, dismissed: false, status })).toBe(false) + } + }) + + it('hides off macOS and when dismissed', () => { + expect( + shouldShowFullDiskAccessNudge({ isMac: false, dismissed: false, status: 'unknown' }) + ).toBe(false) + expect(shouldShowFullDiskAccessNudge({ isMac: true, dismissed: true, status: 'unknown' })).toBe( + false + ) + }) +}) + +describe('FullDiskAccessNudge', () => { + it('renders the ambient card with honest copy when FDA is ungranted on macOS', async () => { + setUserAgent(MAC_UA) + installDeveloperPermissionsApi({ getStatus: async () => fdaStatus('unknown') }) + const { container } = await renderNudge() + expect(container.textContent).toContain('Reduce macOS permission prompts') + expect(container.textContent).toContain('Full Disk Access') + expect(container.textContent).toContain('Open System Settings') + }) + + it('stays hidden while the probe is unresolved (no first-paint flash)', async () => { + setUserAgent(MAC_UA) + // A never-resolving probe leaves status undefined. + installDeveloperPermissionsApi({ getStatus: () => new Promise(() => {}) }) + const { container } = await renderNudge() + expect(container.textContent).toBe('') + }) + + it('hides once Full Disk Access is granted', async () => { + setUserAgent(MAC_UA) + installDeveloperPermissionsApi({ getStatus: async () => fdaStatus('granted') }) + const { container } = await renderNudge() + expect(container.textContent).toBe('') + }) + + it('does not probe permissions off macOS', async () => { + setUserAgent(WINDOWS_UA) + const { getStatus } = installDeveloperPermissionsApi({ + getStatus: async () => fdaStatus('unknown') + }) + const { container } = await renderNudge() + expect(container.textContent).toBe('') + expect(getStatus).not.toHaveBeenCalled() + }) + + it('stays hidden on the web client whose status list is empty', async () => { + setUserAgent(MAC_UA) + installDeveloperPermissionsApi({ getStatus: async () => [] }) + const { container } = await renderNudge() + expect(container.textContent).toBe('') + }) + + it('opens System Settings via the Full Disk Access request', async () => { + setUserAgent(MAC_UA) + const { request } = installDeveloperPermissionsApi({ + getStatus: async () => fdaStatus('unknown') + }) + const { container } = await renderNudge() + const openButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Open System Settings') + ) + expect(openButton).toBeTruthy() + await act(async () => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(request).toHaveBeenCalledWith({ id: 'full-disk-access' }) + }) + + it('hides the card when the request reports Full Disk Access was granted', async () => { + setUserAgent(MAC_UA) + installDeveloperPermissionsApi({ + getStatus: async () => fdaStatus('unknown'), + request: async () => ({ + id: 'full-disk-access', + status: 'granted', + openedSystemSettings: false + }) + }) + const { container } = await renderNudge() + const openButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Open System Settings') + ) + await act(async () => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.textContent).toBe('') + expect(toast.success).toHaveBeenCalled() + }) + + it('re-enables the action and surfaces an error toast when the request rejects', async () => { + setUserAgent(MAC_UA) + installDeveloperPermissionsApi({ + getStatus: async () => fdaStatus('unknown'), + request: async () => { + throw new Error('ipc failure') + } + }) + const { container } = await renderNudge() + const openButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Open System Settings') + ) + await act(async () => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(toast.error).toHaveBeenCalled() + // Card stays visible and the button is usable again for a retry. + const retryButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Open System Settings') + ) + expect(retryButton).toBeTruthy() + expect((retryButton as HTMLButtonElement).disabled).toBe(false) + }) + + it('probes protected data only once even under React StrictMode', async () => { + setUserAgent(MAC_UA) + const { getStatus } = installDeveloperPermissionsApi({ + getStatus: async () => fdaStatus('unknown') + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render( + React.createElement( + React.StrictMode, + null, + React.createElement(TooltipProvider, null, React.createElement(FullDiskAccessNudge)) + ) + ) + }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(getStatus).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain('Reduce macOS permission prompts') + }) + + it('hides on return-focus once the grant takes effect after the CTA', async () => { + setUserAgent(MAC_UA) + let currentStatus: DeveloperPermissionStatus = 'unknown' + installDeveloperPermissionsApi({ + getStatus: async () => fdaStatus(currentStatus), + request: async () => ({ + id: 'full-disk-access', + status: 'unknown', + openedSystemSettings: true + }) + }) + const { container } = await renderNudge() + const openButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Open System Settings') + ) + await act(async () => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + // Opening System Settings alone does not grant, so the card stays. + expect(container.textContent).toContain('Reduce macOS permission prompts') + // The user grants Full Disk Access in System Settings, then returns to Orca. + currentStatus = 'granted' + await act(async () => { + window.dispatchEvent(new Event('focus')) + await Promise.resolve() + await Promise.resolve() + }) + expect(container.textContent).toBe('') + }) + + it('stops watching for the grant once the card is dismissed', async () => { + setUserAgent(MAC_UA) + const { getStatus } = installDeveloperPermissionsApi({ + getStatus: async () => fdaStatus('unknown'), + request: async () => ({ + id: 'full-disk-access', + status: 'unknown', + openedSystemSettings: true + }) + }) + const { container } = await renderNudge() + const openButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Open System Settings') + ) + await act(async () => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + const dismissButton = container.querySelector( + 'button[aria-label="Dismiss Full Disk Access suggestion"]' + ) + await act(async () => { + dismissButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.textContent).toBe('') + const callsAtDismiss = getStatus.mock.calls.length + // A later window focus must not re-probe protected data after dismissal. + await act(async () => { + window.dispatchEvent(new Event('focus')) + await Promise.resolve() + await Promise.resolve() + }) + expect(getStatus.mock.calls.length).toBe(callsAtDismiss) + }) + + it('records a granted request result in the session cache even if unmounted mid-request', async () => { + setUserAgent(MAC_UA) + let resolveRequest: (result: DeveloperPermissionRequestResult) => void = () => {} + const pendingRequest = new Promise((resolve) => { + resolveRequest = resolve + }) + installDeveloperPermissionsApi({ + getStatus: async () => fdaStatus('unknown'), + request: () => pendingRequest + }) + const { container, root } = await renderNudge() + const openButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Open System Settings') + ) + await act(async () => { + openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await act(async () => { + root.unmount() + }) + await act(async () => { + resolveRequest({ id: 'full-disk-access', status: 'granted', openedSystemSettings: false }) + await Promise.resolve() + await Promise.resolve() + }) + // A fresh mount reads the updated session cache and stays hidden (granted). + installDeveloperPermissionsApi({ getStatus: async () => fdaStatus('unknown') }) + const remount = await renderNudge() + expect(remount.container.textContent).toBe('') + }) + + it('dismisses immediately, persists, and does not re-probe after remount', async () => { + setUserAgent(MAC_UA) + const first = installDeveloperPermissionsApi({ getStatus: async () => fdaStatus('unknown') }) + const rendered = await renderNudge() + const dismissButton = rendered.container.querySelector( + 'button[aria-label="Dismiss Full Disk Access suggestion"]' + ) + expect(dismissButton).toBeTruthy() + await act(async () => { + dismissButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(rendered.container.textContent).toBe('') + expect(window.localStorage.getItem('orca.fullDiskAccessNudgeDismissed.v1')).toBe('1') + + // Remounting after a permanent dismissal must not render or re-probe. + const second = installDeveloperPermissionsApi({ getStatus: async () => fdaStatus('unknown') }) + const remounted = await renderNudge() + expect(remounted.container.textContent).toBe('') + expect(second.getStatus).not.toHaveBeenCalled() + expect(first.getStatus).toHaveBeenCalledTimes(1) + }) + + it('dismisses for the session even when persistence throws', async () => { + setUserAgent(MAC_UA) + installDeveloperPermissionsApi({ getStatus: async () => fdaStatus('unknown') }) + vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { + throw new Error('storage unavailable') + }) + const { container } = await renderNudge() + const dismissButton = container.querySelector( + 'button[aria-label="Dismiss Full Disk Access suggestion"]' + ) + await act(async () => { + dismissButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.textContent).toBe('') + }) +}) diff --git a/src/renderer/src/components/sidebar/FullDiskAccessNudge.tsx b/src/renderer/src/components/sidebar/FullDiskAccessNudge.tsx new file mode 100644 index 00000000000..b4d695e8471 --- /dev/null +++ b/src/renderer/src/components/sidebar/FullDiskAccessNudge.tsx @@ -0,0 +1,279 @@ +import { useCallback, useEffect, useState } from 'react' +import { ExternalLink, HardDrive, Loader2, X } from 'lucide-react' +import { toast } from 'sonner' +import type { + DeveloperPermissionId, + DeveloperPermissionState, + DeveloperPermissionStatus +} from '../../../../shared/developer-permissions-types' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useMountedRef } from '@/hooks/useMountedRef' +import { isMacUserAgent } from '../terminal-pane/pane-helpers' +import { + isFullDiskAccessReady, + isFullDiskAccessSetupVisible +} from '../feature-wall/FullDiskAccessSetupPrompt' +import { translate } from '@/i18n/i18n' + +const FULL_DISK_ACCESS_PERMISSION_ID: DeveloperPermissionId = 'full-disk-access' +// Why: camelCase segment + .v1 suffix matches sibling one-time sidebar keys +// (orca.workspaceBoardMovedHintSeen.v1); bump the version to re-surface later. +const DISMISS_KEY = 'orca.fullDiskAccessNudgeDismissed.v1' + +// Why: the FDA status probe reads Safari's TCC-protected data — the same "other +// apps' data" read this nudge exists to reduce — so it must run at most once per +// renderer session, shared across StrictMode replay and sidebar remounts (#9756). +let cachedFullDiskAccessStatus: DeveloperPermissionStatus | undefined +let fullDiskAccessProbed = false +let fullDiskAccessProbe: Promise | null = null + +function getFullDiskAccessStatus( + states: readonly DeveloperPermissionState[] +): DeveloperPermissionStatus | undefined { + return states.find((state) => state.id === FULL_DISK_ACCESS_PERMISSION_ID)?.status +} + +function rememberFullDiskAccessStatus(status: DeveloperPermissionStatus | undefined): void { + cachedFullDiskAccessStatus = status + fullDiskAccessProbed = true +} + +function probeFullDiskAccessStatusOnce(): Promise { + if (fullDiskAccessProbed) { + return Promise.resolve(cachedFullDiskAccessStatus) + } + if (!fullDiskAccessProbe) { + fullDiskAccessProbe = window.api.developerPermissions + .getStatus() + .then((states) => { + rememberFullDiskAccessStatus(getFullDiskAccessStatus(states)) + return cachedFullDiskAccessStatus + }) + .catch(() => { + // Inconclusive probe: allow a later mount to retry rather than caching it. + fullDiskAccessProbe = null + return undefined + }) + } + return fullDiskAccessProbe +} + +// Why: a fresh, cache-bypassing probe used only on CTA return-focus so the card +// can hide the moment a Full Disk Access grant takes effect for this process. +function refreshFullDiskAccessStatus(): Promise { + return window.api.developerPermissions + .getStatus() + .then((states) => { + const next = getFullDiskAccessStatus(states) + rememberFullDiskAccessStatus(next) + return next + }) + .catch(() => cachedFullDiskAccessStatus) +} + +export function resetFullDiskAccessProbeForTests(): void { + cachedFullDiskAccessStatus = undefined + fullDiskAccessProbed = false + fullDiskAccessProbe = null +} + +function readDismissed(): boolean { + try { + return window.localStorage.getItem(DISMISS_KEY) === '1' + } catch { + return false + } +} + +function persistDismissed(): void { + try { + window.localStorage.setItem(DISMISS_KEY, '1') + } catch { + // Best-effort; if storage is unavailable the nudge returns next launch. + } +} + +export function shouldShowFullDiskAccessNudge(args: { + isMac: boolean + dismissed: boolean + status: DeveloperPermissionStatus | undefined +}): boolean { + return ( + args.isMac && + !args.dismissed && + isFullDiskAccessSetupVisible(args.status) && + !isFullDiskAccessReady(args.status) + ) +} + +/** + * Ambient, dismissable sidebar card that surfaces the durable Full Disk Access + * grant for the recurring macOS "access other apps' data" prompt (#9756) — a TCC + * identity-churn artifact, not a code bug we can otherwise fix. Off macOS renders nothing. + */ +export function FullDiskAccessNudge(): React.JSX.Element | null { + const mountedRef = useMountedRef() + const isMac = isMacUserAgent() + const [dismissed, setDismissed] = useState(() => readDismissed()) + const [status, setStatus] = useState( + () => cachedFullDiskAccessStatus + ) + const [requesting, setRequesting] = useState(false) + const [watchForGrant, setWatchForGrant] = useState(false) + + useEffect(() => { + // Why: probe only when the card could actually show — never off macOS or once + // dismissed. probeFullDiskAccessStatusOnce() reads protected data at most once. + if (!isMac || dismissed) { + return + } + void probeFullDiskAccessStatusOnce().then((next) => { + if (mountedRef.current) { + setStatus(next) + } + }) + }, [isMac, dismissed, mountedRef]) + + useEffect(() => { + // Why: only after the user clicked "Open System Settings" (explicit intent to + // grant), watch return-focus for the grant so the card hides once FDA is + // effective — one gated read per focus. Stops once granted or dismissed so a + // permanently dismissed card never keeps probing protected data. + if (!watchForGrant || dismissed || isFullDiskAccessReady(status)) { + return + } + const onFocus = (): void => { + void refreshFullDiskAccessStatus().then((next) => { + if (mountedRef.current) { + setStatus(next) + } + }) + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [watchForGrant, dismissed, status, mountedRef]) + + const handleDismiss = useCallback((): void => { + persistDismissed() + setDismissed(true) + }, []) + + const handleOpenSystemSettings = useCallback(async (): Promise => { + setRequesting(true) + try { + const result = await window.api.developerPermissions.request({ + id: FULL_DISK_ACCESS_PERMISSION_ID + }) + // Why: the request re-probes in main (authoritative); record it in the + // session cache before the mount check so an unmount mid-request can't + // strand a later remount on a stale status. + rememberFullDiskAccessStatus(result.status) + if (!mountedRef.current) { + return + } + // Why: feed it back so a granted result hides the card, and watch return- + // focus for the eventual grant since it opens Settings first. + setStatus(result.status) + setWatchForGrant(true) + if (result.status === 'granted') { + toast.success( + translate( + 'auto.components.sidebar.FullDiskAccessNudge.granted', + 'Full Disk Access granted' + ) + ) + } else if (result.openedSystemSettings) { + toast.message( + translate( + 'auto.components.sidebar.FullDiskAccessNudge.openedPrivacy', + 'Opened macOS Privacy & Security' + ) + ) + } + } catch { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.sidebar.FullDiskAccessNudge.requestError', + 'Could not open System Settings' + ) + ) + } + } finally { + if (mountedRef.current) { + setRequesting(false) + } + } + }, [mountedRef]) + + // Why: hidden while the probe is unresolved (no first-paint flash) and once FDA + // is already granted; off macOS or after a permanent dismissal it never shows. + if (!shouldShowFullDiskAccessNudge({ isMac, dismissed, status })) { + return null + } + + return ( +
+
+
+
+ +

+ {translate( + 'auto.components.sidebar.FullDiskAccessNudge.title', + 'Reduce macOS permission prompts' + )} +

+
+ + + + + + {translate('auto.components.sidebar.FullDiskAccessNudge.dismiss', 'Dismiss')} + + +
+

+ {translate( + 'auto.components.sidebar.FullDiskAccessNudge.body', + 'Grant Full Disk Access so macOS stops asking when this copy of Orca reads protected app and folder data.' + )} +

+ +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index b224e594277..504e284f70d 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -5,6 +5,7 @@ import { useSidebarResize } from '@/hooks/useSidebarResize' import SidebarHeader from './SidebarHeader' import SidebarNav from './SidebarNav' import SetupScriptPromptCard from './SetupScriptPromptCard' +import { FullDiskAccessNudge } from './FullDiskAccessNudge' import WorktreeList from './WorktreeList' import SidebarToolbar from './SidebarToolbar' import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' @@ -156,6 +157,8 @@ function Sidebar({ + + {/* Fixed bottom toolbar */}