Files

594 lines
21 KiB
Swift

import AppKit
import ApplicationServices
import Foundation
enum ComputerUsePermissionTarget: String {
case accessibility
case screenRecording = "screen-recording"
}
struct ComputerUsePermissionStatus {
let accessibility: Bool
let screenRecording: Bool
var ok: Bool { accessibility && screenRecording }
var dictionary: [String: Any] {
["ok": ok, "accessibility": accessibility, "screenRecording": screenRecording]
}
}
enum ComputerUsePermissions {
/// Non-prompting check — safe to call on a timer / from HTTP polling.
static func status() -> ComputerUsePermissionStatus {
ComputerUsePermissionStatus(
accessibility: AXIsProcessTrusted(),
screenRecording: CGPreflightScreenCaptureAccess()
)
}
/// Prompting request — only call when the user explicitly clicks "Grant".
static func request(_ target: ComputerUsePermissionTarget) {
switch target {
case .accessibility:
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
case .screenRecording:
_ = CGRequestScreenCaptureAccess()
}
}
}
// MARK: - App delegate
@MainActor
final class PermissionSetupAppDelegate: NSObject, NSApplicationDelegate {
private var windowController: NSWindowController?
func applicationDidFinishLaunching(_ notification: Notification) {
let window = PermissionSetupWindow()
let vc = PermissionSetupViewController()
window.contentViewController = vc
let wc = NSWindowController(window: window)
windowController = wc
wc.showWindow(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
}
// MARK: - Window
private final class PermissionSetupWindow: NSWindow {
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 460, height: 600),
styleMask: [.titled, .closable, .fullSizeContentView],
backing: .buffered,
defer: false
)
title = "OpenWork Computer Use"
titleVisibility = .hidden
titlebarAppearsTransparent = true
isMovableByWindowBackground = true
backgroundColor = .clear
isOpaque = false
hasShadow = true
center()
}
}
// MARK: - View controller
@MainActor
final class PermissionSetupViewController: NSViewController {
private var axBadge = StatusBadge()
private var srBadge = StatusBadge()
private var axGrantButton: NSButton?
private var srGrantButton: NSButton?
private var timer: Timer?
init() { super.init(nibName: nil, bundle: nil) }
required init?(coder: NSCoder) { nil }
deinit { timer?.invalidate() }
override func loadView() {
// Liquid glass: NSVisualEffectView blending with content behind the window
let blur = NSVisualEffectView()
blur.material = .underWindowBackground
blur.blendingMode = .behindWindow
blur.state = .active
blur.wantsLayer = true
blur.layer?.cornerRadius = 12
blur.layer?.masksToBounds = true
view = blur
}
override func viewDidLoad() {
super.viewDidLoad()
buildLayout()
refresh()
timer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
guard let controller = self else { return }
Task { @MainActor in controller.refresh() }
}
}
private func buildLayout() {
let iconView = NSImageView()
iconView.image = NSApplication.shared.applicationIconImage
iconView.imageScaling = .scaleProportionallyUpOrDown
iconView.translatesAutoresizingMaskIntoConstraints = false
iconView.widthAnchor.constraint(equalToConstant: 52).isActive = true
iconView.heightAnchor.constraint(equalToConstant: 52).isActive = true
let titleField = textField("Computer Use Setup", size: 20, weight: .semibold)
titleField.alignment = .center
let subtitleField = wrappingField(
"Grant two permissions so agents can see and control apps in the background.",
size: 13
)
subtitleField.textColor = .secondaryLabelColor
subtitleField.alignment = .center
let header = vstack([iconView, titleField, subtitleField], spacing: 8, alignment: NSLayoutConstraint.Attribute.centerX)
let axCard = makeAccessibilityCard()
let srCard = makeScreenRecordingCard()
let doneBtn = NSButton(title: "Done — Return to OpenWork", target: self, action: #selector(done))
doneBtn.bezelStyle = .rounded
doneBtn.controlSize = .large
doneBtn.keyEquivalent = "\r"
let root = vstack([header, axCard, srCard, doneBtn], spacing: 14, alignment: NSLayoutConstraint.Attribute.leading)
root.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(root)
NSLayoutConstraint.activate([
root.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
root.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
root.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor, constant: 52),
root.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor, constant: -20),
root.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 10),
axCard.widthAnchor.constraint(equalTo: root.widthAnchor),
srCard.widthAnchor.constraint(equalTo: root.widthAnchor),
doneBtn.widthAnchor.constraint(equalTo: root.widthAnchor),
])
}
// MARK: Accessibility card
private func makeAccessibilityCard() -> NSView {
let step = StepCircle(number: "1")
let title = textField("Accessibility", size: 15, weight: .semibold)
let body = wrappingField(
"Allows agents to interact with UI controls, click buttons, and type text entirely in the background.",
size: 13
)
body.textColor = .secondaryLabelColor
let btn = NSButton(title: "Grant Accessibility", target: self, action: #selector(grantAccessibility))
btn.bezelStyle = .rounded
btn.controlSize = .regular
axGrantButton = btn
let top = hstack([step, title, springy(), axBadge], spacing: 8)
let inner = vstack([top, body, btn], spacing: 10, alignment: NSLayoutConstraint.Attribute.leading)
btn.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
return glassCard(inner)
}
// MARK: Screen Recording card
private func makeScreenRecordingCard() -> NSView {
let step = StepCircle(number: "2")
let title = textField("Screen Recording", size: 15, weight: .semibold)
let body = wrappingField(
"Lets agents see what is on screen. If macOS does not prompt automatically, drag the app icon below into the Screen Recording list.",
size: 13
)
body.textColor = .secondaryLabelColor
let dragFlow = makeDragFlowView()
let reqBtn = NSButton(title: "Request Screen Recording", target: self, action: #selector(requestScreenRecording))
reqBtn.bezelStyle = .rounded
reqBtn.controlSize = .regular
srGrantButton = reqBtn
let openBtn = NSButton(title: "Open Privacy & Security", target: self, action: #selector(openPrivacySecurity))
openBtn.bezelStyle = .rounded
openBtn.controlSize = .small
let top = hstack([step, title, springy(), srBadge], spacing: 8)
let inner = vstack([top, body, dragFlow, reqBtn, openBtn], spacing: 10, alignment: NSLayoutConstraint.Attribute.leading)
reqBtn.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
openBtn.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
dragFlow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
return glassCard(inner)
}
// MARK: Drag flow visualization
private func makeDragFlowView() -> NSView {
// Large draggable app icon
let iconView = DraggableAppIconView(frame: .zero)
iconView.image = NSApplication.shared.applicationIconImage
iconView.imageScaling = .scaleProportionallyUpOrDown
iconView.translatesAutoresizingMaskIntoConstraints = false
iconView.widthAnchor.constraint(equalToConstant: 56).isActive = true
iconView.heightAnchor.constraint(equalToConstant: 56).isActive = true
let dragHint = textField("Drag me", size: 10)
dragHint.textColor = .tertiaryLabelColor
dragHint.alignment = .center
let iconCol = vstack([iconView, dragHint], spacing: 5, alignment: NSLayoutConstraint.Attribute.centerX)
let arrow = textField("→", size: 20)
arrow.textColor = .tertiaryLabelColor
let dropZone = PrivacyDropZoneView()
dropZone.translatesAutoresizingMaskIntoConstraints = false
dropZone.widthAnchor.constraint(equalToConstant: 116).isActive = true
dropZone.heightAnchor.constraint(equalToConstant: 60).isActive = true
let row = hstack([iconCol, arrow, dropZone], spacing: 14)
row.alignment = NSLayoutConstraint.Attribute.centerY
row.distribution = NSStackView.Distribution.fill
let hint = wrappingField(
"Drag this icon into the Screen Recording list in Privacy & Security, then enable it.",
size: 11
)
hint.textColor = .secondaryLabelColor
hint.alignment = .center
let content = vstack([row, hint], spacing: 8, alignment: NSLayoutConstraint.Attribute.centerX)
content.translatesAutoresizingMaskIntoConstraints = false
let wrapper = InnerTintView()
wrapper.translatesAutoresizingMaskIntoConstraints = false
wrapper.addSubview(content)
NSLayoutConstraint.activate([
content.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
content.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor, constant: -12),
content.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor, constant: 12),
content.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor, constant: -12),
])
return wrapper
}
// MARK: Refresh
private func refresh() {
let status = ComputerUsePermissions.status()
axBadge.update(granted: status.accessibility)
srBadge.update(granted: status.screenRecording)
axGrantButton?.isEnabled = !status.accessibility
srGrantButton?.isEnabled = !status.screenRecording
}
// MARK: Actions
@objc private func grantAccessibility() {
ComputerUsePermissions.request(.accessibility)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in self?.refresh() }
}
@objc private func requestScreenRecording() {
ComputerUsePermissions.request(.screenRecording)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in self?.refresh() }
}
@objc private func openPrivacySecurity() {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") else { return }
NSWorkspace.shared.open(url)
}
@objc private func done() {
NSApplication.shared.terminate(nil)
}
// MARK: Builder helpers
private func glassCard(_ content: NSView) -> GlassCardView {
let card = GlassCardView()
card.addContent(content)
return card
}
private func textField(_ text: String, size: CGFloat, weight: NSFont.Weight = .regular) -> NSTextField {
let f = NSTextField(labelWithString: text)
f.font = .systemFont(ofSize: size, weight: weight)
return f
}
private func wrappingField(_ text: String, size: CGFloat) -> NSTextField {
let f = NSTextField(wrappingLabelWithString: text)
f.font = .systemFont(ofSize: size)
return f
}
private func vstack(_ views: [NSView], spacing: CGFloat, alignment: NSLayoutConstraint.Attribute = NSLayoutConstraint.Attribute.leading) -> NSStackView {
let sv = NSStackView(views: views)
sv.orientation = .vertical
sv.spacing = spacing
sv.alignment = alignment
return sv
}
private func hstack(_ views: [NSView], spacing: CGFloat) -> NSStackView {
let sv = NSStackView(views: views)
sv.orientation = .horizontal
sv.spacing = spacing
sv.alignment = NSLayoutConstraint.Attribute.centerY
return sv
}
private func springy() -> NSView {
let v = NSView()
v.setContentHuggingPriority(.defaultLow, for: .horizontal)
v.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return v
}
}
// MARK: - Glass card
/// NSView container with an NSVisualEffectView inside for the glass effect.
/// Uses updateLayer() so the border color responds to appearance changes.
final class GlassCardView: NSView {
private let blur: NSVisualEffectView
override init(frame: NSRect) {
blur = NSVisualEffectView()
blur.material = .contentBackground
blur.blendingMode = .withinWindow
blur.state = .active
blur.wantsLayer = true
blur.layer?.cornerRadius = 13
blur.layer?.masksToBounds = true
blur.translatesAutoresizingMaskIntoConstraints = false
super.init(frame: frame)
wantsLayer = true
layer?.cornerRadius = 14
addSubview(blur)
NSLayoutConstraint.activate([
blur.topAnchor.constraint(equalTo: topAnchor),
blur.bottomAnchor.constraint(equalTo: bottomAnchor),
blur.leadingAnchor.constraint(equalTo: leadingAnchor),
blur.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
required init?(coder: NSCoder) { nil }
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
layer?.borderWidth = 0.5
layer?.borderColor = NSColor.separatorColor.cgColor
}
func addContent(_ content: NSView) {
content.translatesAutoresizingMaskIntoConstraints = false
blur.addSubview(content)
NSLayoutConstraint.activate([
content.topAnchor.constraint(equalTo: blur.topAnchor, constant: 16),
content.bottomAnchor.constraint(equalTo: blur.bottomAnchor, constant: -16),
content.leadingAnchor.constraint(equalTo: blur.leadingAnchor, constant: 16),
content.trailingAnchor.constraint(equalTo: blur.trailingAnchor, constant: -16),
])
}
}
// MARK: - Status badge
/// Pill badge showing Granted/Needed with adaptive color.
@MainActor
final class StatusBadge: NSView {
private let dot = NSView()
private let label = NSTextField(labelWithString: "")
private var isGranted = false
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
layer?.cornerRadius = 10
dot.wantsLayer = true
dot.layer?.cornerRadius = 4
dot.translatesAutoresizingMaskIntoConstraints = false
dot.widthAnchor.constraint(equalToConstant: 8).isActive = true
dot.heightAnchor.constraint(equalToConstant: 8).isActive = true
label.font = .systemFont(ofSize: 12, weight: .medium)
label.translatesAutoresizingMaskIntoConstraints = false
let row = NSStackView(views: [dot, label])
row.orientation = .horizontal
row.spacing = 5
row.alignment = .centerY
row.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
NSLayoutConstraint.activate([
row.topAnchor.constraint(equalTo: topAnchor, constant: 4),
row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
}
required init?(coder: NSCoder) { nil }
func update(granted: Bool) {
isGranted = granted
applyColors()
label.stringValue = granted ? "Granted" : "Needed"
label.textColor = granted ? .systemGreen : .systemOrange
}
private func applyColors() {
layer?.backgroundColor = isGranted
? NSColor.systemGreen.withAlphaComponent(0.15).cgColor
: NSColor.systemOrange.withAlphaComponent(0.12).cgColor
dot.layer?.backgroundColor = isGranted
? NSColor.systemGreen.cgColor
: NSColor.systemOrange.cgColor
}
override var wantsUpdateLayer: Bool { true }
override func updateLayer() { applyColors() }
}
// MARK: - Step circle
private final class StepCircle: NSView {
init(number: String) {
super.init(frame: .zero)
wantsLayer = true
layer?.backgroundColor = NSColor.controlAccentColor.cgColor
layer?.cornerRadius = 11
translatesAutoresizingMaskIntoConstraints = false
widthAnchor.constraint(equalToConstant: 22).isActive = true
heightAnchor.constraint(equalToConstant: 22).isActive = true
let label = NSTextField(labelWithString: number)
label.font = .systemFont(ofSize: 12, weight: .semibold)
label.textColor = .white
label.alignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: centerXAnchor),
label.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
required init?(coder: NSCoder) { nil }
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
layer?.backgroundColor = NSColor.controlAccentColor.cgColor
}
}
// MARK: - Inner tint (drag flow background)
/// Subtle tinted background for the drag-flow visualization.
/// Adapts to light/dark appearance via updateLayer().
private final class InnerTintView: NSView {
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
layer?.cornerRadius = 10
}
required init?(coder: NSCoder) { nil }
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
let isDark = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
layer?.backgroundColor = isDark
? NSColor.white.withAlphaComponent(0.05).cgColor
: NSColor.black.withAlphaComponent(0.04).cgColor
}
}
// MARK: - Privacy drop zone
/// Dashed rounded rect showing where the user should drop the app icon.
final class PrivacyDropZoneView: NSView {
private let label: NSTextField
override init(frame: NSRect) {
label = NSTextField(labelWithString: "Screen Recording\nlist")
super.init(frame: frame)
label.font = .systemFont(ofSize: 11)
label.textColor = .tertiaryLabelColor
label.alignment = .center
label.maximumNumberOfLines = 2
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: centerXAnchor),
label.centerYAnchor.constraint(equalTo: centerYAnchor),
label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4),
label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4),
])
}
required init?(coder: NSCoder) { nil }
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
NSGraphicsContext.saveGraphicsState()
let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 8, yRadius: 8)
path.lineWidth = 1.5
path.setLineDash([6.0, 4.0], count: 2, phase: 0)
NSColor.tertiaryLabelColor.setStroke()
path.stroke()
NSGraphicsContext.restoreGraphicsState()
}
}
// MARK: - Draggable app icon
/// NSImageView that initiates a drag session carrying the app bundle URL,
/// so it can be dropped into System Settings > Privacy & Security > Screen Recording.
final class DraggableAppIconView: NSImageView, NSDraggingSource {
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
layer?.cornerRadius = 12
layer?.masksToBounds = true
addTrackingArea(NSTrackingArea(
rect: .zero,
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
owner: self,
userInfo: nil
))
}
required init?(coder: NSCoder) { nil }
override func mouseEntered(with event: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.12
animator().alphaValue = 0.75
}
}
override func mouseExited(with event: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.12
animator().alphaValue = 1.0
}
}
override func mouseDown(with event: NSEvent) {
guard let image else { return }
let appURL = Bundle.main.bundleURL
let item = NSPasteboardItem()
item.setString(appURL.absoluteString, forType: .fileURL)
let dragItem = NSDraggingItem(pasteboardWriter: item)
dragItem.setDraggingFrame(bounds, contents: image)
beginDraggingSession(with: [dragItem], event: event, source: self)
}
func draggingSession(
_ session: NSDraggingSession,
sourceOperationMaskFor context: NSDraggingContext
) -> NSDragOperation {
.copy
}
}