diff --git a/.gitignore b/.gitignore index fff13b0d2..04bd3aef7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ coverage .pnpm-store .DS_Store src/.DS_Store +apps/macos/.build/ +apps/macos/Assets.xcassets/LobsterTemplate.imageset/lobster-template.pdf +bin/clawdis-mac diff --git a/apps/macos/Assets.xcassets/LobsterTemplate.imageset/Contents.json b/apps/macos/Assets.xcassets/LobsterTemplate.imageset/Contents.json new file mode 100644 index 000000000..80bace3a5 --- /dev/null +++ b/apps/macos/Assets.xcassets/LobsterTemplate.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "lobster-template.pdf", + "idiom" : "mac", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved new file mode 100644 index 000000000..0dea82945 --- /dev/null +++ b/apps/macos/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "606518c5b87c69959b38cf07ab1ce9ec606bb1900efdd52a82f3178a39da8881", + "pins" : [ + { + "identity" : "asyncxpcconnection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/AsyncXPCConnection", + "state" : { + "revision" : "da31dbcaa1b57949e46dcc19360b17d1a8de06bd", + "version" : "1.3.0" + } + } + ], + "version" : 3 +} diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift new file mode 100644 index 000000000..643b6480b --- /dev/null +++ b/apps/macos/Package.swift @@ -0,0 +1,56 @@ +// swift-tools-version: 6.2 +// Package manifest for the Clawdis macOS companion (menu bar app + CLI + IPC library). + +import PackageDescription + +let package = Package( + name: "Clawdis", + platforms: [ + .macOS(.v15), + ], + products: [ + .library(name: "ClawdisIPC", targets: ["ClawdisIPC"]), + .executable(name: "Clawdis", targets: ["Clawdis"]), + .executable(name: "ClawdisCLI", targets: ["ClawdisCLI"]), + ], + dependencies: [ + .package(url: "https://github.com/ChimeHQ/AsyncXPCConnection", from: "1.3.0"), + ], + targets: [ + .target( + name: "ClawdisIPC", + dependencies: [], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ] + ), + .executableTarget( + name: "Clawdis", + dependencies: [ + "ClawdisIPC", + .product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ] + ), + .executableTarget( + name: "ClawdisCLI", + dependencies: [ + "ClawdisIPC", + .product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ] + ), + .testTarget( + name: "ClawdisIPCTests", + dependencies: ["ClawdisIPC"], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("SwiftTesting"), + ] + ), + ] +) diff --git a/apps/macos/Sources/Clawdis/AppMain.swift b/apps/macos/Sources/Clawdis/AppMain.swift new file mode 100644 index 000000000..918699792 --- /dev/null +++ b/apps/macos/Sources/Clawdis/AppMain.swift @@ -0,0 +1,797 @@ +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? + 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 } + } +} diff --git a/apps/macos/Sources/ClawdisCLI/main.swift b/apps/macos/Sources/ClawdisCLI/main.swift new file mode 100644 index 000000000..d8ac5aa35 --- /dev/null +++ b/apps/macos/Sources/ClawdisCLI/main.swift @@ -0,0 +1,140 @@ +import AsyncXPCConnection +import ClawdisIPC +import Foundation + +private let serviceName = "com.steipete.clawdis.xpc" + +@objc protocol ClawdisXPCProtocol { + func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void) +} + +@main +struct ClawdisCLI { + static func main() async { + do { + let request = try parseCommandLine() + let response = try await send(request: request) + let payloadString: String? + if let payload = response.payload, let text = String(data: payload, encoding: .utf8) { + payloadString = text + } else { + payloadString = nil + } + let output: [String: Any] = [ + "ok": response.ok, + "message": response.message ?? "", + "payload": payloadString ?? "", + ] + let json = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted]) + FileHandle.standardOutput.write(json) + FileHandle.standardOutput.write(Data([0x0A])) + exit(response.ok ? 0 : 1) + } catch { + fputs("clawdis-mac error: \(error)\n", stderr) + exit(2) + } + } + + private static func parseCommandLine() throws -> Request { + var args = Array(CommandLine.arguments.dropFirst()) + guard let command = args.first else { throw CLIError.help } + args = Array(args.dropFirst()) + + switch command { + case "notify": + var title: String? + var body: String? + var sound: String? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--title": title = args.popFirst() + case "--body": body = args.popFirst() + case "--sound": sound = args.popFirst() + default: break + } + } + guard let t = title, let b = body else { throw CLIError.help } + return .notify(title: t, body: b, sound: sound) + case "ensure-permissions": + var caps: [Capability] = [] + var interactive = false + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--cap": + if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) } + case "--interactive": interactive = true + default: break + } + } + if caps.isEmpty { caps = Capability.allCases } + return .ensurePermissions(caps, interactive: interactive) + case "screenshot": + var displayID: UInt32? + var windowID: UInt32? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--display-id": if let val = args.popFirst(), let num = UInt32(val) { displayID = num } + case "--window-id": if let val = args.popFirst(), let num = UInt32(val) { windowID = num } + default: break + } + } + return .screenshot(displayID: displayID, windowID: windowID, format: "png") + case "run": + var cwd: String? + var env: [String: String] = [:] + var timeout: Double? + var needsSR = false + var cmd: [String] = [] + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--cwd": cwd = args.popFirst() + case "--env": + if let pair = args.popFirst(), let eq = pair.firstIndex(of: "=") { + let k = String(pair[.. Response { + let conn = NSXPCConnection(machServiceName: serviceName) + let interface = NSXPCInterface(with: ClawdisXPCProtocol.self) + conn.remoteObjectInterface = interface + conn.resume() + defer { conn.invalidate() } + + let data = try JSONEncoder().encode(request) + + let service = AsyncXPCConnection.RemoteXPCService(connection: conn) + let raw: Data = try await service.withValueErrorCompletion { proxy, completion in + struct CompletionBox: @unchecked Sendable { let handler: (Data?, Error?) -> Void } + let box = CompletionBox(handler: completion) + proxy.handle(data, withReply: { data, error in box.handler(data, error) }) + } + return try JSONDecoder().decode(Response.self, from: raw) + } +} + +enum CLIError: Error { case help } + +extension Array where Element == String { + mutating func popFirst() -> String? { + guard let first else { return nil } + self = Array(self.dropFirst()) + return first + } +} diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift new file mode 100644 index 000000000..ed483b083 --- /dev/null +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -0,0 +1,114 @@ +import Foundation + +// MARK: - Capabilities + +public enum Capability: String, Codable, CaseIterable, Sendable { + case notifications + case accessibility + case screenRecording +} + +// MARK: - Requests + +public enum Request: Sendable { + case notify(title: String, body: String, sound: String?) + case ensurePermissions([Capability], interactive: Bool) + case screenshot(displayID: UInt32?, windowID: UInt32?, format: String) + case runShell(command: [String], cwd: String?, env: [String: String]?, timeoutSec: Double?, needsScreenRecording: Bool) + case status +} + +// MARK: - Responses + +public struct Response: Codable, Sendable { + public var ok: Bool + public var message: String? + /// Optional payload (PNG bytes, stdout text, etc.). + public var payload: Data? + + public init(ok: Bool, message: String? = nil, payload: Data? = nil) { + self.ok = ok + self.message = message + self.payload = payload + } +} + +// MARK: - Codable conformance for Request + +extension Request: Codable { + private enum CodingKeys: String, CodingKey { + case type + case title, body, sound + case caps, interactive + case displayID, windowID, format + case command, cwd, env, timeoutSec, needsScreenRecording + } + + private enum Kind: String, Codable { + case notify + case ensurePermissions + case screenshot + case runShell + case status + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .notify(title, body, sound): + try container.encode(Kind.notify, forKey: .type) + try container.encode(title, forKey: .title) + try container.encode(body, forKey: .body) + try container.encodeIfPresent(sound, forKey: .sound) + case let .ensurePermissions(caps, interactive): + try container.encode(Kind.ensurePermissions, forKey: .type) + try container.encode(caps, forKey: .caps) + try container.encode(interactive, forKey: .interactive) + case let .screenshot(displayID, windowID, format): + try container.encode(Kind.screenshot, forKey: .type) + try container.encodeIfPresent(displayID, forKey: .displayID) + try container.encodeIfPresent(windowID, forKey: .windowID) + try container.encode(format, forKey: .format) + case let .runShell(command, cwd, env, timeoutSec, needsSR): + try container.encode(Kind.runShell, forKey: .type) + try container.encode(command, forKey: .command) + try container.encodeIfPresent(cwd, forKey: .cwd) + try container.encodeIfPresent(env, forKey: .env) + try container.encodeIfPresent(timeoutSec, forKey: .timeoutSec) + try container.encode(needsSR, forKey: .needsScreenRecording) + case .status: + try container.encode(Kind.status, forKey: .type) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Kind.self, forKey: .type) + switch kind { + case .notify: + let title = try container.decode(String.self, forKey: .title) + let body = try container.decode(String.self, forKey: .body) + let sound = try container.decodeIfPresent(String.self, forKey: .sound) + self = .notify(title: title, body: body, sound: sound) + case .ensurePermissions: + let caps = try container.decode([Capability].self, forKey: .caps) + let interactive = try container.decode(Bool.self, forKey: .interactive) + self = .ensurePermissions(caps, interactive: interactive) + case .screenshot: + let displayID = try container.decodeIfPresent(UInt32.self, forKey: .displayID) + let windowID = try container.decodeIfPresent(UInt32.self, forKey: .windowID) + let format = try container.decode(String.self, forKey: .format) + self = .screenshot(displayID: displayID, windowID: windowID, format: format) + case .runShell: + let command = try container.decode([String].self, forKey: .command) + let cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + let env = try container.decodeIfPresent([String: String].self, forKey: .env) + let timeout = try container.decodeIfPresent(Double.self, forKey: .timeoutSec) + let needsSR = try container.decode(Bool.self, forKey: .needsScreenRecording) + self = .runShell(command: command, cwd: cwd, env: env, timeoutSec: timeout, needsScreenRecording: needsSR) + case .status: + self = .status + } + } +} + diff --git a/apps/macos/Tests/ClawdisIPCTests/Placeholder.swift b/apps/macos/Tests/ClawdisIPCTests/Placeholder.swift new file mode 100644 index 000000000..425250293 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/Placeholder.swift @@ -0,0 +1,8 @@ +import Testing + +@Suite struct PlaceholderTests { + @Test func placeholder() { + #expect(true) + } +} + diff --git a/docs/clawdis-mac.md b/docs/clawdis-mac.md new file mode 100644 index 000000000..a866cb3d8 --- /dev/null +++ b/docs/clawdis-mac.md @@ -0,0 +1,87 @@ +# Clawdis macOS Companion (menu bar + XPC broker) + +Author: steipete · Status: draft spec · Date: 2025-12-05 + +## Purpose +- Single macOS menu-bar app named **Clawdis** that: + - Shows native notifications for Clawdis/warelay events. + - Owns TCC prompts (Notifications, Accessibility, Screen Recording). + - Brokers privileged actions (screen capture, shell with elevated UI context) via XPC. + - Provides a tiny CLI (`clawdis-mac`) that talks to the app; Node/TS shells out to it. +- Replace the separate notifier helper pattern (Oracle) with a built-in notifier. +- Offer a first-run experience similar to VibeTunnel’s onboarding (permissions + CLI install). + +## High-level design +- SwiftPM package in `apps/macos/` (macOS 15+, Swift 6): + - Dependency: `https://github.com/ChimeHQ/AsyncXPCConnection` (>=0.6.0). + - Targets: + - `ClawdisIPC` (shared Codable types + helpers). + - `Clawdis` (LSUIElement MenuBarExtra app; embeds XPC listener and notifier). + - `ClawdisCLI` (client that forms requests, talks XPC, prints JSON for scripts). +- Bundle ID: `com.steipete.clawdis`; XPC service name: `com.steipete.clawdis.xpc`. +- The CLI lives in the app bundle `Contents/Helpers/clawdis-mac`; dev symlink `bin/clawdis-mac` points there. +- Node/TS layer calls the CLI; no direct XPC from Node. + +## IPC contract (ClawdisIPC) +- Codable enums; small payloads (<1 MB enforced in listener): + +``` +enum Capability { notifications, accessibility, screenRecording } +enum Request { + notify(title, body, sound?) + ensurePermissions([Capability], interactive: Bool) + screenshot(displayID?, windowID?, format="png") + runShell(command:[String], cwd?, env?, timeoutSec?, needsScreenRecording: Bool) + status +} +struct Response { ok: Bool; message?: String; payload?: Data } +``` +- Listener validates caller `auditToken` == same UID, rejects oversize/unknown cases. + +## App UX (Clawdis) +- MenuBarExtra icon only (LSUIElement; no Dock). +- Menu items: Status, Test Notification, Permissions…, **Pause Clawdis** toggle (temporarily deny privileged actions/notifications without quitting), Quit. +- Settings window (Trimmy-style tabs): + - General: launch at login toggle, default sound, logging verbosity. + - Permissions: live status + “Request” buttons for Notifications/Accessibility/Screen Recording; links to System Settings. + - About: version, links, license. +- Pause behavior: matches Trimmy’s “Auto Trim” toggle. When paused, XPC listener returns `ok=false, message="clawdis paused"` for actions that would touch TCC (notify/run/screenshot). State is persisted (UserDefaults) and surfaced in menu and status view. +- Onboarding (VibeTunnel-inspired): Welcome → What it does → Install CLI (shows `ln -s .../clawdis-mac /usr/local/bin`) → Permissions checklist with live status → Test notification → Done. Re-show when `welcomeVersion` bumps or CLI/app version mismatch. + +## Built-in services +- NotificationManager: UNUserNotificationCenter primary; AppleScript `display notification` fallback; respects sound setting. +- PermissionManager: checks/requests Notifications, Accessibility (AX), Screen Recording (capture probe); publishes changes for UI. +- ScreenCaptureManager: window/display PNG capture; gated on permission. +- ShellRunner: executes `Process` with timeout; rejects when `needsScreenRecording` and permission missing; returns stdout/stderr in payload. +- XPCListener actor: routes Request → managers; logs via OSLog. + +## CLI (`clawdis-mac`) +- Subcommands (JSON out, non-zero exit on failure): + - `notify --title --body [--sound]` + - `ensure-permissions --cap accessibility --cap screenRecording [--interactive]` + - `screenshot [--display-id N | --window-id N] [--out path]` + - `run -- cmd args... [--cwd] [--env KEY=VAL] [--timeout 30] [--needs-screen-recording]` + - `status` +- Internals: builds Request, connects via AsyncXPCConnection, prints Response as JSON to stdout. + +## Integration with warelay/Clawdis (Node/TS) +- Add helper module that shells to `clawdis-mac`: + - Prefer `ensure-permissions` before actions that need TCC. + - Use `notify` for desktop toasts; fall back to JS notifier only if CLI missing or platform ≠ macOS. + - Use `run` for tasks requiring privileged UI context (screen-recorded terminal runs, etc.). + +## Permissions strategy +- All TCC prompts originate from the app bundle; CLI and Node stay headless. +- Permission checks are idempotent; onboarding surfaces missing grants and provides one-click request buttons. + +## Build & dev workflow (native) +- `cd native && swift build` (debug) / `swift build -c release`. +- Run app for dev: `swift run Clawdis` (or Xcode scheme). +- Package app + helper: `swift build -c release && swift package --allow-writing-to-directory ../dist` (tbd exact script). +- Tests: add Swift Testing suites under `apps/macos/Tests` (especially IPC round-trips and permission probing fakes). + +## Open questions / decisions +- Where to place the dev symlink `bin/clawdis-mac` (repo root vs. `apps/macos/bin`)? +- Should `runShell` support streaming stdout/stderr (XPC with AsyncSequence) or just buffered? (Start buffered; streaming later.) +- Icon: reuse Clawdis lobster or new mac-specific glyph? +- Sparkle updates: out of scope initially; add later if we ship signed builds.