Files
clawdbot/apps/macos/Sources/Clawdis/AppMain.swift
2025-12-05 23:18:47 +01:00

798 lines
28 KiB
Swift

import AppKit
import ApplicationServices
import AsyncXPCConnection
import ClawdisIPC
import Foundation
import OSLog
import CoreGraphics
@preconcurrency import ScreenCaptureKit
import VideoToolbox
import ServiceManagement
import SwiftUI
import UserNotifications
private let serviceName = "com.steipete.clawdis.xpc"
private let pauseDefaultsKey = "clawdis.pauseEnabled"
// MARK: - App model
@MainActor
final class AppState: ObservableObject {
@Published var isPaused: Bool {
didSet { UserDefaults.standard.set(isPaused, forKey: pauseDefaultsKey) }
}
@Published var defaultSound: String {
didSet { UserDefaults.standard.set(defaultSound, forKey: "clawdis.defaultSound") }
}
@Published var launchAtLogin: Bool {
didSet { Task { AppStateStore.updateLaunchAtLogin(enabled: launchAtLogin) } }
}
@Published var onboardingSeen: Bool {
didSet { UserDefaults.standard.set(onboardingSeen, forKey: "clawdis.onboardingSeen") }
}
init() {
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.defaultSound = UserDefaults.standard.string(forKey: "clawdis.defaultSound") ?? ""
self.launchAtLogin = SMAppService.mainApp.status == .enabled
self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
}
}
@MainActor
enum AppStateStore {
static let shared = AppState()
static var isPausedFlag: Bool { UserDefaults.standard.bool(forKey: pauseDefaultsKey) }
static var defaultSound: String { UserDefaults.standard.string(forKey: "clawdis.defaultSound") ?? "" }
static func updateLaunchAtLogin(enabled: Bool) {
if enabled {
try? SMAppService.mainApp.register()
} else {
try? SMAppService.mainApp.unregister()
}
}
}
// MARK: - XPC service protocol
@objc protocol ClawdisXPCProtocol {
func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void)
}
// MARK: - XPC service implementation
final class ClawdisXPCService: NSObject, ClawdisXPCProtocol {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "xpc")
func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void) {
let logger = logger
Task.detached(priority: nil) { @Sendable in
do {
let request = try JSONDecoder().decode(Request.self, from: data)
let response = try await Self.process(request: request, notifier: NotificationManager(), logger: logger)
let encoded = try JSONEncoder().encode(response)
reply(encoded, nil)
} catch {
logger.error("Failed to handle XPC request: \(error.localizedDescription, privacy: .public)")
let resp = Response(ok: false, message: "decode/handle error: \(error.localizedDescription)")
reply(try? JSONEncoder().encode(resp), error)
}
}
}
private static func process(request: Request, notifier: NotificationManager, logger: Logger) async throws -> Response {
let paused = await MainActor.run { AppStateStore.isPausedFlag }
if paused {
return Response(ok: false, message: "clawdis paused")
}
switch request {
case let .notify(title, body, sound):
let chosenSound: String
if let sound { chosenSound = sound } else { chosenSound = await MainActor.run { AppStateStore.defaultSound } }
let ok = await notifier.send(title: title, body: body, sound: chosenSound)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
case let .ensurePermissions(caps, interactive):
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
let missing = statuses.filter { !$0.value }.map { $0.key.rawValue }
let ok = missing.isEmpty
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
return Response(ok: ok, message: msg)
case .status:
return Response(ok: true, message: "ready")
case let .screenshot(displayID, windowID, _):
let authorized = await PermissionManager.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
if let data = await Screenshotter.capture(displayID: displayID, windowID: windowID) {
return Response(ok: true, payload: data)
}
return Response(ok: false, message: "screenshot failed")
case let .runShell(command, cwd, env, timeoutSec, needsSR):
if needsSR {
let authorized = await PermissionManager.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
}
return await ShellRunner.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
}
}
}
// MARK: - Notification manager
@MainActor
struct NotificationManager {
func send(title: String, body: String, sound: String?) async -> Bool {
let center = UNUserNotificationCenter.current()
let status = await center.notificationSettings()
if status.authorizationStatus == .notDetermined {
let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted != true { return false }
} else if status.authorizationStatus != .authorized {
return false
}
let content = UNMutableNotificationContent()
content.title = title
content.body = body
if let soundName = sound, !soundName.isEmpty {
content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName))
}
let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
do {
try await center.add(req)
return true
} catch {
return false
}
}
}
// MARK: - Permission manager (minimal stub)
enum PermissionManager {
@MainActor
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
var results: [Capability: Bool] = [:]
for cap in caps {
switch cap {
case .notifications:
let center = UNUserNotificationCenter.current()
let status = await center.notificationSettings()
if status.authorizationStatus == .notDetermined && interactive {
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
let post = await center.notificationSettings()
results[cap] = post.authorizationStatus == .authorized
} else {
results[cap] = status.authorizationStatus == .authorized
}
case .accessibility:
// Accessing AX APIs must be on main thread.
let trusted = AXIsProcessTrusted()
results[cap] = trusted
if interactive && !trusted {
_ = AXIsProcessTrustedWithOptions(nil)
}
case .screenRecording:
let granted = ScreenRecordingProbe.isAuthorized()
if interactive && !granted {
await ScreenRecordingProbe.requestAuthorization()
}
results[cap] = ScreenRecordingProbe.isAuthorized()
}
}
return results
}
@MainActor
static func status(_ caps: [Capability] = Capability.allCases) async -> [Capability: Bool] {
var results: [Capability: Bool] = [:]
for cap in caps {
switch cap {
case .notifications:
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
results[cap] = settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional
case .accessibility:
results[cap] = AXIsProcessTrusted()
case .screenRecording:
if #available(macOS 10.15, *) {
results[cap] = CGPreflightScreenCaptureAccess()
} else {
results[cap] = true
}
}
}
return results
}
}
enum ScreenRecordingProbe {
static func isAuthorized() -> Bool {
if #available(macOS 10.15, *) {
return CGPreflightScreenCaptureAccess()
}
return true
}
@MainActor
static func requestAuthorization() async {
if #available(macOS 10.15, *) {
_ = CGRequestScreenCaptureAccess()
}
}
}
// MARK: - Screenshot
enum Screenshotter {
@MainActor
static func capture(displayID: UInt32?, windowID: UInt32?) async -> Data? {
guard let content = try? await SCShareableContent.current else { return nil }
let targetDisplay: SCDisplay?
if let displayID {
targetDisplay = content.displays.first(where: { $0.displayID == displayID })
} else {
targetDisplay = content.displays.first
}
let filter: SCContentFilter
if let windowID, let win = content.windows.first(where: { $0.windowID == windowID }) {
filter = SCContentFilter(desktopIndependentWindow: win)
} else if let display = targetDisplay {
filter = SCContentFilter(display: display, excludingWindows: [])
} else {
return nil
}
let config = SCStreamConfiguration()
if let display = targetDisplay {
config.width = display.width
config.height = display.height
}
config.scalesToFit = true
config.colorSpaceName = CGColorSpace.displayP3
let stream = SCStream(filter: filter, configuration: config, delegate: nil)
let grabber = FrameGrabber()
try? stream.addStreamOutput(grabber, type: .screen, sampleHandlerQueue: DispatchQueue(label: "com.steipete.clawdis.sshot"))
do {
try await stream.startCapture()
let data = await grabber.awaitPNG()
try? await stream.stopCapture()
return data
} catch {
return nil
}
}
}
final class FrameGrabber: NSObject, SCStreamOutput {
private var continuation: CheckedContinuation<Data?, Never>?
private var delivered = false
func awaitPNG() async -> Data? {
await withCheckedContinuation { cont in
self.continuation = cont
}
}
nonisolated func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
guard outputType == .screen else { return }
if delivered { return }
guard let imageBuffer = sampleBuffer.imageBuffer else { return }
var cgImage: CGImage?
let result = VTCreateCGImageFromCVPixelBuffer(imageBuffer, options: nil, imageOut: &cgImage)
guard result == noErr, let cgImage else { return }
let rep = NSBitmapImageRep(cgImage: cgImage)
guard let data = rep.representation(using: .png, properties: [:]) else { return }
delivered = true
continuation?.resume(returning: data)
continuation = nil
}
}
// MARK: - Shell runner
enum ShellRunner {
static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response {
guard !command.isEmpty else { return Response(ok: false, message: "empty command") }
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = command
if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) }
if let env { process.environment = env }
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
} catch {
return Response(ok: false, message: "failed to start: \(error.localizedDescription)")
}
let waitTask = Task.detached { () -> (Int32, Data, Data) in
process.waitUntilExit()
let out = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let err = stderrPipe.fileHandleForReading.readDataToEndOfFile()
return (process.terminationStatus, out, err)
}
if let timeout, timeout > 0 {
let nanos = UInt64(timeout * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
if process.isRunning {
process.terminate()
return Response(ok: false, message: "timeout")
}
}
let (status, out, err) = await waitTask.value
let combined = out.isEmpty ? err : out
return Response(ok: status == 0, message: status == 0 ? nil : "exit \(status)", payload: combined)
}
}
// MARK: - App + menu UI
@main
struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@StateObject private var state: AppState
init() {
_state = StateObject(wrappedValue: AppStateStore.shared)
}
var body: some Scene {
MenuBarExtra { menuContent } label: { LobsterStatusLabel(isPaused: state.isPaused) }
.menuBarExtraStyle(.menu)
Settings {
SettingsView(state: state)
.frame(width: 420, height: 320)
}
}
@ViewBuilder
private var menuContent: some View {
Toggle(isOn: $state.isPaused) {
Text(state.isPaused ? "Clawdis Paused" : "Pause Clawdis")
}
Divider()
Button("Test Notification") {
Task { _ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil) }
}
Divider()
Button("Permissions…") { PermissionsSheetController.shared.show(state: state) }
Button("Quit") { NSApplication.shared.terminate(nil) }
}
}
private struct LobsterStatusLabel: View {
var isPaused: Bool
var body: some View {
let imageView: Image = {
if let img = NSImage(named: "LobsterTemplate") {
img.isTemplate = true
return Image(nsImage: img).renderingMode(.template)
}
return Image(systemName: "antenna.radiowaves.left.and.right")
}()
return imageView.foregroundStyle(isPaused ? .secondary : .primary)
}
}
final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate {
private var listener: NSXPCListener?
private var state: AppState?
@MainActor
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
state = AppStateStore.shared
startListener()
}
@MainActor
private func startListener() {
guard state != nil else { return }
let listener = NSXPCListener(machServiceName: serviceName)
listener.delegate = self
listener.resume()
self.listener = listener
}
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
let interface = NSXPCInterface(with: ClawdisXPCProtocol.self)
connection.exportedInterface = interface
connection.exportedObject = ClawdisXPCService()
connection.resume()
return true
}
}
// MARK: - Settings UI
struct SettingsView: View {
@ObservedObject var state: AppState
@State private var permStatus: [Capability: Bool] = [:]
@State private var loadingPerms = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if !state.onboardingSeen {
Label("Complete onboarding to finish setup", systemImage: "sparkles")
.foregroundColor(.accentColor)
}
Toggle(isOn: $state.isPaused) {
Text("Pause Clawdis (disables notifications & privileged actions)")
}
Toggle(isOn: $state.launchAtLogin) {
Text("Launch at login")
}
HStack {
Text("Default sound")
Spacer()
Picker("Sound", selection: $state.defaultSound) {
Text("None").tag("")
Text("Glass").tag("Glass")
Text("Basso").tag("Basso")
Text("Ping").tag("Ping")
}
.labelsHidden()
.frame(width: 140)
}
Divider()
Text("Permissions")
.font(.headline)
PermissionStatusList(status: permStatus, refresh: refreshPerms)
Button("Show Onboarding") {
OnboardingController.shared.show()
}
Spacer()
HStack {
Spacer()
Text("Clawdis Companion")
.foregroundColor(.secondary)
}
}
.padding()
.task { await refreshPerms() }
}
@MainActor
private func refreshPerms() async {
guard !loadingPerms else { return }
loadingPerms = true
permStatus = await PermissionManager.status()
loadingPerms = false
}
}
struct PermissionStatusList: View {
let status: [Capability: Bool]
let refresh: () async -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
row(label: "Notifications", cap: .notifications, action: requestNotifications)
row(label: "Accessibility", cap: .accessibility) {
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
}
row(label: "Screen Recording", cap: .screenRecording) {
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording")
}
Button("Refresh status") { Task { await refresh() } }
.font(.footnote)
}
}
private func row(label: String, cap: Capability, action: @escaping () -> Void) -> some View {
let ok = status[cap] ?? false
return HStack {
Circle()
.fill(ok ? Color.green : Color.red)
.frame(width: 10, height: 10)
Text(label)
Spacer()
Button(ok ? "Granted" : "Open Settings", action: action)
.disabled(ok)
}
}
private func requestNotifications() {
Task {
let center = UNUserNotificationCenter.current()
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
await refresh()
}
}
private func openSettings(_ path: String) {
if let url = URL(string: path) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Permissions window stub
@MainActor
final class PermissionsSheetController {
static let shared = PermissionsSheetController()
private var window: NSWindow?
func show(state: AppState) {
if let window {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let hosting = NSHostingController(rootView: PermissionsView())
let window = NSWindow(contentViewController: hosting)
window.title = "Permissions"
window.setContentSize(NSSize(width: 360, height: 220))
window.styleMask = [.titled, .closable, .miniaturizable]
window.isReleasedWhenClosed = false
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
self.window = window
}
}
struct PermissionsView: View {
@State private var notificationStatus: String = "Unknown"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Grant the permissions below so Clawdis can help.")
if AppStateStore.isPausedFlag {
Text("Clawdis is paused. Unpause to enable actions.")
.foregroundColor(.orange)
}
Divider()
HStack {
Text("Notifications")
Spacer()
Text(notificationStatus).foregroundColor(.secondary)
Button("Request") { requestNotifications() }
}
HStack {
Text("Accessibility")
Spacer()
Button("Open Settings") { openSettings(path: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") }
}
HStack {
Text("Screen Recording")
Spacer()
Button("Open Settings") { openSettings(path: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") }
}
Spacer()
Text("Tip: run 'clawdis-mac ensure-permissions --interactive' from terminal to trigger prompts.")
.font(.footnote)
.foregroundColor(.secondary)
}
.padding()
.task { await refreshNotificationStatus() }
}
private func requestNotifications() {
Task {
let center = UNUserNotificationCenter.current()
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
await refreshNotificationStatus()
}
}
@MainActor
private func refreshNotificationStatus() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .notDetermined: notificationStatus = "Not determined"
case .denied: notificationStatus = "Denied"
case .authorized, .provisional, .ephemeral: notificationStatus = "Authorized"
@unknown default: notificationStatus = "Unknown"
}
}
private func openSettings(path: String) {
if let url = URL(string: path) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Onboarding (VibeTunnel-style, multi-step)
@MainActor
final class OnboardingController {
static let shared = OnboardingController()
private var window: NSWindow?
func show() {
if let window {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let hosting = NSHostingController(rootView: OnboardingView())
let window = NSWindow(contentViewController: hosting)
window.title = "Welcome to Clawdis"
window.setContentSize(NSSize(width: 520, height: 420))
window.styleMask = [.titled, .closable]
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
self.window = window
}
func close() {
window?.close()
window = nil
}
}
struct OnboardingView: View {
@State private var stepIndex = 0
@State private var permStatus: [Capability: Bool] = [:]
@State private var copied = false
private var steps: [OnboardingStep] {
[
.init(title: "Welcome aboard", detail: "Clawdis is your macOS companion for notifications and privileged agent actions.", accent: "sparkles"),
.init(title: "Grant permissions", detail: "Enable Notifications, Accessibility, and Screen Recording so actions succeed.", accent: "lock.shield", showsPermissions: true),
.init(title: "Install the CLI", detail: "Make the helper available to scripts via a quick symlink.", accent: "terminal", showsCLI: true),
.init(title: "Done", detail: "You can pause Clawdis anytime from the menu. Happy automating!", accent: "hand.thumbsup")
]
}
var body: some View {
let step = steps[stepIndex]
VStack(spacing: 16) {
header(step: step)
contentCard(step: step)
progressDots
footerButtons
}
.padding(20)
.task { await refreshPerms() }
}
@ViewBuilder
private func header(step: OnboardingStep) -> some View {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 14)
.fill(LinearGradient(colors: [Color.blue.opacity(0.9), Color.purple.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(height: 100)
VStack(alignment: .leading, spacing: 6) {
Label(step.title, systemImage: step.accent)
.font(.title3.bold())
.foregroundColor(.white)
Text(step.detail)
.foregroundColor(Color.white.opacity(0.92))
.font(.subheadline)
}
.padding(.horizontal, 16)
}
}
@ViewBuilder
private func contentCard(step: OnboardingStep) -> some View {
VStack(alignment: .leading, spacing: 12) {
if step.showsPermissions {
PermissionStatusList(status: permStatus, refresh: refreshPerms)
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).fill(Color(NSColor.controlBackgroundColor)))
}
if step.showsCLI {
CLIInstallCard(copied: $copied)
}
if !step.showsPermissions && !step.showsCLI {
Text("Keep Clawdis running in your menu bar. Use the Pause toggle anytime if you need to mute actions.")
}
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 14).stroke(Color.gray.opacity(0.2)))
}
private var progressDots: some View {
HStack(spacing: 8) {
ForEach(Array(steps.indices), id: \.self) { idx in
Circle()
.fill(idx == stepIndex ? Color.accentColor : Color.gray.opacity(0.4))
.frame(width: 8, height: 8)
.scaleEffect(idx == stepIndex ? 1.25 : 1.0)
.animation(.spring(response: 0.35, dampingFraction: 0.7), value: stepIndex)
}
Spacer()
}
.padding(.horizontal, 4)
}
private var footerButtons: some View {
HStack {
Button("Skip") { finish() }
.buttonStyle(.plain)
Spacer()
if stepIndex > 0 {
Button("Back") { stepIndex = max(0, stepIndex - 1) }
}
Button(stepIndex == steps.count - 1 ? "Finish" : "Next") {
advance()
}
.buttonStyle(.borderedProminent)
}
}
private func advance() {
if stepIndex + 1 < steps.count {
stepIndex += 1
} else {
finish()
}
}
private func finish() {
UserDefaults.standard.set(true, forKey: "clawdis.onboardingSeen")
OnboardingController.shared.close()
}
@MainActor
private func refreshPerms() async {
permStatus = await PermissionManager.status()
}
}
struct OnboardingStep {
let title: String
let detail: String
let accent: String
var showsPermissions: Bool = false
var showsCLI: Bool = false
}
struct CLIInstallCard: View {
@Binding var copied: Bool
private let command = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Install the helper CLI")
.font(.headline)
Text("Run this once to expose the helper to your shell:")
HStack {
Text(command)
.font(.system(.footnote, design: .monospaced))
.lineLimit(2)
Spacer()
Button(copied ? "Copied" : "Copy") {
copyToPasteboard(command)
}
}
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(Color(NSColor.controlBackgroundColor)))
}
}
private func copyToPasteboard(_ text: String) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { copied = false }
}
}