Improve Computer Use permission setup (#2758)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
78249fd9cf
commit
3a40c62169
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,3 +23,7 @@ export type ComputerUsePermissionSetupResult = {
|
|||
permissions?: ComputerUsePermissionState[]
|
||||
nextStep?: string | null
|
||||
}
|
||||
|
||||
export type ComputerUsePermissionResetResult = ComputerUsePermissionStatusResult & {
|
||||
bundleId: string | null
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue