mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(computer-use): stop relaunching the macOS permission helper on every status read (#34390) (#36028)
* fix(computer-use): stop relaunching the macOS permission helper while Accessibility is denied (#34390) Every computerUsePermissions:getStatus / computer.permissionsStatus call launched a fresh helper via open -n that polls TCC for up to ~3.5s. Three always-mounted setup-guide hooks re-read on every window focus, so a denied Accessibility state produced batches of helper launches and continuous TCCAccessRequest traffic. Gate status reads in main: coalesce concurrent reads, reuse granted results for 2s, and back off denied/failed results 10s doubling to a 120s cap. Window focus lowers the backoff to a 10s floor; openSetup/reset invalidate it. * fix(computer-use): never serve stale macOS permission status after the user acts (#34390) - Re-probe once per real return to Orca (blur then focus) instead of a 10s floor, so grants and revocations made in System Settings show without another focus. - computer.permissionsStatus accepts an optional fresh flag (web clients send it) that bypasses the denied backoff; older hosts strip it, older clients omit it. - A provider permission_denied reply invalidates the cached status. - Invalidate again after a TCC reset; stale in-flight probes get one follow-up probe instead of an overlapping helper. - Failed checks are shared for 2s and retried, not backed off like a denial. * fix(computer-use): gate macOS permission re-probes on app-level returns only (#34390) - Track did-resign-active/did-become-active instead of BrowserWindow blur/focus, so moving between Orca's own windows never re-launches the helper. - If the renderer's focus read reaches main before did-become-active, treat it as the return when a window is focused, so the first read after return is fresh. - Passive badges (setup-guide progress, feature-wall status) opt into the denied backoff with background: true; omitted flags (older clients, active setup screens) keep near-live reads with 2s reuse. Replaces the unreleased fresh flag. - Obsolete probes no longer record outcomes or advance the denied backoff. * test(computer-use): drop casts from permission IPC wiring test * fix(computer-use): keep remote permission reads near-live and count startup as away (#34390) - Web clients never forward background: browser focus can't activate the host, so remote setup surfaces get 2s reuse instead of a stale denial. - Seed the departed state when binding app activity, so a grant made during a slow startup is picked up on the first return. - Drop the mock-only sidecar invalidation test; keep gate constants private. * fix(computer-use): never serve a cached denial to foreground permission reads (#34390) - Active screens, web clients and older clients only reuse a denial by joining an in-flight probe; granted and error results keep the 2s reuse. - Revert the unreleased RPC background option so remote reads stay foreground. - Drop the IPC test half that exercised events production no longer listens to.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
ComputerUsePermissionStatus,
|
||||
ComputerUsePermissionStatusResult
|
||||
} from '../../shared/computer-use-permissions-types'
|
||||
import {
|
||||
bindPermissionStatusGateToAppActivity,
|
||||
createPermissionStatusProbeGate
|
||||
} from './macos-computer-use-permission-status-gate'
|
||||
|
||||
function statusWith(accessibility: ComputerUsePermissionStatus): ComputerUsePermissionStatusResult {
|
||||
return {
|
||||
platform: 'darwin',
|
||||
helperAppPath: '/Applications/Orca Computer Use.app',
|
||||
helperUnavailableReason: null,
|
||||
permissions: [
|
||||
{ id: 'accessibility', status: accessibility },
|
||||
{ id: 'screenshots', status: 'granted' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const denied = statusWith('not-granted')
|
||||
const granted = statusWith('granted')
|
||||
const background = { background: true }
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
const promise = new Promise<T>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('createPermissionStatusProbeGate', () => {
|
||||
it('stops relaunching the helper for passive readers while Accessibility stays denied (#34390)', async () => {
|
||||
let clock = 0
|
||||
const probe = vi.fn(async () => denied)
|
||||
const gate = createPermissionStatusProbeGate({ probe, now: () => clock })
|
||||
|
||||
// Three always-mounted setup-guide hooks read on every window focus.
|
||||
await Promise.all([gate.read(background), gate.read(background), gate.read(background)])
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
|
||||
for (let elapsed = 0; elapsed < 600_000; elapsed += 500) {
|
||||
clock += 500
|
||||
await gate.read(background)
|
||||
}
|
||||
// Backoff 10s, 20s, 40s, 80s, then 120s cap: 8 launches in 10 minutes instead of 1,200.
|
||||
expect(probe).toHaveBeenCalledTimes(8)
|
||||
|
||||
gate.noteAppReturn()
|
||||
await gate.read(background)
|
||||
expect(probe).toHaveBeenCalledTimes(8)
|
||||
})
|
||||
|
||||
it('re-probes once per return to the app so a grant or revocation shows immediately', async () => {
|
||||
const probe = vi.fn().mockResolvedValueOnce(denied).mockResolvedValueOnce(granted)
|
||||
const gate = createPermissionStatusProbeGate({ probe, now: () => 0 })
|
||||
|
||||
await expect(gate.read(background)).resolves.toBe(denied)
|
||||
gate.noteAppDeparture()
|
||||
gate.noteAppReturn()
|
||||
const reads = await Promise.all([gate.read(background), gate.read(background)])
|
||||
expect(reads).toEqual([granted, granted])
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
|
||||
probe.mockResolvedValueOnce(denied)
|
||||
gate.noteAppDeparture()
|
||||
gate.noteAppReturn()
|
||||
await expect(gate.read(background)).resolves.toBe(denied)
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('serves a fresh read when the renderer focus read beats did-become-active', async () => {
|
||||
let active = false
|
||||
const probe = vi.fn().mockResolvedValueOnce(denied).mockResolvedValueOnce(granted)
|
||||
const gate = createPermissionStatusProbeGate({ probe, now: () => 0 })
|
||||
gate.setAppActiveProbe(() => active)
|
||||
|
||||
await gate.read(background)
|
||||
gate.noteAppDeparture()
|
||||
await expect(gate.read(background)).resolves.toBe(denied)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
|
||||
active = true
|
||||
await expect(gate.read(background)).resolves.toBe(granted)
|
||||
gate.noteAppReturn()
|
||||
await expect(gate.read(background)).resolves.toBe(granted)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('counts the first app return after a startup spent inactive', async () => {
|
||||
const probe = vi.fn().mockResolvedValueOnce(denied).mockResolvedValueOnce(granted)
|
||||
const gate = createPermissionStatusProbeGate({ probe, now: () => 0 })
|
||||
let returnToApp = (): void => {}
|
||||
let active = false
|
||||
bindPermissionStatusGateToAppActivity(gate, {
|
||||
onDeparture: () => {},
|
||||
onReturn: (listener) => {
|
||||
returnToApp = listener
|
||||
},
|
||||
isAppActive: () => active
|
||||
})
|
||||
|
||||
await gate.read(background)
|
||||
active = true
|
||||
returnToApp()
|
||||
await expect(gate.read(background)).resolves.toBe(granted)
|
||||
})
|
||||
|
||||
it('never serves a finished denial to foreground readers, even on a quick return', async () => {
|
||||
let clock = 0
|
||||
const probe = vi.fn().mockResolvedValueOnce(denied).mockResolvedValueOnce(granted)
|
||||
const gate = createPermissionStatusProbeGate({ probe, now: () => clock })
|
||||
|
||||
await gate.read()
|
||||
// The user grants at 250ms and the browser returns at 500ms.
|
||||
clock += 500
|
||||
await expect(gate.read(background)).resolves.toBe(denied)
|
||||
const reads = await Promise.all([gate.read(), gate.read(), gate.read()])
|
||||
expect(reads).toEqual([granted, granted, granted])
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
|
||||
clock += 2_000 - 1
|
||||
await expect(gate.read()).resolves.toBe(granted)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('discards an invalidated in-flight probe without overlapping helpers', async () => {
|
||||
let clock = 0
|
||||
const first = deferred<ComputerUsePermissionStatusResult>()
|
||||
const probe = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue(denied)
|
||||
const gate = createPermissionStatusProbeGate({ probe, now: () => clock })
|
||||
|
||||
const obsolete = gate.read(background)
|
||||
gate.invalidate()
|
||||
const current = gate.read(background)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
|
||||
first.resolve(denied)
|
||||
await expect(obsolete).resolves.toBe(denied)
|
||||
await expect(current).resolves.toBe(denied)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Only the live probe counts toward the backoff: first step is 10s, not 20s.
|
||||
clock += 10_000
|
||||
await gate.read(background)
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('shares a failed check briefly instead of backing it off like a denial', async () => {
|
||||
let clock = 0
|
||||
const failure = new Error('Timed out checking permissions')
|
||||
const probe = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(granted)
|
||||
const gate = createPermissionStatusProbeGate({ probe, now: () => clock })
|
||||
|
||||
const results = await Promise.allSettled([gate.read(background), gate.read(background)])
|
||||
expect(results).toEqual([
|
||||
{ status: 'rejected', reason: failure },
|
||||
{ status: 'rejected', reason: failure }
|
||||
])
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
|
||||
clock += 2_000
|
||||
await expect(gate.read(background)).resolves.toBe(granted)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { ComputerUsePermissionStatusResult } from '../../shared/computer-use-permissions-types'
|
||||
|
||||
// Each macOS probe launches a helper that polls TCC for ~3.5s; denied answers only change on user action.
|
||||
const PERMISSION_STATUS_RESULT_REUSE_MS = 2_000
|
||||
const PERMISSION_STATUS_DENIED_MIN_BACKOFF_MS = 10_000
|
||||
const PERMISSION_STATUS_DENIED_MAX_BACKOFF_MS = 120_000
|
||||
|
||||
type ProbeOutcome =
|
||||
| { ok: true; result: ComputerUsePermissionStatusResult }
|
||||
| { ok: false; error: unknown }
|
||||
|
||||
type InFlightProbe = {
|
||||
epoch: number
|
||||
promise: Promise<ComputerUsePermissionStatusResult>
|
||||
followUp: Promise<ComputerUsePermissionStatusResult> | null
|
||||
}
|
||||
|
||||
export type PermissionStatusReadOptions = {
|
||||
// Local passive badges opt into the denied backoff; foreground reads never get a cached denial.
|
||||
background?: boolean
|
||||
}
|
||||
|
||||
export type PermissionStatusProbeGate = {
|
||||
read: (options?: PermissionStatusReadOptions) => Promise<ComputerUsePermissionStatusResult>
|
||||
noteAppDeparture: () => void
|
||||
noteAppReturn: () => void
|
||||
setAppActiveProbe: (isAppActive: () => boolean) => void
|
||||
invalidate: () => void
|
||||
}
|
||||
|
||||
export type AppActivitySource = {
|
||||
onDeparture: (listener: () => void) => void
|
||||
onReturn: (listener: () => void) => void
|
||||
isAppActive: () => boolean
|
||||
}
|
||||
|
||||
export function createPermissionStatusProbeGate(options: {
|
||||
probe: () => Promise<ComputerUsePermissionStatusResult>
|
||||
now?: () => number
|
||||
}): PermissionStatusProbeGate {
|
||||
const now = options.now ?? Date.now
|
||||
// A new epoch means the answer may have changed; results from older epochs are never served.
|
||||
let epoch = 0
|
||||
let departed = false
|
||||
let isAppActive = (): boolean => false
|
||||
let inFlight: InFlightProbe | null = null
|
||||
let cached: { epoch: number; outcome: ProbeOutcome; finishedAt: number } | null = null
|
||||
let reuseUntil = 0
|
||||
let deniedStreak = 0
|
||||
|
||||
const noteAppReturn = (): void => {
|
||||
if (departed) {
|
||||
departed = false
|
||||
epoch += 1
|
||||
}
|
||||
}
|
||||
|
||||
const recordOutcome = (probeEpoch: number, outcome: ProbeOutcome): void => {
|
||||
if (probeEpoch !== epoch) {
|
||||
return
|
||||
}
|
||||
const finishedAt = now()
|
||||
cached = { epoch: probeEpoch, outcome, finishedAt }
|
||||
if (!outcome.ok || !isMissingPermission(outcome.result)) {
|
||||
if (outcome.ok) {
|
||||
deniedStreak = 0
|
||||
}
|
||||
reuseUntil = finishedAt + PERMISSION_STATUS_RESULT_REUSE_MS
|
||||
return
|
||||
}
|
||||
deniedStreak += 1
|
||||
const backoff = PERMISSION_STATUS_DENIED_MIN_BACKOFF_MS * 2 ** (deniedStreak - 1)
|
||||
reuseUntil = finishedAt + Math.min(backoff, PERMISSION_STATUS_DENIED_MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
const startProbe = (): Promise<ComputerUsePermissionStatusResult> => {
|
||||
const probeEpoch = epoch
|
||||
const entry: InFlightProbe = {
|
||||
epoch: probeEpoch,
|
||||
promise: options.probe().then(
|
||||
(result) => {
|
||||
recordOutcome(probeEpoch, { ok: true, result })
|
||||
return result
|
||||
},
|
||||
(error: unknown) => {
|
||||
recordOutcome(probeEpoch, { ok: false, error })
|
||||
throw error
|
||||
}
|
||||
),
|
||||
followUp: null
|
||||
}
|
||||
inFlight = entry
|
||||
void entry.promise
|
||||
.finally(() => {
|
||||
if (inFlight === entry) {
|
||||
inFlight = null
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
return entry.promise
|
||||
}
|
||||
|
||||
const reuseLimit = (
|
||||
entry: { outcome: ProbeOutcome; finishedAt: number },
|
||||
readOptions: PermissionStatusReadOptions
|
||||
): number => {
|
||||
if (readOptions.background) {
|
||||
return reuseUntil
|
||||
}
|
||||
// Foreground readers may have just granted; only passive badges accept a finished denial.
|
||||
if (entry.outcome.ok && isMissingPermission(entry.outcome.result)) {
|
||||
return 0
|
||||
}
|
||||
return entry.finishedAt + PERMISSION_STATUS_RESULT_REUSE_MS
|
||||
}
|
||||
|
||||
const read = (
|
||||
readOptions: PermissionStatusReadOptions = {}
|
||||
): Promise<ComputerUsePermissionStatusResult> => {
|
||||
// The renderer's focus read can reach main before did-become-active does.
|
||||
if (departed && isAppActive()) {
|
||||
noteAppReturn()
|
||||
}
|
||||
const current = inFlight
|
||||
if (current) {
|
||||
if (current.epoch === epoch) {
|
||||
return current.promise
|
||||
}
|
||||
// Never run two helpers at once; re-probe once the stale one settles.
|
||||
current.followUp ??= current.promise.then(noop, noop).then(() => read(readOptions))
|
||||
return current.followUp
|
||||
}
|
||||
if (cached && cached.epoch === epoch) {
|
||||
if (now() < reuseLimit(cached, readOptions)) {
|
||||
const { outcome } = cached
|
||||
return outcome.ok ? Promise.resolve(outcome.result) : Promise.reject(outcome.error)
|
||||
}
|
||||
}
|
||||
return startProbe()
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
noteAppDeparture: () => {
|
||||
departed = true
|
||||
},
|
||||
noteAppReturn,
|
||||
setAppActiveProbe: (probe) => {
|
||||
isAppActive = probe
|
||||
},
|
||||
invalidate: () => {
|
||||
epoch += 1
|
||||
deniedStreak = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App-level activation only: moving between Orca's own windows can't change a TCC answer.
|
||||
export function bindPermissionStatusGateToAppActivity(
|
||||
gate: PermissionStatusProbeGate,
|
||||
activity: AppActivitySource
|
||||
): void {
|
||||
gate.setAppActiveProbe(activity.isAppActive)
|
||||
// Listeners attach after startup; a grant made while Orca was inactive must count on first return.
|
||||
if (!activity.isAppActive()) {
|
||||
gate.noteAppDeparture()
|
||||
}
|
||||
activity.onDeparture(() => gate.noteAppDeparture())
|
||||
activity.onReturn(() => gate.noteAppReturn())
|
||||
}
|
||||
|
||||
function noop(): void {}
|
||||
|
||||
function isMissingPermission(result: ComputerUsePermissionStatusResult): boolean {
|
||||
return (
|
||||
result.helperUnavailableReason === null &&
|
||||
result.permissions.some((permission) => permission.status === 'not-granted')
|
||||
)
|
||||
}
|
||||
|
||||
export const computerUsePermissionStatusGate = createPermissionStatusProbeGate({
|
||||
probe: async () => {
|
||||
const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
|
||||
return getComputerUsePermissionStatus()
|
||||
}
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import { normalizeComputerActionResult } from './computer-action-verification-no
|
||||
import { isComputerSidecarDiagnostic, logComputerDiagnostic } from './computer-sidecar-diagnostics'
|
||||
import { validateComputerSidecarPasteText } from './computer-sidecar-paste-validation'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
import { computerUsePermissionStatusGate } from './macos-computer-use-permission-status-gate'
|
||||
import { getForkedChildEntryPath } from '../forked-child-entry-path'
|
||||
|
||||
// Bundled beside orcad.js on servers and under out/main on desktop.
|
||||
@@ -255,6 +256,10 @@ class ComputerSidecarProcess {
|
||||
pending.resolve(message.result)
|
||||
return
|
||||
}
|
||||
if (message.error.code === 'permission_denied' && process.platform === 'darwin') {
|
||||
// The provider's own TCC check outranks any cached status.
|
||||
computerUsePermissionStatusGate.invalidate()
|
||||
}
|
||||
pending.reject(new RuntimeClientError(message.error.code, message.error.message))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type HoistedMocks = {
|
||||
app: EventEmitter | null
|
||||
handlers: Map<string, (...args: unknown[]) => unknown>
|
||||
focusedWindow: { current: object | null }
|
||||
probe: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted((): HoistedMocks => ({
|
||||
app: null,
|
||||
handlers: new Map(),
|
||||
focusedWindow: { current: null },
|
||||
probe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', async () => {
|
||||
const { EventEmitter: Emitter } = await import('node:events')
|
||||
mocks.app = new Emitter()
|
||||
return {
|
||||
app: mocks.app,
|
||||
BrowserWindow: { getFocusedWindow: () => mocks.focusedWindow.current },
|
||||
ipcMain: {
|
||||
handle: (name: string, handler: (...args: unknown[]) => unknown) =>
|
||||
mocks.handlers.set(name, handler)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../computer/macos-computer-use-permissions', () => ({
|
||||
getComputerUsePermissionStatus: mocks.probe
|
||||
}))
|
||||
|
||||
describe('registerComputerUsePermissionHandlers', () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
})
|
||||
|
||||
it('re-probes passive readers only after the app itself returns', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
mocks.probe.mockResolvedValue({
|
||||
platform: 'darwin',
|
||||
helperAppPath: '/Applications/Orca Computer Use.app',
|
||||
helperUnavailableReason: null,
|
||||
permissions: [
|
||||
{ id: 'accessibility', status: 'not-granted' },
|
||||
{ id: 'screenshots', status: 'granted' }
|
||||
]
|
||||
})
|
||||
const { registerComputerUsePermissionHandlers } = await import('./computer-use-permissions')
|
||||
registerComputerUsePermissionHandlers()
|
||||
const app = mocks.app!
|
||||
const readPassively = (): unknown =>
|
||||
mocks.handlers.get('computerUsePermissions:getStatus')!({}, { background: true })
|
||||
|
||||
mocks.focusedWindow.current = {}
|
||||
await readPassively()
|
||||
await readPassively()
|
||||
expect(mocks.probe).toHaveBeenCalledTimes(1)
|
||||
|
||||
app.emit('did-resign-active')
|
||||
app.emit('did-become-active')
|
||||
await readPassively()
|
||||
expect(mocks.probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,24 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import type {
|
||||
ComputerUsePermissionId,
|
||||
ComputerUsePermissionResetResult,
|
||||
ComputerUsePermissionSetupResult,
|
||||
ComputerUsePermissionStatusResult
|
||||
} from '../../shared/computer-use-permissions-types'
|
||||
import {
|
||||
bindPermissionStatusGateToAppActivity,
|
||||
computerUsePermissionStatusGate,
|
||||
type PermissionStatusReadOptions
|
||||
} from '../computer/macos-computer-use-permission-status-gate'
|
||||
|
||||
export function registerComputerUsePermissionHandlers(): void {
|
||||
if (process.platform === 'darwin') {
|
||||
bindPermissionStatusGateToAppActivity(computerUsePermissionStatusGate, {
|
||||
onDeparture: (listener) => app.on('did-resign-active', listener),
|
||||
onReturn: (listener) => app.on('did-become-active', listener),
|
||||
isAppActive: () => BrowserWindow.getFocusedWindow() !== null
|
||||
})
|
||||
}
|
||||
ipcMain.handle(
|
||||
'computerUsePermissions:openSetup',
|
||||
async (
|
||||
@@ -15,23 +27,27 @@ export function registerComputerUsePermissionHandlers(): void {
|
||||
): Promise<ComputerUsePermissionSetupResult> => {
|
||||
const { openComputerUsePermissions } =
|
||||
await import('../computer/macos-computer-use-permissions')
|
||||
computerUsePermissionStatusGate.invalidate()
|
||||
return openComputerUsePermissions(args?.id)
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'computerUsePermissions:getStatus',
|
||||
async (): Promise<ComputerUsePermissionStatusResult> => {
|
||||
const { getComputerUsePermissionStatus } =
|
||||
await import('../computer/macos-computer-use-permissions')
|
||||
return getComputerUsePermissionStatus()
|
||||
}
|
||||
(_event, args?: PermissionStatusReadOptions): Promise<ComputerUsePermissionStatusResult> =>
|
||||
computerUsePermissionStatusGate.read({ background: args?.background === true })
|
||||
)
|
||||
ipcMain.handle(
|
||||
'computerUsePermissions:reset',
|
||||
async (): Promise<ComputerUsePermissionResetResult> => {
|
||||
const { resetComputerUsePermissions } =
|
||||
await import('../computer/macos-computer-use-permissions')
|
||||
return resetComputerUsePermissions()
|
||||
computerUsePermissionStatusGate.invalidate()
|
||||
try {
|
||||
return await resetComputerUsePermissions()
|
||||
} finally {
|
||||
// Reads during the reset may have cached the pre-reset grants.
|
||||
computerUsePermissionStatusGate.invalidate()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ export const COMPUTER_METHODS = [
|
||||
handler: async (params) => {
|
||||
const { openComputerUsePermissions } =
|
||||
await import('../../../computer/macos-computer-use-permissions')
|
||||
const { computerUsePermissionStatusGate } =
|
||||
await import('../../../computer/macos-computer-use-permission-status-gate')
|
||||
computerUsePermissionStatusGate.invalidate()
|
||||
return openComputerUsePermissions(params.id)
|
||||
}
|
||||
}),
|
||||
@@ -63,9 +66,9 @@ export const COMPUTER_METHODS = [
|
||||
permission: 'workspace',
|
||||
params: ComputerPermissionsStatusParams,
|
||||
handler: async () => {
|
||||
const { getComputerUsePermissionStatus } =
|
||||
await import('../../../computer/macos-computer-use-permissions')
|
||||
return getComputerUsePermissionStatus()
|
||||
const { computerUsePermissionStatusGate } =
|
||||
await import('../../../computer/macos-computer-use-permission-status-gate')
|
||||
return computerUsePermissionStatusGate.read()
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ipcRenderer } from 'electron'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const computerUsePermissionsApi = {
|
||||
getStatus: () => ipcRenderer.invoke('computerUsePermissions:getStatus'),
|
||||
getStatus: (args?: { background?: boolean }) =>
|
||||
ipcRenderer.invoke('computerUsePermissions:getStatus', args),
|
||||
openSetup: (args?: { id?: string }) =>
|
||||
ipcRenderer.invoke('computerUsePermissions:openSetup', args),
|
||||
reset: () => ipcRenderer.invoke('computerUsePermissions:reset')
|
||||
|
||||
@@ -61,7 +61,8 @@ export type DeveloperPermissionsApi = {
|
||||
}
|
||||
|
||||
export type ComputerUsePermissionsApi = {
|
||||
getStatus: () => Promise<ComputerUsePermissionStatusResult>
|
||||
// Passive badges pass background to accept a backed-off denial instead of relaunching the helper.
|
||||
getStatus: (args?: { background?: boolean }) => Promise<ComputerUsePermissionStatusResult>
|
||||
openSetup: (args?: { id?: ComputerUsePermissionId }) => Promise<ComputerUsePermissionSetupResult>
|
||||
reset: () => Promise<ComputerUsePermissionResetResult>
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ function useComputerUsePermissionStatus(enabled: boolean): {
|
||||
const refresh = (): void => {
|
||||
setStatus((current) => ({ ...current, checking: true }))
|
||||
window.api.computerUsePermissions
|
||||
.getStatus()
|
||||
.getStatus({ background: true })
|
||||
.then((next) => {
|
||||
if (stale) {
|
||||
return
|
||||
|
||||
@@ -191,7 +191,9 @@ export function useSetupGuideProgress(
|
||||
}, [orderedGitRepos, settings, setupScriptProbeSignature, shouldRefreshCoreState])
|
||||
|
||||
const readComputerUsePermissions = useCallback(async (isStale: () => boolean): Promise<void> => {
|
||||
const status = await window.api.computerUsePermissions.getStatus().catch(() => null)
|
||||
const status = await window.api.computerUsePermissions
|
||||
.getStatus({ background: true })
|
||||
.catch(() => null)
|
||||
if (isStale()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ export function createComputerUsePermissionsApi(): NonNullable<
|
||||
Partial<PreloadApi>['computerUsePermissions']
|
||||
> {
|
||||
return {
|
||||
// Browser focus can't activate the host app, so remote reads never opt into the denied backoff.
|
||||
getStatus: () =>
|
||||
callRuntimeResult<ComputerUsePermissionStatusResult>(
|
||||
'computer.permissionsStatus',
|
||||
|
||||
@@ -718,7 +718,10 @@ describe('web UI preload API', () => {
|
||||
skills: [{ name: 'computer-use', installed: true }],
|
||||
scannedAt: 123
|
||||
})
|
||||
const permissionsStatus = await globals.window.api.computerUsePermissions.getStatus()
|
||||
// Passive local callers pass background; the browser can't activate the host, so it must not reach RPC.
|
||||
const permissionsStatus = await globals.window.api.computerUsePermissions.getStatus({
|
||||
background: true
|
||||
})
|
||||
expect(permissionsStatus.helperUnavailableReason).toBeNull()
|
||||
expect(permissionsStatus.permissions).toContainEqual({ id: 'accessibility', status: 'granted' })
|
||||
await expect(
|
||||
|
||||
Reference in New Issue
Block a user