mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(macos): explain the TCC prompts on the settings row, drop the sidebar card
The sidebar nudge added in 344d466b was premised on FDA being reachable
"only inside onboarding". It isn't: Settings > macOS Permissions has had a
full-disk-access row all along (searchable), the Setup Guide hosts the same
prompt from both a settings pane and a re-openable modal, and the sidebar
already links to that modal via the "Onboarding checklist" entry. The card
added a fifth affordance to the same sidebar that already had the fourth,
so it bought prominence rather than access - shown to every macOS user
without FDA, most of whom never hit #9756.
Keep the part that was actually new. The settings row still described the
prompts as something projects and worktrees trigger, which is the same
misattribution the card carried: the reads come from the agents Orca runs,
and macOS names Orca only because it is the responsible process for every
terminal child. It also never mentioned that the grant has to cover Orca
Helper, or that the preserved daemon keeps stale TCC state until restart.
Non-English catalogs get the English string as a placeholder; the bootstrap
translators key their cache on the English value, so a changed string is
re-translated on the next run.
This commit is contained in:
@@ -105,7 +105,7 @@ const PERMISSIONS: PermissionDefinition[] = [
|
||||
get description() {
|
||||
return translate(
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
|
||||
'Recommended when projects, worktrees, or symlinked files touch macOS-protected folders.'
|
||||
"macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Granting this reduces those prompts. Terminals run in a separate helper, so grant Orca Helper too and restart Orca."
|
||||
)
|
||||
},
|
||||
actionLabel: 'Open Settings',
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
// @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'
|
||||
|
||||
// Why: without this React skips its "update not wrapped in act" warnings, so a
|
||||
// state update escaping act() in these async probe paths would pass unnoticed.
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
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<DeveloperPermissionState[]>
|
||||
request?: () => Promise<DeveloperPermissionRequestResult>
|
||||
}): { getStatus: ReturnType<typeof vi.fn>; request: ReturnType<typeof vi.fn> } {
|
||||
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 }]
|
||||
}
|
||||
|
||||
const mountedRoots: Root[] = []
|
||||
|
||||
async function renderNudge(): Promise<{ container: HTMLDivElement; root: Root }> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
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(async () => {
|
||||
// Why: clearing document.body leaves the roots mounted, so their window focus
|
||||
// listener survives and re-probes during later tests. Unmount them instead.
|
||||
const roots = mountedRoots.splice(0)
|
||||
await act(async () => {
|
||||
for (const root of roots) {
|
||||
root.unmount()
|
||||
}
|
||||
})
|
||||
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')
|
||||
// The grant covers this app, not the detached PTY daemon that owns terminals.
|
||||
expect(container.textContent).toContain('may still prompt separately')
|
||||
expect(container.textContent).not.toContain('stops asking')
|
||||
// Agent sweeps trigger these prompts; macOS only names Orca as the responsible process.
|
||||
expect(container.textContent).toContain('agents it runs')
|
||||
})
|
||||
|
||||
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)
|
||||
// Why: this test builds its own root instead of renderNudge(), so it has to
|
||||
// register for the afterEach unmount or it leaks a live component into later tests.
|
||||
mountedRoots.push(root)
|
||||
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('ignores a slow focus refresh that resolves after a newer one saw the grant', async () => {
|
||||
setUserAgent(MAC_UA)
|
||||
const pendingResolvers: ((states: DeveloperPermissionState[]) => void)[] = []
|
||||
let deferGetStatus = false
|
||||
installDeveloperPermissionsApi({
|
||||
getStatus: () =>
|
||||
deferGetStatus
|
||||
? new Promise<DeveloperPermissionState[]>((resolve) => {
|
||||
pendingResolvers.push(resolve)
|
||||
})
|
||||
: Promise.resolve(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 }))
|
||||
})
|
||||
// Rapid blur/focus while the first round-trip is still in flight.
|
||||
deferGetStatus = true
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
expect(pendingResolvers.length).toBe(2)
|
||||
// The newer refresh observes the grant and hides the card.
|
||||
await act(async () => {
|
||||
pendingResolvers[1]?.(fdaStatus('granted'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(container.textContent).toBe('')
|
||||
// The older, slower refresh still reports the pre-grant status; it must not win.
|
||||
await act(async () => {
|
||||
pendingResolvers[0]?.(fdaStatus('unknown'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(container.textContent).toBe('')
|
||||
// The stale result must not poison the session cache a later mount reads either.
|
||||
const remount = await renderNudge()
|
||||
expect(remount.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<DeveloperPermissionRequestResult>((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('')
|
||||
})
|
||||
})
|
||||
@@ -1,287 +0,0 @@
|
||||
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<DeveloperPermissionStatus | undefined> | null = null
|
||||
let fullDiskAccessRefreshSequence = 0
|
||||
|
||||
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<DeveloperPermissionStatus | undefined> {
|
||||
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<DeveloperPermissionStatus | undefined> {
|
||||
// Why: rapid focus/blur puts several getStatus() round-trips in flight, and an
|
||||
// earlier pre-grant result resolving last would un-hide the card and poison the
|
||||
// session cache — same refresh-sequence guard as FullDiskAccessSetupPrompt.
|
||||
const refreshId = ++fullDiskAccessRefreshSequence
|
||||
return window.api.developerPermissions
|
||||
.getStatus()
|
||||
.then((states) => {
|
||||
if (refreshId !== fullDiskAccessRefreshSequence) {
|
||||
return cachedFullDiskAccessStatus
|
||||
}
|
||||
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<boolean>(() => readDismissed())
|
||||
const [status, setStatus] = useState<DeveloperPermissionStatus | undefined>(
|
||||
() => 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<void> => {
|
||||
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 (
|
||||
<div className="shrink-0 px-3 pb-2">
|
||||
<div className="worktree-sidebar-notice-card rounded-lg p-3 text-worktree-sidebar-accent-foreground">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<HardDrive className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm font-semibold leading-snug">
|
||||
{translate(
|
||||
'auto.components.sidebar.FullDiskAccessNudge.title',
|
||||
'Reduce macOS permission prompts'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="-mr-1 -mt-1 shrink-0 text-muted-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.FullDiskAccessNudge.dismissAriaLabel',
|
||||
'Dismiss Full Disk Access suggestion'
|
||||
)}
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate('auto.components.sidebar.FullDiskAccessNudge.dismiss', 'Dismiss')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.FullDiskAccessNudge.body',
|
||||
"These prompts come from Orca and the agents it runs reading other apps' data. Full Disk Access reduces them; terminal sessions may still prompt separately."
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="mt-2 w-full gap-1.5"
|
||||
disabled={requesting}
|
||||
onClick={() => void handleOpenSystemSettings()}
|
||||
>
|
||||
{requesting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="size-3" />
|
||||
)}
|
||||
{requesting
|
||||
? translate('auto.components.sidebar.FullDiskAccessNudge.opening', 'Opening…')
|
||||
: translate(
|
||||
'auto.components.sidebar.FullDiskAccessNudge.openSystemSettings',
|
||||
'Open System Settings'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -157,8 +156,6 @@ function Sidebar({
|
||||
|
||||
<SetupScriptPromptCard />
|
||||
|
||||
<FullDiskAccessNudge />
|
||||
|
||||
{/* Fixed bottom toolbar */}
|
||||
<SidebarToolbar
|
||||
workspaceBoardOpen={workspaceBoardOpen}
|
||||
|
||||
@@ -4827,17 +4827,6 @@
|
||||
"cancel": "Cancel",
|
||||
"forget": "Remove from Orca",
|
||||
"reconnectAndDelete": "Reconnect & Delete"
|
||||
},
|
||||
"FullDiskAccessNudge": {
|
||||
"granted": "Full Disk Access granted",
|
||||
"openedPrivacy": "Opened macOS Privacy & Security",
|
||||
"requestError": "Could not open System Settings",
|
||||
"title": "Reduce macOS permission prompts",
|
||||
"dismissAriaLabel": "Dismiss Full Disk Access suggestion",
|
||||
"dismiss": "Dismiss",
|
||||
"body": "These prompts come from Orca and the agents it runs reading other apps' data. Full Disk Access reduces them; terminal sessions may still prompt separately.",
|
||||
"opening": "Opening…",
|
||||
"openSystemSettings": "Open System Settings"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -5463,7 +5452,7 @@
|
||||
"e7bb06007c": "Local Network",
|
||||
"4a73f5217a": "Apple Events for scripts that control other local apps.",
|
||||
"e119f0d66b": "Automation",
|
||||
"7ca17b62c8": "Recommended when projects, worktrees, or symlinked files touch macOS-protected folders.",
|
||||
"7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Granting this reduces those prompts. Terminals run in a separate helper, so grant Orca Helper too and restart Orca.",
|
||||
"c566bca278": "Full Disk Access",
|
||||
"9f35980756": "Keystroke injection, window control, and UI automation tools.",
|
||||
"5b2f22ca2d": "Accessibility",
|
||||
|
||||
@@ -4804,17 +4804,6 @@
|
||||
"cancel": "Cancelar",
|
||||
"forget": "Eliminar de Orca",
|
||||
"reconnectAndDelete": "Reconectar y eliminar"
|
||||
},
|
||||
"FullDiskAccessNudge": {
|
||||
"granted": "Full Disk Access granted",
|
||||
"openedPrivacy": "Opened macOS Privacy & Security",
|
||||
"requestError": "Could not open System Settings",
|
||||
"title": "Reduce macOS permission prompts",
|
||||
"dismissAriaLabel": "Dismiss Full Disk Access suggestion",
|
||||
"dismiss": "Dismiss",
|
||||
"body": "These prompts come from Orca and the agents it runs reading other apps' data. Full Disk Access reduces them; terminal sessions may still prompt separately.",
|
||||
"opening": "Opening…",
|
||||
"openSystemSettings": "Open System Settings"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -5440,7 +5429,7 @@
|
||||
"e7bb06007c": "Red local",
|
||||
"4a73f5217a": "Apple Events para scripts que controlan otras apps locales.",
|
||||
"e119f0d66b": "Automatización",
|
||||
"7ca17b62c8": "Recomendado cuando proyectos, worktrees o archivos symlinked tocan carpetas protegidas de macOS.",
|
||||
"7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Granting this reduces those prompts. Terminals run in a separate helper, so grant Orca Helper too and restart Orca.",
|
||||
"c566bca278": "Acceso total al disco",
|
||||
"9f35980756": "Herramientas de inyección de teclas, control de ventanas y automatización de UI.",
|
||||
"5b2f22ca2d": "Accesibilidad",
|
||||
|
||||
@@ -4804,17 +4804,6 @@
|
||||
"cancel": "キャンセル",
|
||||
"forget": "Orcaから削除",
|
||||
"reconnectAndDelete": "再接続して削除"
|
||||
},
|
||||
"FullDiskAccessNudge": {
|
||||
"granted": "Full Disk Access granted",
|
||||
"openedPrivacy": "Opened macOS Privacy & Security",
|
||||
"requestError": "Could not open System Settings",
|
||||
"title": "Reduce macOS permission prompts",
|
||||
"dismissAriaLabel": "Dismiss Full Disk Access suggestion",
|
||||
"dismiss": "Dismiss",
|
||||
"body": "These prompts come from Orca and the agents it runs reading other apps' data. Full Disk Access reduces them; terminal sessions may still prompt separately.",
|
||||
"opening": "Opening…",
|
||||
"openSystemSettings": "Open System Settings"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -5425,7 +5414,7 @@
|
||||
"e7bb06007c": "ローカルネットワーク",
|
||||
"4a73f5217a": "他のローカル アプリを制御するスクリプトの Apple Events。",
|
||||
"e119f0d66b": "オートメーション",
|
||||
"7ca17b62c8": "プロジェクト、worktree、またはシンボリックリンクされたファイルが macOS の保護フォルダーに触れる場合に推奨されます。",
|
||||
"7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Granting this reduces those prompts. Terminals run in a separate helper, so grant Orca Helper too and restart Orca.",
|
||||
"c566bca278": "フルディスクアクセス",
|
||||
"9f35980756": "キーストローク インジェクション、ウィンドウ コントロール、UI 自動化ツール。",
|
||||
"5b2f22ca2d": "アクセシビリティ",
|
||||
|
||||
@@ -4804,17 +4804,6 @@
|
||||
"cancel": "취소",
|
||||
"forget": "Orca에서 제거",
|
||||
"reconnectAndDelete": "재연결 후 삭제"
|
||||
},
|
||||
"FullDiskAccessNudge": {
|
||||
"granted": "Full Disk Access granted",
|
||||
"openedPrivacy": "Opened macOS Privacy & Security",
|
||||
"requestError": "Could not open System Settings",
|
||||
"title": "Reduce macOS permission prompts",
|
||||
"dismissAriaLabel": "Dismiss Full Disk Access suggestion",
|
||||
"dismiss": "Dismiss",
|
||||
"body": "These prompts come from Orca and the agents it runs reading other apps' data. Full Disk Access reduces them; terminal sessions may still prompt separately.",
|
||||
"opening": "Opening…",
|
||||
"openSystemSettings": "Open System Settings"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -5425,7 +5414,7 @@
|
||||
"e7bb06007c": "로컬 네트워크",
|
||||
"4a73f5217a": "다른 로컬 앱을 제어하는 스크립트를 위한 Apple 이벤트.",
|
||||
"e119f0d66b": "자동화",
|
||||
"7ca17b62c8": "프로젝트, 워크트리 또는 심볼릭 링크된 파일이 macOS 보호 폴더에 닿을 때 권장됩니다.",
|
||||
"7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Granting this reduces those prompts. Terminals run in a separate helper, so grant Orca Helper too and restart Orca.",
|
||||
"c566bca278": "전체 디스크 액세스",
|
||||
"9f35980756": "키 입력 주입, 창 제어 및 UI 자동화 도구입니다.",
|
||||
"5b2f22ca2d": "접근성",
|
||||
|
||||
@@ -4804,17 +4804,6 @@
|
||||
"cancel": "取消",
|
||||
"forget": "从 Orca 中移除",
|
||||
"reconnectAndDelete": "重新连接并删除"
|
||||
},
|
||||
"FullDiskAccessNudge": {
|
||||
"granted": "Full Disk Access granted",
|
||||
"openedPrivacy": "Opened macOS Privacy & Security",
|
||||
"requestError": "Could not open System Settings",
|
||||
"title": "Reduce macOS permission prompts",
|
||||
"dismissAriaLabel": "Dismiss Full Disk Access suggestion",
|
||||
"dismiss": "Dismiss",
|
||||
"body": "These prompts come from Orca and the agents it runs reading other apps' data. Full Disk Access reduces them; terminal sessions may still prompt separately.",
|
||||
"opening": "Opening…",
|
||||
"openSystemSettings": "Open System Settings"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -5425,7 +5414,7 @@
|
||||
"e7bb06007c": "本地网络",
|
||||
"4a73f5217a": "用于控制其他本地应用程序的脚本的 Apple 事件。",
|
||||
"e119f0d66b": "自动化",
|
||||
"7ca17b62c8": "当项目、worktree 或符号链接文件会访问 macOS 受保护文件夹时推荐启用。",
|
||||
"7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Granting this reduces those prompts. Terminals run in a separate helper, so grant Orca Helper too and restart Orca.",
|
||||
"c566bca278": "全磁盘访问",
|
||||
"9f35980756": "击键注入、窗口控制和 UI 自动化工具。",
|
||||
"5b2f22ca2d": "无障碍",
|
||||
|
||||
Reference in New Issue
Block a user