mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(macos): add a Full Disk Access nudge to reduce recurring TCC prompts (#9756)
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.
This commit is contained in:
@@ -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<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 }]
|
||||
}
|
||||
|
||||
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<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('')
|
||||
})
|
||||
})
|
||||
@@ -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<DeveloperPermissionStatus | undefined> | 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<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> {
|
||||
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<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',
|
||||
'Grant Full Disk Access so macOS stops asking when this copy of Orca reads protected app and folder data.'
|
||||
)}
|
||||
</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,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({
|
||||
|
||||
<SetupScriptPromptCard />
|
||||
|
||||
<FullDiskAccessNudge />
|
||||
|
||||
{/* Fixed bottom toolbar */}
|
||||
<SidebarToolbar
|
||||
workspaceBoardOpen={workspaceBoardOpen}
|
||||
|
||||
@@ -4827,6 +4827,17 @@
|
||||
"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": "Grant Full Disk Access so macOS stops asking when this copy of Orca reads protected app and folder data.",
|
||||
"opening": "Opening…",
|
||||
"openSystemSettings": "Open System Settings"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4804,6 +4804,17 @@
|
||||
"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": "Grant Full Disk Access so macOS stops asking when this copy of Orca reads protected app and folder data.",
|
||||
"opening": "Opening…",
|
||||
"openSystemSettings": "Open System Settings"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4804,6 +4804,17 @@
|
||||
"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": "Grant Full Disk Access so macOS stops asking when this copy of Orca reads protected app and folder data.",
|
||||
"opening": "Opening…",
|
||||
"openSystemSettings": "Open System Settings"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4804,6 +4804,17 @@
|
||||
"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": "Grant Full Disk Access so macOS stops asking when this copy of Orca reads protected app and folder data.",
|
||||
"opening": "Opening…",
|
||||
"openSystemSettings": "Open System Settings"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4804,6 +4804,17 @@
|
||||
"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": "Grant Full Disk Access so macOS stops asking when this copy of Orca reads protected app and folder data.",
|
||||
"opening": "Opening…",
|
||||
"openSystemSettings": "Open System Settings"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
Reference in New Issue
Block a user