Improve Computer Use permission setup (#2758)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-25 17:54:47 -04:00 committed by GitHub
parent 78249fd9cf
commit 3a40c62169
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 746 additions and 152 deletions

View File

@ -2056,6 +2056,7 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
windowController = PermissionWindowController(
initialPermission: initialPermission,
terminateWhenDragAssistantCloses: initialPermission != nil
)
if let initialPermission {
@ -2067,6 +2068,10 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate {
}
}
func applicationDidBecomeActive(_ notification: Notification) {
windowController?.refreshPermissions()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
initialPermission == nil
}
@ -2074,9 +2079,11 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate {
private final class PermissionWindowController: NSWindowController {
private var dragAssistant: PermissionDragAssistantController?
private var dragAssistantPermission: PermissionKind?
private let initialPermission: PermissionKind?
private let terminateWhenDragAssistantCloses: Bool
convenience init(terminateWhenDragAssistantCloses: Bool = false) {
convenience init(initialPermission: PermissionKind? = nil, terminateWhenDragAssistantCloses: Bool = false) {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 315),
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
@ -2089,13 +2096,20 @@ private final class PermissionWindowController: NSWindowController {
window.backgroundColor = PermissionPalette.background
window.center()
window.isReleasedWhenClosed = false
self.init(window: window, terminateWhenDragAssistantCloses: terminateWhenDragAssistantCloses)
window.contentView = PermissionView(frame: window.contentView?.bounds ?? .zero) { [weak self] permission in
self?.showDragAssistant(for: permission)
}
self.init(window: window, initialPermission: initialPermission, terminateWhenDragAssistantCloses: terminateWhenDragAssistantCloses)
window.contentView = PermissionView(
frame: window.contentView?.bounds ?? .zero,
showDragAssistant: { [weak self] permission in
self?.showDragAssistant(for: permission)
},
close: { [weak self] in
self?.closePermissionWindow()
}
)
}
init(window: NSWindow?, terminateWhenDragAssistantCloses: Bool) {
init(window: NSWindow?, initialPermission: PermissionKind?, terminateWhenDragAssistantCloses: Bool) {
self.initialPermission = initialPermission
self.terminateWhenDragAssistantCloses = terminateWhenDragAssistantCloses
super.init(window: window)
}
@ -2106,9 +2120,13 @@ private final class PermissionWindowController: NSWindowController {
private func showDragAssistant(for permission: PermissionKind) {
dragAssistant?.close()
dragAssistantPermission = permission
dragAssistant = PermissionDragAssistantController(
permission: permission,
fallbackVisibleFrame: window?.screen?.visibleFrame,
onRefreshPermissions: { [weak self] in
self?.refreshPermissions()
},
onClose: { [weak self] in
if self?.terminateWhenDragAssistantCloses == true {
NSApp.terminate(nil)
@ -2118,13 +2136,57 @@ private final class PermissionWindowController: NSWindowController {
dragAssistant?.showWhenReady()
}
private func closeDragAssistant() {
dragAssistant?.close()
dragAssistant = nil
dragAssistantPermission = nil
}
private func completeDragAssistant() {
guard let dragAssistant else {
dragAssistantPermission = nil
if terminateWhenDragAssistantCloses {
NSApp.terminate(nil)
}
return
}
self.dragAssistant = nil
dragAssistantPermission = nil
dragAssistant.complete()
}
private func closePermissionWindow() {
// Why: the floating assistant is a separate retained window controller and
// can keep the helper app alive after the main permission window closes.
closeDragAssistant()
window?.close()
}
func openPermission(_ permission: PermissionKind) {
permission.requestAndOpenSettings()
showDragAssistant(for: permission)
}
func refreshPermissions() {
if let initialPermission, initialPermission.isGranted {
// Why: targeted permission helpers should finish once the requested
// grant lands, even if other Computer Use permissions remain unset.
completeDragAssistant()
return
}
if dragAssistantPermission?.isGranted == true {
// Why: after one grant in full setup, the remaining missing permission
// needs fresh guidance instead of the old assistant's instructions.
closeDragAssistant()
}
if PermissionKind.allCases.allSatisfy(\.isGranted) {
closeDragAssistant()
}
(window?.contentView as? PermissionView)?.refreshPermissions()
}
}
private enum PermissionKind {
private enum PermissionKind: CaseIterable {
case accessibility
case screenshots
@ -2148,6 +2210,42 @@ private enum PermissionKind {
}
}
var title: String {
switch self {
case .accessibility:
"Accessibility"
case .screenshots:
"Screenshots"
}
}
var detail: String {
switch self {
case .accessibility:
"Read and control app interfaces"
case .screenshots:
"Capture windows for visual state"
}
}
var icon: NSImage {
switch self {
case .accessibility:
NSImage(systemSymbolName: "figure", accessibilityDescription: "Accessibility") ?? NSImage()
case .screenshots:
NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "Screen Recording") ?? NSImage()
}
}
var isGranted: Bool {
switch self {
case .accessibility:
accessibilityTrusted()
case .screenshots:
screenCaptureTrusted()
}
}
func requestAndOpenSettings() {
switch self {
case .accessibility:
@ -2162,9 +2260,13 @@ private enum PermissionKind {
private final class PermissionView: NSView {
private let appURL = Bundle.main.bundleURL
private let showDragAssistant: (PermissionKind) -> Void
private let close: () -> Void
private var contentStack: NSStackView?
private var contentConstraints: [NSLayoutConstraint] = []
init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void) {
init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void, close: @escaping () -> Void) {
self.showDragAssistant = showDragAssistant
self.close = close
super.init(frame: frameRect)
wantsLayer = true
layer?.backgroundColor = PermissionPalette.background.cgColor
@ -2176,6 +2278,10 @@ private final class PermissionView: NSView {
}
private func build() {
NSLayoutConstraint.deactivate(contentConstraints)
contentStack?.removeFromSuperview()
contentConstraints = []
let stack = NSStackView()
stack.orientation = .vertical
stack.alignment = .leading
@ -2183,6 +2289,7 @@ private final class PermissionView: NSView {
stack.distribution = .gravityAreas
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
contentStack = stack
let icon = NSImageView(image: NSWorkspace.shared.icon(forFile: appURL.path))
icon.imageScaling = .scaleProportionallyUpOrDown
@ -2192,9 +2299,12 @@ private final class PermissionView: NSView {
icon.heightAnchor.constraint(equalToConstant: 58)
])
let title = label("Enable Orca Computer Use", size: 22, weight: .bold)
let missingPermissions = PermissionKind.allCases.filter { !$0.isGranted }
let ready = missingPermissions.isEmpty
let title = label(ready ? "Computer Use is Ready" : "Enable Orca Computer Use", size: 22, weight: .bold)
let subtitle = label(
"Grant permissions so Orca can use apps when you ask.",
ready ? "Orca can use local apps when you ask." : "Grant permissions so Orca can use apps when you ask.",
size: 12,
weight: .regular
)
@ -2211,41 +2321,32 @@ private final class PermissionView: NSView {
header.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
subtitle.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -10).isActive = true
stack.addArrangedSubview(permissionRow(
icon: NSImage(systemSymbolName: "figure", accessibilityDescription: "Accessibility"),
title: "Accessibility",
detail: "Read and control app interfaces",
buttonTitle: "Allow"
) {
PermissionKind.accessibility.requestAndOpenSettings()
self.showDragAssistant(.accessibility)
})
if ready {
stack.addArrangedSubview(doneButton())
} else {
for permission in missingPermissions {
stack.addArrangedSubview(permissionRow(permission: permission) { [weak self] in
permission.requestAndOpenSettings()
self?.showDragAssistant(permission)
})
}
}
stack.addArrangedSubview(permissionRow(
icon: NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "Screen Recording"),
title: "Screenshots",
detail: "Capture windows for visual state",
buttonTitle: "Allow"
) {
PermissionKind.screenshots.requestAndOpenSettings()
self.showDragAssistant(.screenshots)
})
NSLayoutConstraint.activate([
contentConstraints = [
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 18),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -18),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 22),
stack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -20)
])
]
NSLayoutConstraint.activate(contentConstraints)
}
private func permissionRow(
icon: NSImage?,
title: String,
detail: String,
buttonTitle: String,
action: @escaping () -> Void
) -> NSView {
func refreshPermissions() {
// Why: TCC grants can change in System Settings while this window stays open.
build()
}
private func permissionRow(permission: PermissionKind, action: @escaping () -> Void) -> NSView {
let row = NSView()
row.wantsLayer = true
row.layer?.cornerRadius = 14
@ -2254,13 +2355,13 @@ private final class PermissionView: NSView {
row.layer?.backgroundColor = PermissionPalette.card.cgColor
row.translatesAutoresizingMaskIntoConstraints = false
let iconView = NSImageView(image: icon ?? NSImage())
let iconView = NSImageView(image: permission.icon)
iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 30, weight: .regular)
iconView.contentTintColor = .controlAccentColor
iconView.translatesAutoresizingMaskIntoConstraints = false
let titleLabel = label(title, size: 13, weight: .bold)
let detailLabel = label(detail, size: 11, weight: .regular)
let titleLabel = label(permission.title, size: 13, weight: .bold)
let detailLabel = label(permission.detail, size: 11, weight: .regular)
detailLabel.textColor = PermissionPalette.secondaryText
let textStack = NSStackView(views: [titleLabel, detailLabel])
textStack.orientation = .vertical
@ -2268,7 +2369,7 @@ private final class PermissionView: NSView {
textStack.spacing = 4
textStack.translatesAutoresizingMaskIntoConstraints = false
let button = NSButton(title: buttonTitle, target: nil, action: nil)
let button = NSButton(title: "Allow", target: nil, action: nil)
button.bezelStyle = .rounded
button.controlSize = .regular
button.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
@ -2278,8 +2379,8 @@ private final class PermissionView: NSView {
.foregroundColor: NSColor.white,
.font: NSFont.systemFont(ofSize: 13, weight: .semibold)
]
button.attributedTitle = NSAttributedString(string: buttonTitle, attributes: buttonTitleAttributes)
button.attributedAlternateTitle = NSAttributedString(string: buttonTitle, attributes: buttonTitleAttributes)
button.attributedTitle = NSAttributedString(string: "Allow", attributes: buttonTitleAttributes)
button.attributedAlternateTitle = NSAttributedString(string: "Allow", attributes: buttonTitleAttributes)
let target = ButtonTarget(action)
button.target = target
button.action = #selector(ButtonTarget.run)
@ -2306,6 +2407,29 @@ private final class PermissionView: NSView {
return row
}
private func doneButton() -> NSView {
let button = NSButton(title: "Done", target: nil, action: nil)
button.bezelStyle = .rounded
button.controlSize = .regular
button.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
button.contentTintColor = .white
button.bezelColor = .controlAccentColor
let buttonTitleAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: NSColor.white,
.font: NSFont.systemFont(ofSize: 13, weight: .semibold)
]
button.attributedTitle = NSAttributedString(string: "Done", attributes: buttonTitleAttributes)
button.attributedAlternateTitle = NSAttributedString(string: "Done", attributes: buttonTitleAttributes)
let target = ButtonTarget(close)
button.target = target
button.action = #selector(ButtonTarget.run)
objc_setAssociatedObject(button, "orca-action", target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
button.translatesAutoresizingMaskIntoConstraints = false
button.widthAnchor.constraint(greaterThanOrEqualToConstant: 82).isActive = true
button.heightAnchor.constraint(equalToConstant: 32).isActive = true
return button
}
private func label(_ text: String, size: CGFloat, weight: NSFont.Weight) -> NSTextField {
let label = NSTextField(labelWithString: text)
label.font = NSFont.systemFont(ofSize: size, weight: weight)
@ -2323,11 +2447,19 @@ private final class PermissionDragAssistantController: NSWindowController {
}
private let fallbackVisibleFrame: NSRect?
private let onRefreshPermissions: () -> Void
private let onClose: () -> Void
private var hasSeenSettingsWindow = false
private var followTimer: Timer?
private var isDismissed = false
private var scheduledShowWorkItems: [DispatchWorkItem] = []
convenience init(permission: PermissionKind, fallbackVisibleFrame: NSRect?, onClose: @escaping () -> Void) {
convenience init(
permission: PermissionKind,
fallbackVisibleFrame: NSRect?,
onRefreshPermissions: @escaping () -> Void,
onClose: @escaping () -> Void
) {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 390, height: 92),
styleMask: [.borderless, .nonactivatingPanel],
@ -2347,15 +2479,25 @@ private final class PermissionDragAssistantController: NSWindowController {
window.isMovable = false
window.isMovableByWindowBackground = false
window.hasShadow = true
self.init(window: window, fallbackVisibleFrame: fallbackVisibleFrame, onClose: onClose)
window.contentView = PermissionDragAssistantView(permission: permission, appURL: Bundle.main.bundleURL) { [weak self, weak window] in
window?.close()
self?.onClose()
self.init(
window: window,
fallbackVisibleFrame: fallbackVisibleFrame,
onRefreshPermissions: onRefreshPermissions,
onClose: onClose
)
window.contentView = PermissionDragAssistantView(permission: permission, appURL: Bundle.main.bundleURL) { [weak self] in
self?.dismissFromCloseButton()
}
}
init(window: NSWindow?, fallbackVisibleFrame: NSRect?, onClose: @escaping () -> Void) {
init(
window: NSWindow?,
fallbackVisibleFrame: NSRect?,
onRefreshPermissions: @escaping () -> Void,
onClose: @escaping () -> Void
) {
self.fallbackVisibleFrame = fallbackVisibleFrame
self.onRefreshPermissions = onRefreshPermissions
self.onClose = onClose
super.init(window: window)
}
@ -2365,31 +2507,52 @@ private final class PermissionDragAssistantController: NSWindowController {
}
func showWhenReady() {
guard !isDismissed else { return }
startFollowingSettingsWindow()
schedulePositionAndShow()
}
override func close() {
isDismissed = true
scheduledShowWorkItems.forEach { $0.cancel() }
scheduledShowWorkItems.removeAll()
followTimer?.invalidate()
followTimer = nil
super.close()
}
private func dismissFromCloseButton() {
// Why: closing the NSWindow directly skips this controller's timer cleanup,
// letting the assistant reappear while System Settings remains visible.
complete()
}
func complete() {
close()
onClose()
}
private func schedulePositionAndShow() {
scheduledShowWorkItems.forEach { $0.cancel() }
scheduledShowWorkItems.removeAll()
let delays = [0.12, 0.25, 0.4, 0.65, 0.95, 1.35, 1.8, 2.5]
for (index, delay) in delays.enumerated() {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self, self.window?.isVisible != true else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self, !self.isDismissed, self.window?.isVisible != true else { return }
if let settingsWindow = self.systemSettingsWindowState(), settingsWindow.isVisible {
self.positionNearSettingsWindow(settingsWindow.frame)
guard !self.isDismissed else { return }
self.showWindow(nil)
self.window?.orderFrontRegardless()
} else if index == delays.count - 1 && self.systemSettingsIsFrontmost() {
self.positionFallback()
guard !self.isDismissed else { return }
self.showWindow(nil)
self.window?.orderFrontRegardless()
}
}
scheduledShowWorkItems.append(workItem)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
}
@ -2397,12 +2560,18 @@ private final class PermissionDragAssistantController: NSWindowController {
followTimer?.invalidate()
followTimer = Timer.scheduledTimer(withTimeInterval: 0.35, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.syncVisibilityWithSettingsWindow()
guard let self, !self.isDismissed else { return }
self.syncVisibilityWithSettingsWindow()
}
}
}
private func syncVisibilityWithSettingsWindow() {
guard !isDismissed else {
followTimer?.invalidate()
followTimer = nil
return
}
guard let window else {
followTimer?.invalidate()
followTimer = nil
@ -2418,10 +2587,14 @@ private final class PermissionDragAssistantController: NSWindowController {
// Why: System Settings can stay visible on one display while the user
// works on another; follow actual occlusion instead of app focus.
if let settingsWindow, settingsWindow.isVisible {
onRefreshPermissions()
guard !isDismissed else { return }
positionNearSettingsWindow(settingsWindow.frame)
guard !isDismissed else { return }
if !window.isVisible {
showWindow(nil)
}
guard !isDismissed else { return }
window.orderFrontRegardless()
} else if window.isVisible {
window.orderOut(nil)

View File

@ -1,16 +1,40 @@
import { execFileSync, spawn, spawnSync } from 'child_process'
import { mkdtemp, readFile, rm, stat } from 'fs/promises'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { openComputerUsePermissions } from './macos-computer-use-permissions'
import {
openComputerUsePermissions,
resetComputerUsePermissions
} from './macos-computer-use-permissions'
const resolveHelperAppPathMock = vi.hoisted(() => vi.fn())
const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn())
vi.mock('child_process', () => ({
execFileSync: vi.fn(),
spawn: vi.fn(() => ({ unref: vi.fn() })),
spawn: vi.fn(() => {
const child = {
stdout: { on: vi.fn(), setEncoding: vi.fn() },
stderr: { on: vi.fn(), setEncoding: vi.fn() },
on: vi.fn((event: string, callback: (status: number) => void) => {
if (event === 'close') {
queueMicrotask(() => callback(0))
}
return child
}),
unref: vi.fn()
}
return child
}),
spawnSync: vi.fn()
}))
vi.mock('fs/promises', () => ({
mkdtemp: vi.fn(),
readFile: vi.fn(),
rm: vi.fn(),
stat: vi.fn()
}))
vi.mock('./macos-native-provider-paths', () => ({
resolveMacOSComputerUseAppPath: resolveHelperAppPathMock,
resolveMacOSComputerUseExecutablePath: resolveHelperExecutablePathMock
@ -23,11 +47,17 @@ describe('openComputerUsePermissions', () => {
vi.mocked(spawn).mockClear()
vi.mocked(spawnSync).mockClear()
vi.mocked(execFileSync).mockReset()
vi.mocked(mkdtemp).mockReset()
vi.mocked(readFile).mockReset()
vi.mocked(rm).mockReset()
vi.mocked(stat).mockReset()
resolveHelperAppPathMock.mockReset()
resolveHelperExecutablePathMock.mockReset()
resolveHelperExecutablePathMock.mockReturnValue(
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos'
)
vi.mocked(mkdtemp).mockResolvedValue('/tmp/orca-computer-use-permissions-test')
vi.mocked(stat).mockResolvedValue({} as Awaited<ReturnType<typeof stat>>)
mockPermissionStatus('{"accessibility":"granted","screenshots":"granted"}')
setPlatform('darwin')
})
@ -36,10 +66,10 @@ describe('openComputerUsePermissions', () => {
setPlatform(originalPlatform)
})
it('does not launch the setup helper when all permissions are granted', () => {
it('does not launch the setup helper when all permissions are granted', async () => {
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
expect(openComputerUsePermissions()).toEqual({
await expect(openComputerUsePermissions()).resolves.toEqual({
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
permissionId: undefined,
@ -51,14 +81,18 @@ describe('openComputerUsePermissions', () => {
],
nextStep: null
})
expect(spawn).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalledWith(
'/usr/bin/open',
['-n', '/Applications/Orca Computer Use.app', '--args', '--permissions'],
{ detached: true, stdio: 'ignore' }
)
})
it('launches the helper app in permissions mode', () => {
it('launches the helper app in permissions mode', async () => {
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}')
expect(openComputerUsePermissions()).toEqual({
await expect(openComputerUsePermissions()).resolves.toEqual({
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
permissionId: undefined,
@ -70,15 +104,14 @@ describe('openComputerUsePermissions', () => {
],
nextStep: 'Grant Screen Recording to Orca Computer Use, then retry get-app-state.'
})
expect(spawn).toHaveBeenCalledTimes(1)
expect(spawnSync).toHaveBeenCalledWith(
'/usr/bin/pkill',
['-f', 'orca-computer-use-macos --permission'],
['-f', 'orca-computer-use-macos[[:space:]]+--permission([[:space:]]|$)'],
{ stdio: 'ignore' }
)
expect(spawnSync).toHaveBeenCalledWith(
'/usr/bin/pkill',
['-f', 'orca-computer-use-macos --permissions'],
['-f', 'orca-computer-use-macos[[:space:]]+--permissions([[:space:]]|$)'],
{ stdio: 'ignore' }
)
expect(spawn).toHaveBeenCalledWith(
@ -88,11 +121,11 @@ describe('openComputerUsePermissions', () => {
)
})
it('launches a targeted permission helper flow', () => {
it('launches a targeted permission helper flow', async () => {
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
mockPermissionStatus('{"accessibility":"not-granted","screenshots":"not-granted"}')
expect(openComputerUsePermissions('accessibility')).toEqual({
await expect(openComputerUsePermissions('accessibility')).resolves.toEqual({
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
permissionId: 'accessibility',
@ -111,10 +144,33 @@ describe('openComputerUsePermissions', () => {
)
})
it('returns a no-op result on non-macOS platforms', () => {
it('launches a targeted permission helper even when that permission is already granted', async () => {
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}')
await expect(openComputerUsePermissions('accessibility')).resolves.toEqual({
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
permissionId: 'accessibility',
openedSettings: true,
launchedHelper: true,
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'not-granted' }
],
nextStep: 'Grant Screen Recording to Orca Computer Use, then retry get-app-state.'
})
expect(spawn).toHaveBeenCalledWith(
'/usr/bin/open',
['-n', '/Applications/Orca Computer Use.app', '--args', '--permission', 'accessibility'],
{ detached: true, stdio: 'ignore' }
)
})
it('returns a no-op result on non-macOS platforms', async () => {
setPlatform('linux')
expect(openComputerUsePermissions()).toEqual({
await expect(openComputerUsePermissions()).resolves.toEqual({
platform: 'linux',
helperAppPath: null,
permissionId: undefined,
@ -129,27 +185,56 @@ describe('openComputerUsePermissions', () => {
expect(spawn).not.toHaveBeenCalled()
})
it('throws when the helper app is missing on macOS', () => {
it('throws when the helper app is missing on macOS', async () => {
resolveHelperAppPathMock.mockReturnValue(null)
expect(() => openComputerUsePermissions()).toThrow('Orca Computer Use.app was not found')
await expect(openComputerUsePermissions()).rejects.toThrow(
'Orca Computer Use.app was not found'
)
})
it('throws when the helper executable is missing during setup', () => {
it('throws when the helper executable is missing during setup', async () => {
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
resolveHelperExecutablePathMock.mockReturnValue(null)
expect(() => openComputerUsePermissions('accessibility')).toThrow(
await expect(openComputerUsePermissions('accessibility')).rejects.toThrow(
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos was not found'
)
})
it('reads permission status through the helper app executable', async () => {
it('wraps permission status helper launch failures', async () => {
const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
const child = {
stdout: { on: vi.fn(), setEncoding: vi.fn() },
stderr: { on: vi.fn(), setEncoding: vi.fn() },
on: vi.fn((event: string, callback: (error: Error) => void) => {
if (event === 'error') {
queueMicrotask(() => callback(new Error('spawn ENOENT /private/path')))
}
return child
}),
unref: vi.fn()
}
vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ReturnType<typeof spawn>)
await expect(getComputerUsePermissionStatus()).rejects.toMatchObject({
name: 'RuntimeClientError',
code: 'accessibility_error',
message: 'Could not check permissions: failed to launch helper'
})
expect(rm).toHaveBeenCalledWith('/tmp/orca-computer-use-permissions-test', {
recursive: true,
force: true
})
})
it('reads permission status through the helper app identity', async () => {
const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}')
expect(getComputerUsePermissionStatus()).toEqual({
await expect(getComputerUsePermissionStatus()).resolves.toEqual({
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
helperUnavailableReason: null,
@ -158,18 +243,72 @@ describe('openComputerUsePermissions', () => {
{ id: 'screenshots', status: 'not-granted' }
]
})
expect(spawn).toHaveBeenCalledWith(
'/usr/bin/open',
[
'-n',
'/Applications/Orca Computer Use.app',
'--args',
'--permission-status-file',
'/tmp/orca-computer-use-permissions-test/status.json'
],
{ stdio: ['ignore', 'pipe', 'pipe'] }
)
expect(spawnSync).not.toHaveBeenCalled()
expect(readFile).toHaveBeenCalledWith(
'/tmp/orca-computer-use-permissions-test/status.json',
'utf8'
)
expect(rm).toHaveBeenCalledWith('/tmp/orca-computer-use-permissions-test', {
recursive: true,
force: true
})
})
it('resets stale macOS TCC grants for the helper bundle id', async () => {
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
vi.mocked(readFile)
.mockResolvedValueOnce('{"accessibility":"granted","screenshots":"granted"}')
.mockResolvedValueOnce('{"accessibility":"not-granted","screenshots":"not-granted"}')
vi.mocked(execFileSync).mockReturnValueOnce('com.example.orca.computer-use\n')
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType<typeof spawnSync>)
await expect(resetComputerUsePermissions()).resolves.toEqual({
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
helperUnavailableReason: null,
bundleId: 'com.example.orca.computer-use',
permissions: [
{ id: 'accessibility', status: 'not-granted' },
{ id: 'screenshots', status: 'not-granted' }
]
})
expect(execFileSync).toHaveBeenCalledWith(
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos',
['--permission-status'],
'/usr/libexec/PlistBuddy',
[
'-c',
'Print :CFBundleIdentifier',
'/Applications/Orca Computer Use.app/Contents/Info.plist'
],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
)
expect(spawnSync).toHaveBeenCalledWith(
'/usr/bin/tccutil',
['reset', 'Accessibility', 'com.example.orca.computer-use'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
)
expect(spawnSync).toHaveBeenCalledWith(
'/usr/bin/tccutil',
['reset', 'ScreenCapture', 'com.example.orca.computer-use'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
)
})
it('returns unavailable permission status when the helper app is missing on macOS', async () => {
const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
resolveHelperAppPathMock.mockReturnValue(null)
expect(getComputerUsePermissionStatus()).toEqual({
await expect(getComputerUsePermissionStatus()).resolves.toEqual({
platform: 'darwin',
helperAppPath: null,
helperUnavailableReason: 'Orca Computer Use.app was not found',
@ -183,8 +322,8 @@ describe('openComputerUsePermissions', () => {
})
function mockPermissionStatus(json: string): void {
vi.mocked(spawnSync).mockReturnValue({} as ReturnType<typeof spawnSync>)
vi.mocked(execFileSync).mockReturnValue(json)
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType<typeof spawnSync>)
vi.mocked(readFile).mockResolvedValue(json)
}
function setPlatform(platform: NodeJS.Platform): void {

View File

@ -1,4 +1,8 @@
import { execFileSync, spawn, spawnSync } from 'child_process'
import { mkdtemp, readFile, rm, stat } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { setTimeout as delay } from 'timers/promises'
import { RuntimeClientError } from './runtime-client-error'
import {
resolveMacOSComputerUseAppPath,
@ -6,14 +10,23 @@ import {
} from './macos-native-provider-paths'
import type {
ComputerUsePermissionId,
ComputerUsePermissionResetResult,
ComputerUsePermissionSetupResult,
ComputerUsePermissionStatus,
ComputerUsePermissionStatusResult
} from '../../shared/computer-use-permissions-types'
const DEFAULT_COMPUTER_USE_BUNDLE_ID = 'com.stablyai.orca.computer-use'
export function openComputerUsePermissions(
permissionId?: ComputerUsePermissionId
): ComputerUsePermissionSetupResult {
): Promise<ComputerUsePermissionSetupResult> {
return openComputerUsePermissionsAsync(permissionId)
}
async function openComputerUsePermissionsAsync(
permissionId?: ComputerUsePermissionId
): Promise<ComputerUsePermissionSetupResult> {
if (process.platform !== 'darwin') {
return {
platform: process.platform,
@ -33,7 +46,7 @@ export function openComputerUsePermissions(
if (!helperAppPath) {
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
const status = getComputerUsePermissionStatus()
const status = await getComputerUsePermissionStatus()
if (status.helperUnavailableReason) {
throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason)
}
@ -70,16 +83,64 @@ export function openComputerUsePermissions(
}
}
function closeExistingPermissionHelpers(): void {
spawnSync('/usr/bin/pkill', ['-f', 'orca-computer-use-macos --permission'], {
stdio: 'ignore'
})
spawnSync('/usr/bin/pkill', ['-f', 'orca-computer-use-macos --permissions'], {
stdio: 'ignore'
})
export function resetComputerUsePermissions(): Promise<ComputerUsePermissionResetResult> {
return resetComputerUsePermissionsAsync()
}
export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusResult {
async function resetComputerUsePermissionsAsync(): Promise<ComputerUsePermissionResetResult> {
if (process.platform !== 'darwin') {
return {
platform: process.platform,
helperAppPath: null,
helperUnavailableReason: null,
bundleId: null,
permissions: [
{ id: 'accessibility', status: 'unsupported' },
{ id: 'screenshots', status: 'unsupported' }
]
}
}
const helperAppPath = resolveMacOSComputerUseAppPath()
if (!helperAppPath) {
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
const status = await getComputerUsePermissionStatus()
if (status.helperUnavailableReason) {
throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason)
}
const bundleId = readComputerUseBundleId(helperAppPath)
closeExistingPermissionHelpers()
resetTccPermission('Accessibility', bundleId)
resetTccPermission('ScreenCapture', bundleId)
return {
...(await getComputerUsePermissionStatus()),
bundleId
}
}
function closeExistingPermissionHelpers(): void {
// Why: status probes use --permission-status-file and must not be killed
// while setup helpers are being replaced.
const setupHelperPatterns = [
'orca-computer-use-macos[[:space:]]+--permission([[:space:]]|$)',
'orca-computer-use-macos[[:space:]]+--permissions([[:space:]]|$)'
]
for (const pattern of setupHelperPatterns) {
spawnSync('/usr/bin/pkill', ['-f', pattern], {
stdio: 'ignore'
})
}
}
export function getComputerUsePermissionStatus(): Promise<ComputerUsePermissionStatusResult> {
return getComputerUsePermissionStatusAsync()
}
async function getComputerUsePermissionStatusAsync(): Promise<ComputerUsePermissionStatusResult> {
if (process.platform !== 'darwin') {
return {
platform: process.platform,
@ -105,7 +166,7 @@ export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusRes
)
}
const raw = readPermissionStatusFromHelperExecutable(executablePath)
const raw = await readPermissionStatusFromHelperApp(helperAppPath)
return {
platform: process.platform,
@ -133,16 +194,111 @@ function createUnavailablePermissionStatus(
}
}
function readPermissionStatusFromHelperExecutable(
executablePath: string
): Partial<Record<ComputerUsePermissionId, ComputerUsePermissionStatus>> {
// Why: launching the nested helper via LaunchServices can make TCC evaluate
// Orca.app as responsible; the signed helper executable owns this grant.
const output = execFileSync(executablePath, ['--permission-status'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
async function readPermissionStatusFromHelperApp(
helperAppPath: string
): Promise<Partial<Record<ComputerUsePermissionId, ComputerUsePermissionStatus>>> {
const tempDir = await mkdtemp(join(tmpdir(), 'orca-computer-use-permissions-'))
const statusPath = join(tempDir, 'status.json')
try {
// Why: TCC status must be checked through the helper app identity. Directly
// execing the binary can inherit the parent app's already-granted context.
await launchPermissionStatusHelper(helperAppPath, statusPath)
for (let attempt = 0; attempt < 50; attempt++) {
if (await fileExists(statusPath)) {
const output = await readFile(statusPath, 'utf8')
return JSON.parse(output) as Partial<
Record<ComputerUsePermissionId, ComputerUsePermissionStatus>
>
}
await delay(100)
}
throw new RuntimeClientError('accessibility_error', 'Timed out checking permissions')
} finally {
await rm(tempDir, { recursive: true, force: true })
}
}
function launchPermissionStatusHelper(helperAppPath: string, statusPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const launch = spawn(
'/usr/bin/open',
['-n', helperAppPath, '--args', '--permission-status-file', statusPath],
{
stdio: ['ignore', 'pipe', 'pipe']
}
)
let stdout = ''
let stderr = ''
launch.stdout?.setEncoding('utf8')
launch.stderr?.setEncoding('utf8')
launch.stdout?.on('data', (chunk) => {
stdout += chunk
})
launch.stderr?.on('data', (chunk) => {
stderr += chunk
})
launch.on('error', () => {
reject(
new RuntimeClientError(
'accessibility_error',
'Could not check permissions: failed to launch helper'
)
)
})
launch.on('close', (status) => {
if (status === 0) {
resolve()
return
}
const detail = stderr.trim() || stdout.trim() || `exit ${status ?? 'unknown'}`
reject(
new RuntimeClientError('accessibility_error', `Could not check permissions: ${detail}`)
)
})
})
return JSON.parse(output) as Partial<Record<ComputerUsePermissionId, ComputerUsePermissionStatus>>
}
async function fileExists(path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}
function readComputerUseBundleId(helperAppPath: string): string {
const infoPlistPath = join(helperAppPath, 'Contents', 'Info.plist')
try {
const bundleId = execFileSync(
'/usr/libexec/PlistBuddy',
['-c', 'Print :CFBundleIdentifier', infoPlistPath],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}
).trim()
return bundleId || DEFAULT_COMPUTER_USE_BUNDLE_ID
} catch {
return DEFAULT_COMPUTER_USE_BUNDLE_ID
}
}
function resetTccPermission(service: string, bundleId: string): void {
// Why: macOS keeps TCC rows after uninstall; users need an explicit way to
// clear stale grants or denials for the helper's stable bundle identity.
const result = spawnSync('/usr/bin/tccutil', ['reset', service, bundleId], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
if (result.status === 0) {
return
}
const detail =
result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? 'unknown'}`
throw new RuntimeClientError('accessibility_error', `Could not reset ${service}: ${detail}`)
}
function nextPermissionStep(

View File

@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import type {
ComputerUsePermissionId,
ComputerUsePermissionResetResult,
ComputerUsePermissionSetupResult,
ComputerUsePermissionStatusResult
} from '../../shared/computer-use-permissions-types'
@ -25,4 +26,12 @@ export function registerComputerUsePermissionHandlers(): void {
return getComputerUsePermissionStatus()
}
)
ipcMain.handle(
'computerUsePermissions:reset',
async (): Promise<ComputerUsePermissionResetResult> => {
const { resetComputerUsePermissions } =
await import('../computer/macos-computer-use-permissions')
return resetComputerUsePermissions()
}
)
}

View File

@ -205,6 +205,7 @@ import type {
} from '../shared/developer-permissions-types'
import type {
ComputerUsePermissionId,
ComputerUsePermissionResetResult,
ComputerUsePermissionSetupResult,
ComputerUsePermissionStatusResult
} from '../shared/computer-use-permissions-types'
@ -1341,6 +1342,7 @@ export type PreloadApi = {
openSetup: (args?: {
id?: ComputerUsePermissionId
}) => Promise<ComputerUsePermissionSetupResult>
reset: () => Promise<ComputerUsePermissionResetResult>
}
shell: {
openPath: (path: string) => Promise<void>

View File

@ -1468,7 +1468,8 @@ const api = {
computerUsePermissions: {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('computerUsePermissions:getStatus'),
openSetup: (args?: { id?: string }): Promise<unknown> =>
ipcRenderer.invoke('computerUsePermissions:openSetup', args)
ipcRenderer.invoke('computerUsePermissions:openSetup', args),
reset: (): Promise<unknown> => ipcRenderer.invoke('computerUsePermissions:reset')
},
shell: {

View File

@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react'
import { Terminal } from 'lucide-react'
import { RefreshCw, Terminal } from 'lucide-react'
import { IntegrationStatusPill } from '../integration-status-pill'
import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal'
import { Button } from '../ui/button'
@ -128,9 +128,11 @@ export function AgentSkillSetupPanel({
type="button"
variant="ghost"
size="sm"
className="gap-1.5"
onClick={() => void onRecheck()}
disabled={loading}
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
Re-check
</Button>
) : null}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
Accessibility,
Camera,
@ -26,6 +26,7 @@ import {
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { Button } from '../ui/button'
import { Badge } from '../ui/badge'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
export { COMPUTER_USE_PANE_SEARCH_ENTRIES } from './computer-use-search'
@ -75,6 +76,10 @@ export function ComputerUsePane(): React.JSX.Element {
const [states, setStates] = useState<ComputerUsePermissionState[]>([])
const [loading, setLoading] = useState(true)
const [pendingId, setPendingId] = useState<ComputerUsePermissionId | null>(null)
const [resetting, setResetting] = useState(false)
// Why: reset changes OS permission state, so older status probes must not overwrite it.
const resettingRef = useRef(false)
const permissionOperationSequence = useRef(0)
const [helperUnavailableReason, setHelperUnavailableReason] = useState<string | null>(null)
const {
installed: computerUseSkillDetected,
@ -89,20 +94,57 @@ export function ComputerUsePane(): React.JSX.Element {
() => new Map(states.map((state) => [state.id, state.status] as const)),
[states]
)
const grantedCount = PERMISSIONS.filter(
(permission) => stateById.get(permission.id) === 'granted'
).length
const allGranted = grantedCount === PERMISSIONS.length
const checking = loading && states.length === 0
const setupUnavailable = helperUnavailableReason !== null
const resetAccessDisabled =
resetting || loading || states.length === 0 || pendingId !== null || setupUnavailable
const summaryTitle = checking
? 'Checking Computer Use access.'
: setupUnavailable
? 'Computer Use is unavailable.'
: allGranted
? 'Computer Use is ready.'
: 'Finish setup to use local apps.'
const summaryDescription = checking
? 'Orca is checking macOS privacy permissions for the Computer Use helper.'
: setupUnavailable
? `Computer Use permissions are unavailable because ${helperUnavailableReason}.`
: allGranted
? 'Agents can inspect and operate app windows when you ask.'
: `${PERMISSIONS.length - grantedCount} permission${
PERMISSIONS.length - grantedCount === 1 ? '' : 's'
} required before agents can operate app windows.`
const refresh = useCallback(async (): Promise<void> => {
if (resettingRef.current) {
return
}
const operationId = ++permissionOperationSequence.current
setLoading(true)
try {
const result = await window.api.computerUsePermissions.getStatus()
if (operationId !== permissionOperationSequence.current) {
return
}
setPlatform(result.platform)
setStates(result.permissions)
setHelperUnavailableReason(result.helperUnavailableReason)
} catch (error) {
if (operationId !== permissionOperationSequence.current) {
return
}
toast.error(
error instanceof Error ? error.message : 'Could not load Computer Use permissions'
)
} finally {
setLoading(false)
if (operationId === permissionOperationSequence.current) {
setLoading(false)
}
}
}, [])
@ -127,7 +169,11 @@ export function ComputerUsePane(): React.JSX.Element {
if (result.launchedHelper) {
toast.message('Opened macOS Privacy & Security')
} else {
toast.message('Computer Use permissions are only required on macOS')
toast.message(
result.platform === 'darwin'
? 'Computer Use setup is already complete'
: 'Computer Use permissions are only required on macOS'
)
}
} catch (error) {
toast.error(
@ -138,75 +184,129 @@ export function ComputerUsePane(): React.JSX.Element {
}
}
const resetAccess = async (): Promise<void> => {
if (resettingRef.current) {
return
}
resettingRef.current = true
const operationId = ++permissionOperationSequence.current
setResetting(true)
try {
const result = await window.api.computerUsePermissions.reset()
if (operationId !== permissionOperationSequence.current) {
return
}
setPlatform(result.platform)
setStates(result.permissions)
setHelperUnavailableReason(result.helperUnavailableReason)
toast.message('Reset Computer Use access')
} catch (error) {
if (operationId !== permissionOperationSequence.current) {
return
}
toast.error(
error instanceof Error ? error.message : 'Could not reset Computer Use permissions'
)
} finally {
if (operationId === permissionOperationSequence.current) {
resettingRef.current = false
setResetting(false)
setLoading(false)
}
}
}
const isMac = platform === null || platform === 'darwin'
return (
<div className="space-y-5">
{isMac ? (
<>
<div className="flex items-start justify-between gap-4 rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-4 rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<ShieldCheck className="size-4" />
Allow Orca to use local apps when you ask.
{summaryTitle}
{allGranted ? (
<Badge
variant="outline"
className="border-emerald-500/30 text-emerald-700 dark:text-emerald-300"
>
Ready
</Badge>
) : null}
</div>
<p className="text-xs text-muted-foreground">
Computer Use needs macOS privacy permissions before agents can inspect and operate
app windows.
</p>
{helperUnavailableReason ? (
<p className="text-xs text-muted-foreground">
Computer Use permissions are unavailable because {helperUnavailableReason}.
</p>
) : null}
<p className="text-xs text-muted-foreground">{summaryDescription}</p>
</div>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => void refresh()}>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
disabled={resetting}
onClick={() => void refresh()}
>
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<div className="divide-y divide-border/60 rounded-lg border border-border/60">
{PERMISSIONS.map((permission) => {
const status = stateById.get(permission.id)
const pending = pendingId === permission.id
<div className="space-y-2">
<div className="divide-y divide-border/60 rounded-lg border border-border/60">
{PERMISSIONS.map((permission) => {
const status = stateById.get(permission.id)
const pending = pendingId === permission.id
return (
<div
key={permission.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 text-muted-foreground">{permission.icon}</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{permission.label}</span>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${statusClass(
status
)}`}
>
{statusLabel(status)}
</span>
return (
<div
key={permission.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 text-muted-foreground">{permission.icon}</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{permission.label}</span>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${statusClass(
status
)}`}
>
{statusLabel(status)}
</span>
</div>
<p className="text-xs text-muted-foreground">{permission.description}</p>
</div>
<p className="text-xs text-muted-foreground">{permission.description}</p>
</div>
<div className="flex w-28 shrink-0 justify-end">
<Button
variant="outline"
size="sm"
disabled={
resetting ||
pending ||
status === 'unsupported' ||
helperUnavailableReason !== null
}
onClick={() => void openPermission(permission.id)}
className="gap-1.5"
>
<ExternalLink className="size-3.5" />
Open
</Button>
</div>
</div>
<Button
variant="outline"
size="sm"
disabled={
pending || status === 'unsupported' || helperUnavailableReason !== null
}
onClick={() => void openPermission(permission.id)}
className="shrink-0 gap-1.5"
>
<ExternalLink className="size-3.5" />
{pending ? 'Opening...' : 'Open'}
</Button>
</div>
)
})}
)
})}
</div>
<button
type="button"
disabled={resetAccessDisabled}
onClick={() => void resetAccess()}
className="ml-auto mr-4 block w-28 text-right text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
{resetting ? 'Resetting access...' : 'Reset access'}
</button>
</div>
</>
) : null}

View File

@ -1700,6 +1700,14 @@ function createComputerUsePermissionsApi(): NonNullable<
openedSettings: false,
launchedHelper: false,
nextStep: 'Computer-use permissions are managed on the Orca server.'
}),
reset: () =>
Promise.resolve({
platform: getBrowserPlatform(),
helperAppPath: null,
helperUnavailableReason: 'web_client',
bundleId: null,
permissions: []
})
}
}

View File

@ -23,3 +23,7 @@ export type ComputerUsePermissionSetupResult = {
permissions?: ComputerUsePermissionState[]
nextStep?: string | null
}
export type ComputerUsePermissionResetResult = ComputerUsePermissionStatusResult & {
bundleId: string | null
}