import AppKit import ApplicationServices import AsyncXPCConnection import ClawdisIPC import Foundation import class Foundation.Bundle import OSLog import CoreGraphics @preconcurrency import ScreenCaptureKit import VideoToolbox import ServiceManagement import SwiftUI import UserNotifications private let serviceName = "com.steipete.clawdis.xpc" private let launchdLabel = "com.steipete.clawdis" private let onboardingVersionKey = "clawdis.onboardingVersion" private let currentOnboardingVersion = 1 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") } } @Published var debugPaneEnabled: Bool { didSet { UserDefaults.standard.set(debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") } } 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") self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled") } } @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(state: state) } label: { CritterStatusLabel(isPaused: state.isPaused) } .menuBarExtraStyle(.menu) Settings { SettingsRootView(state: state) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) } .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) .windowResizability(.contentSize) } } private struct MenuContent: View { @ObservedObject var state: AppState @Environment(\.openSettings) private var openSettings var body: some View { Toggle(isOn: $state.isPaused) { Text(state.isPaused ? "Clawdis Paused" : "Pause Clawdis") } Button("Settings…") { open(tab: .general) } .keyboardShortcut(",", modifiers: [.command]) Button("About Clawdis") { open(tab: .about) } Divider() Button("Test Notification") { Task { _ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil) } } Button("Quit") { NSApplication.shared.terminate(nil) } } private func open(tab: SettingsTab) { SettingsTabRouter.request(tab) NSApp.activate(ignoringOtherApps: true) openSettings() NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab) } } private struct CritterStatusLabel: View { var isPaused: Bool @State private var blinkAmount: CGFloat = 0 @State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5 ... 8.5)) @State private var wiggleAngle: Double = 0 @State private var wiggleOffset: CGFloat = 0 @State private var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5 ... 14)) @State private var legWiggle: CGFloat = 0 @State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0 ... 11.0)) @State private var earWiggle: CGFloat = 0 @State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0 ... 14.0)) private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect() var body: some View { Image(nsImage: CritterIconRenderer.makeIcon( blink: blinkAmount, legWiggle: legWiggle, earWiggle: earWiggle, isPaused: isPaused )) .renderingMode(.template) .frame(width: 18, height: 16) .rotationEffect(.degrees(wiggleAngle), anchor: .center) .offset(x: wiggleOffset) .foregroundStyle(isPaused ? .secondary : .primary) .opacity(isPaused ? 0.45 : 1.0) .onReceive(ticker) { now in guard !isPaused else { resetMotion() return } if now >= nextBlink { blink() nextBlink = now.addingTimeInterval(Double.random(in: 3.5 ... 8.5)) } if now >= nextWiggle { wiggle() nextWiggle = now.addingTimeInterval(Double.random(in: 6.5 ... 14)) } if now >= nextLegWiggle { wiggleLegs() nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0 ... 11.0)) } if now >= nextEarWiggle { wiggleEars() nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0 ... 14.0)) } } .onChange(of: isPaused) { _, paused in if paused { resetMotion() } else { nextBlink = Date().addingTimeInterval(Double.random(in: 1.5 ... 3.5)) nextWiggle = Date().addingTimeInterval(Double.random(in: 4.5 ... 9.5)) nextLegWiggle = Date().addingTimeInterval(Double.random(in: 4.0 ... 8.0)) nextEarWiggle = Date().addingTimeInterval(Double.random(in: 5.5 ... 10.5)) } } } private func resetMotion() { blinkAmount = 0 wiggleAngle = 0 wiggleOffset = 0 legWiggle = 0 earWiggle = 0 } private func blink() { withAnimation(.easeInOut(duration: 0.08)) { blinkAmount = 1 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { withAnimation(.easeOut(duration: 0.12)) { blinkAmount = 0 } } } private func wiggle() { let targetAngle = Double.random(in: -4.5 ... 4.5) let targetOffset = CGFloat.random(in: -0.5 ... 0.5) withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) { wiggleAngle = targetAngle wiggleOffset = targetOffset } DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) { withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) { wiggleAngle = 0 wiggleOffset = 0 } } } private func wiggleLegs() { let target = CGFloat.random(in: 0.35 ... 0.9) withAnimation(.easeInOut(duration: 0.14)) { legWiggle = target } DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { withAnimation(.easeOut(duration: 0.18)) { legWiggle = 0 } } } private func wiggleEars() { let target = CGFloat.random(in: -1.2 ... 1.2) withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { earWiggle = target } DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { earWiggle = 0 } } } } enum CritterIconRenderer { private static let size = NSSize(width: 18, height: 16) static func makeIcon(blink: CGFloat, legWiggle: CGFloat = 0, earWiggle: CGFloat = 0, isPaused: Bool = false) -> NSImage { let image = NSImage(size: size) image.lockFocus() defer { image.unlockFocus() } guard let ctx = NSGraphicsContext.current?.cgContext else { return image } let w = size.width let h = size.height let bodyW = w * 0.78 let bodyH = h * 0.58 let bodyX = (w - bodyW) / 2 let bodyY = h * 0.36 let bodyCorner = w * 0.09 let earW = w * 0.22 let earH = bodyH * 0.66 * (1 - 0.08 * abs(earWiggle)) let earCorner = earW * 0.24 let legW = w * 0.11 let legH = h * 0.26 let legSpacing = w * 0.085 let legsWidth = 4 * legW + 3 * legSpacing let legStartX = (w - legsWidth) / 2 let legLift = legH * 0.35 * legWiggle let legYBase = bodyY - legH + h * 0.05 let eyeOpen = max(0.05, 1 - blink) let eyeW = bodyW * 0.2 let eyeH = bodyH * 0.26 * eyeOpen let eyeY = bodyY + bodyH * 0.56 let eyeOffset = bodyW * 0.24 let baseAlpha: CGFloat = isPaused ? 0.38 : 1.0 ctx.setFillColor(NSColor.labelColor.withAlphaComponent(baseAlpha).cgColor) // Body ctx.addPath(CGPath(roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH), cornerWidth: bodyCorner, cornerHeight: bodyCorner, transform: nil)) // Ears (tiny wiggle) ctx.addPath(CGPath(roundedRect: CGRect( x: bodyX - earW * 0.55 + earWiggle, y: bodyY + bodyH * 0.08 + earWiggle * 0.4, width: earW, height: earH), cornerWidth: earCorner, cornerHeight: earCorner, transform: nil)) ctx.addPath(CGPath(roundedRect: CGRect( x: bodyX + bodyW - earW * 0.45 - earWiggle, y: bodyY + bodyH * 0.08 - earWiggle * 0.4, width: earW, height: earH), cornerWidth: earCorner, cornerHeight: earCorner, transform: nil)) // Legs for i in 0 ..< 4 { let x = legStartX + CGFloat(i) * (legW + legSpacing) let lift = (i % 2 == 0 ? legLift : -legLift) let rect = CGRect(x: x, y: legYBase + lift, width: legW, height: legH * (1 - 0.12 * legWiggle)) ctx.addPath(CGPath(roundedRect: rect, cornerWidth: legW * 0.34, cornerHeight: legW * 0.34, transform: nil)) } ctx.fillPath() // Eyes punched out ctx.saveGState() ctx.setBlendMode(.clear) let leftCenter = CGPoint(x: w / 2 - eyeOffset, y: eyeY) let rightCenter = CGPoint(x: w / 2 + eyeOffset, y: eyeY) let left = CGMutablePath() left.move(to: CGPoint(x: leftCenter.x - eyeW / 2, y: leftCenter.y - eyeH)) left.addLine(to: CGPoint(x: leftCenter.x + eyeW / 2, y: leftCenter.y)) left.addLine(to: CGPoint(x: leftCenter.x - eyeW / 2, y: leftCenter.y + eyeH)) left.closeSubpath() let right = CGMutablePath() right.move(to: CGPoint(x: rightCenter.x + eyeW / 2, y: rightCenter.y - eyeH)) right.addLine(to: CGPoint(x: rightCenter.x - eyeW / 2, y: rightCenter.y)) right.addLine(to: CGPoint(x: rightCenter.x + eyeW / 2, y: rightCenter.y + eyeH)) right.closeSubpath() ctx.addPath(left) ctx.addPath(right) ctx.fillPath() ctx.restoreGState() image.isTemplate = true return image } } 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 LaunchdManager.startClawdis() startListener() scheduleFirstRunOnboardingIfNeeded() } func applicationWillTerminate(_ notification: Notification) { LaunchdManager.stopClawdis() } @MainActor private func startListener() { guard state != nil else { return } let listener = NSXPCListener(machServiceName: serviceName) listener.delegate = self listener.resume() self.listener = listener } @MainActor private func scheduleFirstRunOnboardingIfNeeded() { let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey) let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen guard shouldShow else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { OnboardingController.shared.show() } } 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 SettingsRootView: View { @ObservedObject var state: AppState @State private var permStatus: [Capability: Bool] = [:] @State private var loadingPerms = false @State private var selectedTab: SettingsTab = .general var body: some View { TabView(selection: $selectedTab) { GeneralSettings(state: state) .tabItem { Label("General", systemImage: "gearshape") } .tag(SettingsTab.general) PermissionsSettings(status: permStatus, refresh: refreshPerms, showOnboarding: { OnboardingController.shared.show() }) .tabItem { Label("Permissions", systemImage: "lock.shield") } .tag(SettingsTab.permissions) if state.debugPaneEnabled { DebugSettings() .tabItem { Label("Debug", systemImage: "ant") } .tag(SettingsTab.debug) } AboutSettings() .tabItem { Label("About", systemImage: "info.circle") } .tag(SettingsTab.about) } .padding(.horizontal, 12) .padding(.vertical, 12) .onReceive(NotificationCenter.default.publisher(for: .clawdisSelectSettingsTab)) { note in if let tab = note.object as? SettingsTab { withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { selectedTab = tab } } } .onAppear { if let pending = SettingsTabRouter.consumePending() { selectedTab = validTab(for: pending) } } .onChange(of: state.debugPaneEnabled) { _, enabled in if !enabled && selectedTab == .debug { selectedTab = .general } } .task { await refreshPerms() } } private func validTab(for requested: SettingsTab) -> SettingsTab { if requested == .debug && !state.debugPaneEnabled { return .general } return requested } @MainActor private func refreshPerms() async { guard !loadingPerms else { return } loadingPerms = true permStatus = await PermissionManager.status() loadingPerms = false } } enum SettingsTab: CaseIterable { case general, permissions, debug, about static let windowWidth: CGFloat = 410 static let windowHeight: CGFloat = 480 var title: String { switch self { case .general: return "General" case .permissions: return "Permissions" case .debug: return "Debug" case .about: return "About" } } } @MainActor enum SettingsTabRouter { private static var pending: SettingsTab? static func request(_ tab: SettingsTab) { self.pending = tab } static func consumePending() -> SettingsTab? { defer { self.pending = nil } return self.pending } } extension Notification.Name { static let clawdisSelectSettingsTab = Notification.Name("clawdisSelectSettingsTab") } struct GeneralSettings: View { @ObservedObject var state: AppState @State private var isInstallingCLI = false @State private var cliStatus: String? var body: some View { VStack(alignment: .leading, spacing: 16) { if !state.onboardingSeen { Text("Complete onboarding to finish setup") .font(.callout.weight(.semibold)) .foregroundColor(.accentColor) .padding(.bottom, 2) } VStack(alignment: .leading, spacing: 10) { Toggle(isOn: activeBinding) { Text(activeBinding.wrappedValue ? "Clawdis Active" : "Clawdis Paused") } .help("Disable to stop Clawdis background helpers and notifications") Toggle(isOn: $state.launchAtLogin) { Text("Launch at login") } Toggle(isOn: $state.debugPaneEnabled) { Text("Enable debug tools") } .help("Show the Debug tab with development utilities") LabeledContent("Default sound") { Picker("Sound", selection: $state.defaultSound) { Text("None").tag("") Text("Glass").tag("Glass") Text("Basso").tag("Basso") Text("Ping").tag("Ping") } .labelsHidden() .frame(width: 140) } } .padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color(NSColor.controlBackgroundColor))) GroupBox("CLI helper") { cliInstaller } .groupBoxStyle(.automatic) Spacer() HStack { Spacer() Button("Quit Clawdis") { NSApp.terminate(nil) } .buttonStyle(.borderedProminent) } } .frame(maxWidth: .infinity, alignment: .leading) } private var activeBinding: Binding { Binding( get: { !state.isPaused }, set: { state.isPaused = !$0 } ) } private var cliInstaller: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { Button { Task { await installCLI() } } label: { if isInstallingCLI { ProgressView().controlSize(.small) } else { Text("Install CLI helper") } } .disabled(isInstallingCLI) if let status = cliStatus { Text(status) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } } Text("Symlink \"clawdis-mac\" into /usr/local/bin and /opt/homebrew/bin for scripts.") .font(.callout) .foregroundStyle(.secondary) .padding(.leading, 2) } } private func installCLI() async { guard !isInstallingCLI else { return } isInstallingCLI = true defer { isInstallingCLI = false } await CLIInstaller.install { status in await MainActor.run { cliStatus = status } } } } struct PermissionsSettings: View { let status: [Capability: Bool] let refresh: () async -> Void let showOnboarding: () -> Void var body: some View { VStack(alignment: .leading, spacing: 14) { Text("Allow these so Clawdis can notify and capture when needed.") .padding(.top, 4) PermissionStatusList(status: status, refresh: refresh) .padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color(NSColor.controlBackgroundColor))) Button("Show onboarding") { showOnboarding() } .buttonStyle(.bordered) Spacer() } } } struct DebugSettings: View { var body: some View { VStack(alignment: .leading, spacing: 10) { LabeledContent("PID") { Text("\(ProcessInfo.processInfo.processIdentifier)") } LabeledContent("Log file") { Button("Open /tmp/clawdis.log") { NSWorkspace.shared.open(URL(fileURLWithPath: "/tmp/clawdis.log")) } } LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) } HStack { Button("Restart app") { relaunch() } Button("Reveal app in Finder") { revealApp() } } .buttonStyle(.bordered) Spacer() } } private func relaunch() { let url = Bundle.main.bundleURL let task = Process() task.launchPath = "/usr/bin/open" task.arguments = [url.path] try? task.run() task.waitUntilExit() NSApp.terminate(nil) } private func revealApp() { let url = Bundle.main.bundleURL NSWorkspace.shared.activateFileViewerSelecting([url]) } } struct AboutSettings: View { @State private var iconHover = false var body: some View { VStack(spacing: 14) { let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0) Button { if let url = URL(string: "https://github.com/steipete/warelay") { NSWorkspace.shared.open(url) } } label: { Image(nsImage: appIcon) .resizable() .frame(width: 88, height: 88) .cornerRadius(18) .shadow(color: iconHover ? .accentColor.opacity(0.25) : .clear, radius: 8) .scaleEffect(iconHover ? 1.06 : 1.0) } .buttonStyle(.plain) .onHover { hover in withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { iconHover = hover } } VStack(spacing: 4) { Text("Clawdis") .font(.title3.bold()) Text("Version \(versionString)") .foregroundStyle(.secondary) Text("Menu bar companion for notifications, screenshots, and privileged agent actions.") .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 24) } VStack(alignment: .center, spacing: 8) { AboutLinkRow(icon: "chevron.left.slash.chevron.right", title: "GitHub", url: "https://github.com/steipete/warelay") AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me") AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete") AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me") } .padding(.vertical, 10) Text("© 2025 Peter Steinberger — MIT License.") .font(.footnote) .foregroundStyle(.secondary) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .padding(.top, 18) .padding(.horizontal, 18) .padding(.bottom, 22) } private var versionString: String { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String return build.map { "\(version) (\($0))" } ?? version } } @MainActor private struct AboutLinkRow: View { let icon: String let title: String let url: String @State private var hovering = false var body: some View { Button { if let url = URL(string: url) { NSWorkspace.shared.open(url) } } label: { HStack(spacing: 6) { Image(systemName: icon) Text(title) .underline(hovering, color: .accentColor) } .foregroundColor(.accentColor) } .buttonStyle(.plain) .onHover { hovering = $0 } } } struct PermissionStatusList: View { let status: [Capability: Bool] let refresh: () async -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { ForEach(Capability.allCases, id: \.self) { cap in PermissionRow(capability: cap, status: status[cap] ?? false) { Task { await handle(cap) } } } Button("Refresh status") { Task { await refresh() } } .font(.footnote) .padding(.top, 2) } } @MainActor private func handle(_ cap: Capability) async { Task { switch cap { case .notifications: let center = UNUserNotificationCenter.current() _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) case .accessibility: openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") case .screenRecording: openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") } await refresh() } } private func openSettings(_ path: String) { if let url = URL(string: path) { NSWorkspace.shared.open(url) } } } enum LaunchdManager { private static func runLaunchctl(_ args: [String]) { let process = Process() process.launchPath = "/bin/launchctl" process.arguments = args try? process.run() } static func startClawdis() { let userTarget = "gui/\(getuid())/\(launchdLabel)" runLaunchctl(["kickstart", "-k", userTarget]) } static func stopClawdis() { let userTarget = "gui/\(getuid())/\(launchdLabel)" runLaunchctl(["stop", userTarget]) } } @MainActor enum CLIInstaller { static func install(statusHandler: @escaping @Sendable (String) async -> Void) async { let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/ClawdisCLI") guard FileManager.default.isExecutableFile(atPath: helper.path) else { await statusHandler("Helper missing in bundle; rebuild via scripts/package-mac-app.sh") return } let targets = ["/usr/local/bin/clawdis-mac", "/opt/homebrew/bin/clawdis-mac"] var messages: [String] = [] for target in targets { do { try FileManager.default.createDirectory(atPath: (target as NSString).deletingLastPathComponent, withIntermediateDirectories: true) try? FileManager.default.removeItem(atPath: target) try FileManager.default.createSymbolicLink(atPath: target, withDestinationPath: helper.path) messages.append("Linked \(target)") } catch { messages.append("Failed \(target): \(error.localizedDescription)") } } await statusHandler(messages.joined(separator: "; ")) } } private struct PermissionRow: View { let capability: Capability let status: Bool let action: () -> Void var body: some View { HStack(spacing: 12) { ZStack { Circle().fill(status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15)) .frame(width: 32, height: 32) Image(systemName: icon) .foregroundStyle(status ? Color.green : Color.secondary) } VStack(alignment: .leading, spacing: 2) { Text(title).font(.body.weight(.semibold)) Text(subtitle).font(.caption).foregroundStyle(.secondary) } Spacer() if status { Label("Granted", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } else { Button("Grant") { action() } .buttonStyle(.bordered) } } .padding(.vertical, 6) } private var title: String { switch capability { case .notifications: return "Notifications" case .accessibility: return "Accessibility" case .screenRecording: return "Screen Recording" } } private var subtitle: String { switch capability { case .notifications: return "Show desktop alerts for agent activity" case .accessibility: return "Control UI elements when an action requires it" case .screenRecording: return "Capture the screen for context or screenshots" } } private var icon: String { switch capability { case .notifications: return "bell" case .accessibility: return "hand.raised" case .screenRecording: return "display" } } } // 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: 540, 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 @State private var isRequesting = false @ObservedObject private var state = AppStateStore.shared private var steps: [OnboardingStep] { [ .init(title: "Meet Clawdis", detail: "A focused menu bar companion for notifications, screenshots, and privileged agent actions.", systemImage: "sparkles"), .init(title: "Permissions", detail: "Grant Notifications, Accessibility, and Screen Recording so tasks don't get blocked.", systemImage: "lock.shield", showsPermissions: true), .init(title: "CLI helper", detail: "Install the `clawdis-mac` helper so scripts can talk to the app.", systemImage: "terminal", showsCLI: true), .init(title: "Stay running", detail: "Enable launch at login so Clawdis is ready before the agent asks.", systemImage: "arrow.triangle.2.circlepath", showsLogin: true), .init(title: "You're set", detail: "Keep Clawdis running, pause from the menu anytime, and re-run onboarding later if needed.", systemImage: "checkmark.seal") ] } var body: some View { let step = steps[stepIndex] VStack(spacing: 16) { heroCard(step: step) contentPanel(step: step) progressDots footerButtons } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(NSColor.windowBackgroundColor)) .task { await refreshPerms() } } @ViewBuilder private func heroCard(step: OnboardingStep) -> some View { HStack(alignment: .center, spacing: 12) { ZStack { Circle() .fill(Color.white.opacity(0.15)) .frame(width: 38, height: 38) Image(systemName: step.systemImage) .font(.headline.weight(.bold)) .foregroundStyle(.white) } VStack(alignment: .leading, spacing: 6) { Text(step.title) .font(.title3.bold()) .foregroundColor(.white) Text(step.detail) .font(.subheadline) .foregroundColor(.white.opacity(0.92)) } Spacer() } .padding(.horizontal, 18) .padding(.vertical, 16) .background( LinearGradient(colors: [Color.blue.opacity(0.9), Color.purple.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) ) .shadow(color: .black.opacity(0.18), radius: 12, y: 5) } @ViewBuilder private func contentPanel(step: OnboardingStep) -> some View { VStack(alignment: .leading, spacing: 14) { if step.showsPermissions { permissionsCard } if step.showsCLI { CLIInstallCard(copied: $copied) } if step.showsLoginToggle { loginCard } if !step.showsPermissions && !step.showsCLI && !step.showsLoginToggle { VStack(alignment: .leading, spacing: 8) { Text("Keep Clawdis running in your menu bar. Pause from the menu if you need silence, or open Settings to adjust permissions later.") .font(.body) } } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(NSColor.controlBackgroundColor)) .shadow(color: .black.opacity(0.06), radius: 8, y: 3) ) } private var loginCard: some View { VStack(alignment: .leading, spacing: 10) { Text("Launch at login") .font(.headline) Text("Keep the companion ready before automations start. You can change this anytime in Settings.") .font(.subheadline) .foregroundStyle(.secondary) Toggle("Enable launch at login", isOn: $state.launchAtLogin) .toggleStyle(.switch) .onChange(of: state.launchAtLogin) { _, newValue in AppStateStore.updateLaunchAtLogin(enabled: newValue) } } } private var permissionsCard: some View { VStack(alignment: .leading, spacing: 12) { Text("Give Clawdis the access it needs") .font(.headline) Text("Grant these once; the CLI will reuse the same approvals.") .font(.subheadline) .foregroundStyle(.secondary) ForEach(Capability.allCases, id: \.self) { cap in PermissionRow(capability: cap, status: permStatus[cap] ?? false) { Task { await request(cap) } } } Button("Refresh status") { Task { await refreshPerms() } } .font(.footnote) .padding(.top, 4) } } private var progressDots: some View { HStack(spacing: 8) { Spacer() ForEach(Array(steps.indices), id: \.self) { idx in Circle() .fill(idx == stepIndex ? Color.accentColor : Color.gray.opacity(0.35)) .frame(width: 8, height: 8) .scaleEffect(idx == stepIndex ? 1.2 : 1.0) .animation(.spring(response: 0.35, dampingFraction: 0.7), value: stepIndex) } Spacer() } } private var footerButtons: some View { HStack { Button("Skip") { finish() } .buttonStyle(.bordered) Spacer() if stepIndex > 0 { Button("Back") { stepIndex = max(0, stepIndex - 1) } } Button(stepIndex == steps.count - 1 ? "Finish" : "Next") { advance() } .buttonStyle(.borderedProminent) .keyboardShortcut(.return, modifiers: []) } .padding(.top, 4) } private func advance() { if stepIndex + 1 < steps.count { stepIndex += 1 } else { finish() } } private func finish() { UserDefaults.standard.set(true, forKey: "clawdis.onboardingSeen") UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) OnboardingController.shared.close() } @MainActor private func refreshPerms() async { permStatus = await PermissionManager.status() } @MainActor private func request(_ cap: Capability) async { guard !isRequesting else { return } isRequesting = true defer { isRequesting = false } _ = await PermissionManager.ensure([cap], interactive: true) await refreshPerms() } } struct OnboardingStep { let title: String let detail: String let systemImage: String var showsPermissions: Bool = false var showsCLI: Bool = false var showsLogin: Bool = false var showsLoginToggle: Bool { showsLogin } } struct CLIInstallCard: View { @Binding var copied: Bool @State private var installing = false @State private var status: String? 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("Link `clawdis-mac` so scripts and the agent can call the companion.") HStack(spacing: 10) { Button { Task { guard !installing else { return } installing = true defer { installing = false } await CLIInstaller.install { msg in await MainActor.run { status = msg } } } } label: { if installing { ProgressView() } else { Text("Install helper") } } .buttonStyle(.borderedProminent) Button(copied ? "Copied" : "Copy command") { copyToPasteboard(command) } .disabled(installing) } if let status { Text(status) .font(.caption) .foregroundStyle(.secondary) } Text("You can rerun this anytime; we install into /usr/local/bin and /opt/homebrew/bin.") .font(.footnote) .foregroundStyle(.secondary) } } 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 } } }