From 82e751a153ddc05d65032bbdb52d7a750f4fa1c3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Dec 2025 00:10:35 +0100 Subject: [PATCH] macOS: split AppMain into focused modules --- .../macos/Sources/Clawdis/AboutSettings.swift | 111 + apps/macos/Sources/Clawdis/AppMain.swift | 3683 ----------------- apps/macos/Sources/Clawdis/AppState.swift | 118 + .../Sources/Clawdis/ConfigSettings.swift | 197 + apps/macos/Sources/Clawdis/Constants.swift | 17 + .../macos/Sources/Clawdis/DebugSettings.swift | 164 + .../Sources/Clawdis/GeneralSettings.swift | 110 + apps/macos/Sources/Clawdis/MenuBar.swift | 423 ++ .../Sources/Clawdis/ModelCatalogLoader.swift | 62 + .../Sources/Clawdis/NotificationManager.swift | 31 + apps/macos/Sources/Clawdis/Onboarding.swift | 463 +++ .../Sources/Clawdis/PermissionManager.swift | 211 + .../Sources/Clawdis/PermissionsSettings.swift | 111 + .../macos/Sources/Clawdis/Screenshotter.swift | 80 + apps/macos/Sources/Clawdis/SessionData.swift | 269 ++ .../Sources/Clawdis/SessionsSettings.swift | 207 + .../Sources/Clawdis/SettingsComponents.swift | 24 + .../Sources/Clawdis/SettingsRootView.swift | 139 + apps/macos/Sources/Clawdis/ShellRunner.swift | 45 + apps/macos/Sources/Clawdis/Utilities.swift | 48 + .../Sources/Clawdis/VoiceWakeSettings.swift | 716 ++++ apps/macos/Sources/Clawdis/XPCService.swift | 72 + 22 files changed, 3618 insertions(+), 3683 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/AboutSettings.swift delete mode 100644 apps/macos/Sources/Clawdis/AppMain.swift create mode 100644 apps/macos/Sources/Clawdis/AppState.swift create mode 100644 apps/macos/Sources/Clawdis/ConfigSettings.swift create mode 100644 apps/macos/Sources/Clawdis/Constants.swift create mode 100644 apps/macos/Sources/Clawdis/DebugSettings.swift create mode 100644 apps/macos/Sources/Clawdis/GeneralSettings.swift create mode 100644 apps/macos/Sources/Clawdis/MenuBar.swift create mode 100644 apps/macos/Sources/Clawdis/ModelCatalogLoader.swift create mode 100644 apps/macos/Sources/Clawdis/NotificationManager.swift create mode 100644 apps/macos/Sources/Clawdis/Onboarding.swift create mode 100644 apps/macos/Sources/Clawdis/PermissionManager.swift create mode 100644 apps/macos/Sources/Clawdis/PermissionsSettings.swift create mode 100644 apps/macos/Sources/Clawdis/Screenshotter.swift create mode 100644 apps/macos/Sources/Clawdis/SessionData.swift create mode 100644 apps/macos/Sources/Clawdis/SessionsSettings.swift create mode 100644 apps/macos/Sources/Clawdis/SettingsComponents.swift create mode 100644 apps/macos/Sources/Clawdis/SettingsRootView.swift create mode 100644 apps/macos/Sources/Clawdis/ShellRunner.swift create mode 100644 apps/macos/Sources/Clawdis/Utilities.swift create mode 100644 apps/macos/Sources/Clawdis/VoiceWakeSettings.swift create mode 100644 apps/macos/Sources/Clawdis/XPCService.swift diff --git a/apps/macos/Sources/Clawdis/AboutSettings.swift b/apps/macos/Sources/Clawdis/AboutSettings.swift new file mode 100644 index 000000000..ba96d908f --- /dev/null +++ b/apps/macos/Sources/Clawdis/AboutSettings.swift @@ -0,0 +1,111 @@ +import SwiftUI + +struct AboutSettings: View { + @State private var iconHover = false + + var body: some View { + VStack(spacing: 8) { + let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0) + Button { + if let url = URL(string: "https://github.com/steipete/clawdis") { + NSWorkspace.shared.open(url) + } + } label: { + Image(nsImage: appIcon) + .resizable() + .frame(width: 88, height: 88) + .cornerRadius(16) + .shadow(color: self.iconHover ? .accentColor.opacity(0.25) : .clear, radius: 8) + .scaleEffect(self.iconHover ? 1.06 : 1.0) + } + .buttonStyle(.plain) + .onHover { hover in + withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover } + } + + VStack(spacing: 3) { + Text("Clawdis") + .font(.title3.bold()) + Text("Version \(self.versionString)") + .foregroundStyle(.secondary) + if let buildTimestamp { + Text("Built \(buildTimestamp)") + .font(.footnote) + .foregroundStyle(.secondary) + } + Text("Menu bar companion for notifications, screenshots, and privileged agent actions.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 18) + } + + VStack(alignment: .center, spacing: 6) { + AboutLinkRow( + icon: "chevron.left.slash.chevron.right", + title: "GitHub", + url: "https://github.com/steipete/clawdis") + 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") + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.vertical, 10) + + Text("© 2025 Peter Steinberger — MIT License.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 4) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 4) + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + + 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 + } + + private var buildTimestamp: String? { + guard let raw = Bundle.main.object(forInfoDictionaryKey: "ClawdisBuildTimestamp") as? String else { return nil } + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime] + guard let date = parser.date(from: raw) else { return raw } + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + formatter.locale = .current + return formatter.string(from: date) + } +} + +@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: self.icon) + Text(self.title) + .underline(self.hovering, color: .accentColor) + } + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .onHover { self.hovering = $0 } + } +} diff --git a/apps/macos/Sources/Clawdis/AppMain.swift b/apps/macos/Sources/Clawdis/AppMain.swift deleted file mode 100644 index 15516b9fd..000000000 --- a/apps/macos/Sources/Clawdis/AppMain.swift +++ /dev/null @@ -1,3683 +0,0 @@ -import AppKit -import ApplicationServices -import AsyncXPCConnection -import AVFoundation -import ClawdisIPC -import CoreGraphics -import Foundation -import JavaScriptCore -import MenuBarExtraAccess -import OSLog -@preconcurrency import ScreenCaptureKit -import ServiceManagement -import Speech -import SwiftUI -import UserNotifications -import VideoToolbox -import UniformTypeIdentifiers - -private let serviceName = "com.steipete.clawdis.xpc" -private let launchdLabel = "com.steipete.clawdis" -private let onboardingVersionKey = "clawdis.onboardingVersion" -private let currentOnboardingVersion = 2 -private let pauseDefaultsKey = "clawdis.pauseEnabled" -private let swabbleEnabledKey = "clawdis.swabbleEnabled" -private let swabbleTriggersKey = "clawdis.swabbleTriggers" -private let showDockIconKey = "clawdis.showDockIcon" -private let defaultVoiceWakeTriggers = ["clawd", "claude"] -private let voiceWakeMicKey = "clawdis.voiceWakeMicID" -private let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID" -private let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs" -private let modelCatalogPathKey = "clawdis.modelCatalogPath" -private let modelCatalogReloadKey = "clawdis.modelCatalogReload" -private let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 - -enum UIStrings { - static let welcomeTitle = "Welcome to Clawdis" -} - -// MARK: - App model - -@MainActor -final class AppState: ObservableObject { - @Published var isPaused: Bool { - didSet { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } - } - - @Published var defaultSound: String { - didSet { UserDefaults.standard.set(self.defaultSound, forKey: "clawdis.defaultSound") } - } - - @Published var launchAtLogin: Bool { - didSet { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } - } - - @Published var onboardingSeen: Bool { - didSet { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") } - } - - @Published var debugPaneEnabled: Bool { - didSet { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") } - } - - @Published var swabbleEnabled: Bool { - didSet { UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey) } - } - - @Published var swabbleTriggerWords: [String] { - didSet { - let cleaned = self.swabbleTriggerWords.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - UserDefaults.standard.set(cleaned, forKey: swabbleTriggersKey) - if cleaned.count != self.swabbleTriggerWords.count { - self.swabbleTriggerWords = cleaned - } - } - } - - @Published var showDockIcon: Bool { - didSet { - UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey) - AppActivationPolicy.apply(showDockIcon: self.showDockIcon) - } - } - - @Published var voiceWakeMicID: String { - didSet { UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey) } - } - - @Published var voiceWakeLocaleID: String { - didSet { UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey) } - } - - @Published var voiceWakeAdditionalLocaleIDs: [String] { - didSet { UserDefaults.standard.set(self.voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) } - } - - @Published var isWorking: Bool = false - @Published var earBoostActive: Bool = false - - private var earBoostTask: Task? = nil - - 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") - let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey) - self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false - self.swabbleTriggerWords = UserDefaults.standard - .stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers - self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey) - self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? "" - self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier - self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard - .stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? [] - } - - func triggerVoiceEars(ttl: TimeInterval = 5) { - self.earBoostTask?.cancel() - self.earBoostActive = true - self.earBoostTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000)) - await MainActor.run { [weak self] in self?.earBoostActive = false } - } - } - - func setWorking(_ working: Bool) { - self.isWorking = working - } -} - -@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() - } - } -} - -@MainActor -enum AppActivationPolicy { - static func apply(showDockIcon: Bool) { - NSApp.setActivationPolicy(showDockIcon ? .regular : .accessory) - } -} - -// 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 { sound } else { 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(\.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 settings = await center.notificationSettings() - - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - results[cap] = true - - case .notDetermined: - if interactive { - let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false - let updated = await center.notificationSettings() - results[cap] = granted && (updated.authorizationStatus == .authorized || updated - .authorizationStatus == .provisional) - } else { - results[cap] = false - } - - case .denied: - results[cap] = false - if interactive { - NotificationPermissionHelper.openSettings() - } - - @unknown default: - results[cap] = false - } - - case .accessibility: - // Accessing AX APIs must be on main thread. - let trusted = AXIsProcessTrusted() - results[cap] = trusted - if interactive, !trusted { - let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] - _ = AXIsProcessTrustedWithOptions(opts) - } - - case .screenRecording: - let granted = ScreenRecordingProbe.isAuthorized() - if interactive, !granted { - await ScreenRecordingProbe.requestAuthorization() - } - results[cap] = ScreenRecordingProbe.isAuthorized() - - case .microphone: - let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - if interactive, !granted { - let ok = await AVCaptureDevice.requestAccess(for: .audio) - results[cap] = ok - } else { - results[cap] = granted - } - - case .speechRecognition: - let status = SFSpeechRecognizer.authorizationStatus() - if status == .notDetermined, interactive { - let ok = await withCheckedContinuation { cont in - SFSpeechRecognizer.requestAuthorization { auth in cont.resume(returning: auth == .authorized) } - } - results[cap] = ok - } else { - results[cap] = status == .authorized - } - } - } - 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 - } - - case .microphone: - results[cap] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - - case .speechRecognition: - results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized - } - } - return results - } -} - -enum NotificationPermissionHelper { - static func openSettings() { - let candidates = [ - "x-apple.systempreferences:com.apple.Notifications-Settings.extension", - "x-apple.systempreferences:com.apple.preference.notifications", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } - } -} - -// MARK: - Permission monitoring - -@MainActor -final class PermissionMonitor: ObservableObject { - static let shared = PermissionMonitor() - - @Published private(set) var status: [Capability: Bool] = [:] - - private var monitorTimer: Timer? - private var isChecking = false - private var registrations = 0 - private var lastCheck: Date? - private let minimumCheckInterval: TimeInterval = 0.5 - - func register() { - self.registrations += 1 - if self.registrations == 1 { - self.startMonitoring() - } - } - - func unregister() { - guard self.registrations > 0 else { return } - self.registrations -= 1 - if self.registrations == 0 { - self.stopMonitoring() - } - } - - func refreshNow() async { - await self.checkStatus(force: true) - } - - private func startMonitoring() { - Task { await self.checkStatus(force: true) } - - self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - await self.checkStatus(force: false) - } - } - } - - private func stopMonitoring() { - self.monitorTimer?.invalidate() - self.monitorTimer = nil - self.lastCheck = nil - } - - private func checkStatus(force: Bool) async { - if self.isChecking { return } - let now = Date() - if !force, let lastCheck, now.timeIntervalSince(lastCheck) < self.minimumCheckInterval { - return - } - - self.isChecking = true - self.lastCheck = now - - let latest = await PermissionManager.status() - if latest != self.status { - self.status = latest - } - - self.isChecking = false - } -} - -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 { - content.displays.first(where: { $0.displayID == displayID }) - } else { - 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 self.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 } - - self.delivered = true - self.continuation?.resume(returning: data) - self.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 - @StateObject private var relayManager = RelayProcessManager.shared - @State private var statusItem: NSStatusItem? - @State private var isMenuPresented = false - - init() { - _state = StateObject(wrappedValue: AppStateStore.shared) - } - - var body: some Scene { - MenuBarExtra { MenuContent(state: self.state) } label: { - CritterStatusLabel( - isPaused: self.state.isPaused, - isWorking: self.state.isWorking, - earBoostActive: self.state.earBoostActive, - relayStatus: self.relayManager.status) - } - .menuBarExtraStyle(.menu) - .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in - self.statusItem = item - self.applyStatusItemAppearance(paused: self.state.isPaused) - } - .onChange(of: self.state.isPaused) { _, paused in - self.applyStatusItemAppearance(paused: paused) - self.relayManager.setActive(!paused) - } - - Settings { - SettingsRootView(state: self.state) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) - } - .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - .windowResizability(.contentSize) - } - - private func applyStatusItemAppearance(paused: Bool) { - self.statusItem?.button?.appearsDisabled = paused - } -} - -private struct MenuContent: View { - @ObservedObject var state: AppState - @ObservedObject private var relayManager = RelayProcessManager.shared - @Environment(\.openSettings) private var openSettings - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: self.activeBinding) { Text("Clawdis Active") } - Toggle(isOn: self.$state.swabbleEnabled) { Text("Voice Wake") } - .disabled(!voiceWakeSupported) - .opacity(voiceWakeSupported ? 1 : 0.5) - Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) } - self.relayStatusRow - Divider() - Button("Settings…") { self.open(tab: .general) } - .keyboardShortcut(",", modifiers: [.command]) - Button("About Clawdis") { self.open(tab: .about) } - Divider() - Button("Quit") { NSApplication.shared.terminate(nil) } - } - } - - private func open(tab: SettingsTab) { - SettingsTabRouter.request(tab) - NSApp.activate(ignoringOtherApps: true) - self.openSettings() - NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab) - } - - private var relayStatusRow: some View { - let status = self.relayManager.status - return HStack(spacing: 8) { - Circle() - .fill(self.statusColor(status)) - .frame(width: 8, height: 8) - Text(status.label) - .font(.caption.weight(.semibold)) - .foregroundStyle(.primary) - } - .padding(.vertical, 4) - } - - private func statusColor(_ status: RelayProcessManager.Status) -> Color { - switch status { - case .running: return .green - case .starting, .restarting: return .orange - case .failed: return .red - case .stopped: return .secondary - } - } - - private var activeBinding: Binding { - Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 }) - } - - private func primarySessionKey() -> String { - // Prefer canonical main session; fall back to most recent. - let storePath = SessionLoader.defaultStorePath - if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)), - let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) - { - if decoded.keys.contains("main") { return "main" } - - let sorted = decoded.sorted { a, b -> Bool in - let lhs = a.value.updatedAt ?? 0 - let rhs = b.value.updatedAt ?? 0 - return lhs > rhs - } - if let first = sorted.first { return first.key } - } - return "+1003" - } -} - -private struct CritterStatusLabel: View { - var isPaused: Bool - var isWorking: Bool - var earBoostActive: Bool - var relayStatus: RelayProcessManager.Status - - @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 { - ZStack(alignment: .bottomTrailing) { - Group { - if self.isPaused { - Image(nsImage: CritterIconRenderer.makeIcon(blink: 0)) - .frame(width: 18, height: 16) - } else { - Image(nsImage: CritterIconRenderer.makeIcon( - blink: self.blinkAmount, - legWiggle: max(self.legWiggle, self.isWorking ? 0.6 : 0), - earWiggle: self.earWiggle, - earScale: self.earBoostActive ? 1.9 : 1.0)) - .frame(width: 18, height: 16) - .rotationEffect(.degrees(self.wiggleAngle), anchor: .center) - .offset(x: self.wiggleOffset) - .onReceive(self.ticker) { now in - if now >= self.nextBlink { - self.blink() - self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5)) - } - - if now >= self.nextWiggle { - self.wiggle() - self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14)) - } - - if now >= self.nextLegWiggle { - self.wiggleLegs() - self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0)) - } - - if now >= self.nextEarWiggle { - self.wiggleEars() - self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0)) - } - - if self.isWorking { - self.scurry() - } - } - .onChange(of: self.isPaused) { _, _ in self.resetMotion() } - } - } - - if self.relayNeedsAttention { - Circle() - .fill(self.relayBadgeColor) - .frame(width: 8, height: 8) - .offset(x: 4, y: 4) - } - } - } - - private func resetMotion() { - self.blinkAmount = 0 - self.wiggleAngle = 0 - self.wiggleOffset = 0 - self.legWiggle = 0 - self.earWiggle = 0 - } - - private func blink() { - withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { - withAnimation(.easeOut(duration: 0.12)) { self.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)) { - self.wiggleAngle = targetAngle - self.wiggleOffset = targetOffset - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) { - withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) { - self.wiggleAngle = 0 - self.wiggleOffset = 0 - } - } - } - - private func wiggleLegs() { - let target = CGFloat.random(in: 0.35...0.9) - withAnimation(.easeInOut(duration: 0.14)) { - self.legWiggle = target - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { - withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 } - } - } - - private func scurry() { - let target = CGFloat.random(in: 0.7...1.0) - withAnimation(.easeInOut(duration: 0.12)) { - self.legWiggle = target - self.wiggleOffset = CGFloat.random(in: -0.6...0.6) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { - withAnimation(.easeOut(duration: 0.16)) { - self.legWiggle = 0.25 - self.wiggleOffset = 0 - } - } - } - - private func wiggleEars() { - let target = CGFloat.random(in: -1.2...1.2) - withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { - self.earWiggle = target - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { - withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 } - } - } - - private var relayNeedsAttention: Bool { - switch self.relayStatus { - case .failed, .stopped: - return !self.isPaused - case .starting, .restarting, .running: - return false - } - } - - private var relayBadgeColor: Color { - switch self.relayStatus { - case .failed: return .red - case .stopped: return .orange - default: return .clear - } - } -} - -enum CritterIconRenderer { - private static let size = NSSize(width: 18, height: 16) - - static func makeIcon( - blink: CGFloat, - legWiggle: CGFloat = 0, - earWiggle: CGFloat = 0, - earScale: CGFloat = 1 - ) -> NSImage { - let image = NSImage(size: size) - image.lockFocus() - defer { image.unlockFocus() } - - guard let ctx = NSGraphicsContext.current?.cgContext else { return image } - - let w = self.size.width - let h = self.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 * earScale * (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 - - ctx.setFillColor(NSColor.labelColor.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) { - self.state = AppStateStore.shared - AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) - if let state { - RelayProcessManager.shared.setActive(!state.isPaused) - } - self.startListener() - self.scheduleFirstRunOnboardingIfNeeded() - } - - func applicationWillTerminate(_ notification: Notification) { - RelayProcessManager.shared.stop() - } - - @MainActor - private func startListener() { - guard self.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 - -private struct SessionEntryRecord: Decodable { - let sessionId: String? - let updatedAt: Double? - let systemSent: Bool? - let abortedLastRun: Bool? - let thinkingLevel: String? - let verboseLevel: String? - let inputTokens: Int? - let outputTokens: Int? - let totalTokens: Int? - let model: String? - let contextTokens: Int? -} - -private struct SessionTokenStats { - let input: Int - let output: Int - let total: Int - let contextTokens: Int - - var percentUsed: Int? { - guard self.contextTokens > 0, self.total > 0 else { return nil } - return min(100, Int(round((Double(self.total) / Double(self.contextTokens)) * 100))) - } - - var summary: String { - let parts = ["in \(input)", "out \(output)", "total \(total)"] - var text = parts.joined(separator: " | ") - if let percentUsed { - text += " (\(percentUsed)% of \(self.contextTokens))" - } - return text - } -} - -private struct SessionRow: Identifiable { - let id: String - let key: String - let kind: SessionKind - let updatedAt: Date? - let sessionId: String? - let thinkingLevel: String? - let verboseLevel: String? - let systemSent: Bool - let abortedLastRun: Bool - let tokens: SessionTokenStats - let model: String? - - var ageText: String { relativeAge(from: self.updatedAt) } - - var flagLabels: [String] { - var flags: [String] = [] - if let thinkingLevel { flags.append("think \(thinkingLevel)") } - if let verboseLevel { flags.append("verbose \(verboseLevel)") } - if self.systemSent { flags.append("system sent") } - if self.abortedLastRun { flags.append("aborted") } - return flags - } -} - -private enum SessionKind { - case direct, group, global, unknown - - static func from(key: String) -> SessionKind { - if key == "global" { return .global } - if key.hasPrefix("group:") { return .group } - if key == "unknown" { return .unknown } - return .direct - } - - var label: String { - switch self { - case .direct: "Direct" - case .group: "Group" - case .global: "Global" - case .unknown: "Unknown" - } - } - - var tint: Color { - switch self { - case .direct: .accentColor - case .group: .orange - case .global: .purple - case .unknown: .gray - } - } -} - -private struct SessionDefaults { - let model: String - let contextTokens: Int -} - -struct ModelChoice: Identifiable, Hashable { - let id: String - let name: String - let provider: String - let contextWindow: Int? -} - -extension String? { - var isNilOrEmpty: Bool { - switch self { - case .none: true - case let .some(value): value.isEmpty - } - } -} - -extension [String] { - fileprivate func dedupedPreserveOrder() -> [String] { - var seen = Set() - var result: [String] = [] - for item in self { - if !seen.contains(item) { - seen.insert(item) - result.append(item) - } - } - return result - } -} - -private struct SessionConfigHints { - let storePath: String? - let model: String? - let contextTokens: Int? -} - -private enum SessionLoadError: LocalizedError { - case missingStore(String) - case decodeFailed(String) - - var errorDescription: String? { - switch self { - case let .missingStore(path): - "No session store found at \(path) yet. Send or receive a message to create it." - - case let .decodeFailed(reason): - "Could not read the session store: \(reason)" - } - } -} - -private enum SessionLoader { - static let fallbackModel = "claude-opus-4-5" - static let fallbackContextTokens = 200_000 - - static let defaultStorePath = standardize( - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".clawdis/sessions/sessions.json").path) - - private static let legacyStorePaths: [String] = [ - standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".clawdis/sessions.json") - .path), - standardize(FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".warelay/sessions/sessions.json").path), - standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".warelay/sessions.json") - .path), - ] - - static func configHints() -> SessionConfigHints { - let configURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".clawdis/clawdis.json") - guard let data = try? Data(contentsOf: configURL) else { - return SessionConfigHints(storePath: nil, model: nil, contextTokens: nil) - } - guard let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return SessionConfigHints(storePath: nil, model: nil, contextTokens: nil) - } - - let inbound = parsed["inbound"] as? [String: Any] - let reply = inbound?["reply"] as? [String: Any] - let session = reply?["session"] as? [String: Any] - let agent = reply?["agent"] as? [String: Any] - - let store = session?["store"] as? String - let model = agent?["model"] as? String - let contextTokens = (agent?["contextTokens"] as? NSNumber)?.intValue - - return SessionConfigHints( - storePath: store.map { self.standardize($0) }, - model: model, - contextTokens: contextTokens) - } - - static func resolveStorePath(override: String?) -> String { - let preferred = self.standardize(override ?? self.defaultStorePath) - let candidates = [preferred] + self.legacyStorePaths - if let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }) { - return existing - } - return preferred - } - - static func availableModels(storeOverride: String?) -> [String] { - let path = self.resolveStorePath(override: storeOverride) - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) - else { - return [self.fallbackModel] - } - let models = decoded.values.compactMap(\.model) - return ([self.fallbackModel] + models).dedupedPreserveOrder() - } - - static func loadRows(at path: String, defaults: SessionDefaults) async throws -> [SessionRow] { - try await Task.detached(priority: .utility) { - guard FileManager.default.fileExists(atPath: path) else { - throw SessionLoadError.missingStore(path) - } - - let data = try Data(contentsOf: URL(fileURLWithPath: path)) - let decoded: [String: SessionEntryRecord] - do { - decoded = try JSONDecoder().decode([String: SessionEntryRecord].self, from: data) - } catch { - throw SessionLoadError.decodeFailed(error.localizedDescription) - } - - return decoded.map { key, entry in - let updated = entry.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) } - let input = entry.inputTokens ?? 0 - let output = entry.outputTokens ?? 0 - let total = entry.totalTokens ?? input + output - let context = entry.contextTokens ?? defaults.contextTokens - let model = entry.model ?? defaults.model - - return SessionRow( - id: key, - key: key, - kind: SessionKind.from(key: key), - updatedAt: updated, - sessionId: entry.sessionId, - thinkingLevel: entry.thinkingLevel, - verboseLevel: entry.verboseLevel, - systemSent: entry.systemSent ?? false, - abortedLastRun: entry.abortedLastRun ?? false, - tokens: SessionTokenStats( - input: input, - output: output, - total: total, - contextTokens: context), - model: model) - } - .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } - }.value - } - - private static func standardize(_ path: String) -> String { - (path as NSString).expandingTildeInPath.replacingOccurrences(of: "//", with: "/") - } -} - -enum ModelCatalogLoader { - static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path - - static func load(from path: String) async throws -> [ModelChoice] { - let expanded = (path as NSString).expandingTildeInPath - let source = try String(contentsOfFile: expanded, encoding: .utf8) - let sanitized = self.sanitize(source: source) - - let ctx = JSContext() - ctx?.exceptionHandler = { _, exception in - if let exception { print("JS exception: \(exception)") } - } - ctx?.evaluateScript(sanitized) - guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else { - throw NSError( - domain: "ModelCatalogLoader", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"]) - } - - var choices: [ModelChoice] = [] - for (provider, value) in rawModels { - guard let models = value as? [String: Any] else { continue } - for (id, payload) in models { - guard let dict = payload as? [String: Any] else { continue } - let name = dict["name"] as? String ?? id - let ctxWindow = dict["contextWindow"] as? Int - choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow)) - } - } - - return choices.sorted { lhs, rhs in - if lhs.provider == rhs.provider { - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending - } - } - - private static func sanitize(source: String) -> String { - guard let exportRange = source.range(of: "export const MODELS"), - let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), - let lastBrace = source.lastIndex(of: "}") - else { - return "var MODELS = {}" - } - var body = String(source[firstBrace...lastBrace]) - body = body.replacingOccurrences( - of: #"(?m)\bsatisfies\s+[^,}\n]+"#, - with: "", - options: .regularExpression) - body = body.replacingOccurrences( - of: #"(?m)\bas\s+[^;,\n]+"#, - with: "", - options: .regularExpression) - return "var MODELS = \(body);" - } -} - -private func relativeAge(from date: Date?) -> String { - guard let date else { return "unknown" } - let delta = Date().timeIntervalSince(date) - if delta < 60 { return "just now" } - let minutes = Int(round(delta / 60)) - if minutes < 60 { return "\(minutes)m ago" } - let hours = Int(round(Double(minutes) / 60)) - if hours < 48 { return "\(hours)h ago" } - let days = Int(round(Double(hours) / 24)) - return "\(days)d ago" -} - -@MainActor -struct SessionsSettings: View { - @State private var rows: [SessionRow] = [] - @State private var storePath: String = SessionLoader.defaultStorePath - @State private var lastLoaded: Date? - @State private var errorMessage: String? - @State private var loading = false - @State private var hasLoaded = false - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - self.header - self.storeMetadata - Divider().padding(.vertical, 4) - self.content - Spacer() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .task { - guard !self.hasLoaded else { return } - self.hasLoaded = true - await self.refresh() - } - } - - private var header: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Sessions") - .font(.title3.weight(.semibold)) - Text("Peek at the stored conversation buckets the CLI reuses for context and rate limits.") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - private var storeMetadata: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 10) { - VStack(alignment: .leading, spacing: 4) { - Text("Session store") - .font(.callout.weight(.semibold)) - if let lastLoaded { - Text("Updated \(relativeAge(from: lastLoaded))") - .font(.caption) - .foregroundStyle(.secondary) - } - } - Spacer() - Text(self.storePath) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .multilineTextAlignment(.trailing) - } - - HStack(spacing: 10) { - Button { - Task { await self.refresh() } - } label: { - Label(self.loading ? "Refreshing..." : "Refresh", systemImage: "arrow.clockwise") - .labelStyle(.titleAndIcon) - } - .disabled(self.loading) - - Button { - self.revealStore() - } label: { - Label("Reveal", systemImage: "folder") - .labelStyle(.titleAndIcon) - } - .disabled(!FileManager.default.fileExists(atPath: self.storePath)) - - if self.loading { - ProgressView().controlSize(.small) - } - } - - if let errorMessage { - Text(errorMessage) - .font(.footnote) - .foregroundStyle(.red) - } - } - } - - private var content: some View { - Group { - if self.rows.isEmpty, self.errorMessage == nil { - Text("No sessions yet. They appear after the first inbound message or heartbeat.") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 6) - } else { - Table(self.rows) { - TableColumn("Key") { row in - VStack(alignment: .leading, spacing: 4) { - Text(row.key) - .font(.body.weight(.semibold)) - HStack(spacing: 6) { - SessionKindBadge(kind: row.kind) - if !row.flagLabels.isEmpty { - ForEach(row.flagLabels, id: \.self) { flag in - Badge(text: flag) - } - } - } - } - } - .width(170) - - TableColumn("Updated", value: \.ageText) - .width(80) - - TableColumn("Tokens") { row in - Text(row.tokens.summary) - .font(.caption) - .foregroundStyle(.secondary) - } - .width(210) - - TableColumn("Model") { row in - Text(row.model ?? "—") - .font(.caption) - .foregroundStyle(.secondary) - } - .width(120) - - TableColumn("Session ID") { row in - Text(row.sessionId ?? "—") - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } - .tableStyle(.inset(alternatesRowBackgrounds: true)) - .frame(maxHeight: .infinity, alignment: .top) - } - } - } - - private func refresh() async { - guard !self.loading else { return } - self.loading = true - self.errorMessage = nil - - let hints = SessionLoader.configHints() - let resolvedStore = SessionLoader.resolveStorePath(override: hints.storePath) - let defaults = SessionDefaults( - model: hints.model ?? SessionLoader.fallbackModel, - contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens) - - do { - let newRows = try await SessionLoader.loadRows(at: resolvedStore, defaults: defaults) - self.rows = newRows - self.storePath = resolvedStore - self.lastLoaded = Date() - } catch { - self.rows = [] - self.storePath = resolvedStore - self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - } - - self.loading = false - } - - private func revealStore() { - let url = URL(fileURLWithPath: storePath) - if FileManager.default.fileExists(atPath: self.storePath) { - NSWorkspace.shared.activateFileViewerSelecting([url]) - } else { - NSWorkspace.shared.open(url.deletingLastPathComponent()) - } - } -} - -@MainActor -struct ConfigSettings: View { - @State private var configModel: String = "" - @State private var customModel: String = "" - @State private var configStorePath: String = SessionLoader.defaultStorePath - @State private var configSaving = false - @State private var hasLoaded = false - @State private var models: [ModelChoice] = [] - @State private var modelsLoading = false - @State private var modelError: String? - @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath - @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 - @State private var allowAutosave = false - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Clawdis CLI config") - .font(.title3.weight(.semibold)) - Text("Edit ~/.clawdis/clawdis.json (inbound.reply.agent/session).") - .font(.callout) - .foregroundStyle(.secondary) - - LabeledContent("Model") { - VStack(alignment: .leading, spacing: 6) { - Picker("Model", selection: self.$configModel) { - ForEach(self.models) { choice in - Text( - "\(choice.name) — \(choice.provider.uppercased())") - .tag(choice.id) - } - Text("Manual entry…").tag("__custom__") - } - .labelsHidden() - .frame(width: 360) - .disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty)) - .onChange(of: self.configModel) { _, _ in - self.autosaveConfig() - } - - if self.configModel == "__custom__" { - TextField("Enter model ID", text: self.$customModel) - .textFieldStyle(.roundedBorder) - .frame(width: 320) - .onChange(of: self.customModel) { _, newValue in - self.configModel = newValue - self.autosaveConfig() - } - } - - if let contextLabel = self.selectedContextLabel { - Text(contextLabel) - .font(.footnote) - .foregroundStyle(.secondary) - } - - if let modelError { - Text(modelError) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - - LabeledContent("Session store") { - TextField("Path", text: self.$configStorePath) - .textFieldStyle(.roundedBorder) - .frame(width: 360) - .onChange(of: self.configStorePath) { _, _ in - self.autosaveConfig() - } - } - - Spacer() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .onChange(of: self.modelCatalogPath) { _, _ in - Task { await self.loadModels() } - } - .onChange(of: self.modelCatalogReloadBump) { _, _ in - Task { await self.loadModels() } - } - .task { - guard !self.hasLoaded else { return } - self.hasLoaded = true - self.loadConfig() - await self.loadModels() - self.allowAutosave = true - } - } - - private func configURL() -> URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".clawdis") - .appendingPathComponent("clawdis.json") - } - - private func loadConfig() { - let url = self.configURL() - guard let data = try? Data(contentsOf: url) else { - self.configModel = SessionLoader.fallbackModel - self.configStorePath = SessionLoader.defaultStorePath - return - } - guard - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let inbound = parsed["inbound"] as? [String: Any], - let reply = inbound["reply"] as? [String: Any] - else { - return - } - - let session = reply["session"] as? [String: Any] - let agent = reply["agent"] as? [String: Any] - self.configStorePath = (session?["store"] as? String) ?? SessionLoader.defaultStorePath - let loadedModel = (agent?["model"] as? String) ?? "" - if !loadedModel.isEmpty { - self.configModel = loadedModel - self.customModel = loadedModel - } else { - self.configModel = SessionLoader.fallbackModel - self.customModel = SessionLoader.fallbackModel - } - } - - private func autosaveConfig() { - guard self.allowAutosave else { return } - Task { await self.saveConfig() } - } - - private func saveConfig() async { - guard !self.configSaving else { return } - self.configSaving = true - defer { self.configSaving = false } - - var session: [String: Any] = [:] - var agent: [String: Any] = [:] - - let trimmedStore = self.configStorePath.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedStore.isEmpty { session["store"] = trimmedStore } - - let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel) - .trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedModel = chosenModel - if !trimmedModel.isEmpty { agent["model"] = trimmedModel } - - let reply: [String: Any] = [ - "session": session, - "agent": agent, - ] - let inbound: [String: Any] = ["reply": reply] - let root: [String: Any] = ["inbound": inbound] - - do { - let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) - let url = self.configURL() - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) - } catch {} - } - - private func loadModels() async { - guard !self.modelsLoading else { return } - self.modelsLoading = true - self.modelError = nil - do { - let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath) - self.models = loaded - // if current model not in list, switch to custom to keep value visible - if !self.configModel.isEmpty, !loaded.contains(where: { $0.id == self.configModel }) { - self.customModel = self.configModel - self.configModel = "__custom__" - } - } catch { - self.modelError = error.localizedDescription - self.models = [] - } - self.modelsLoading = false - } - - private var selectedContextLabel: String? { - let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel - guard - !chosenId.isEmpty, - let choice = self.models.first(where: { $0.id == chosenId }), - let context = choice.contextWindow - else { - return nil - } - - let human = context >= 1000 ? "\(context / 1000)k" : "\(context)" - return "Context window: \(human) tokens" - } -} - -private struct SessionRowView: View { - let row: SessionRow - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text(self.row.key) - .font(.body.weight(.semibold)) - SessionKindBadge(kind: self.row.kind) - Spacer() - Text(self.row.ageText) - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack(spacing: 12) { - Label(self.row.tokens.summary, systemImage: "chart.bar.doc.horizontal") - .labelStyle(.titleAndIcon) - .foregroundStyle(.secondary) - - if let model = row.model { - Label(model, systemImage: "brain.head.profile") - .labelStyle(.titleAndIcon) - .foregroundStyle(.secondary) - } - - if let sessionId = row.sessionId { - Label(sessionId, systemImage: "number") - .labelStyle(.titleAndIcon) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } - .font(.caption) - .lineLimit(1) - - if !self.row.flagLabels.isEmpty { - HStack(spacing: 6) { - ForEach(self.row.flagLabels, id: \.self) { flag in - Text(flag) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 4) - .background(Color.secondary.opacity(0.12)) - .clipShape(Capsule()) - } - } - } - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor))) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.secondary.opacity(0.15), lineWidth: 1)) - } -} - -private struct SessionKindBadge: View { - let kind: SessionKind - - var body: some View { - Text(self.kind.label) - .font(.caption2.weight(.bold)) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .foregroundStyle(self.kind.tint) - .background(self.kind.tint.opacity(0.15)) - .clipShape(Capsule()) - } -} - -private struct Badge: View { - let text: String - - var body: some View { - Text(self.text) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .foregroundStyle(.secondary) - .background(Color.secondary.opacity(0.12)) - .clipShape(Capsule()) - } -} - -struct SettingsRootView: View { - @ObservedObject var state: AppState - @ObservedObject private var permissionMonitor = PermissionMonitor.shared - @State private var monitoringPermissions = false - @State private var selectedTab: SettingsTab = .general - - var body: some View { - TabView(selection: self.$selectedTab) { - GeneralSettings(state: self.state) - .tabItem { Label("General", systemImage: "gearshape") } - .tag(SettingsTab.general) - - VoiceWakeSettings(state: self.state) - .tabItem { Label("Voice Wake", systemImage: "waveform.circle") } - .tag(SettingsTab.voiceWake) - - ConfigSettings() - .tabItem { Label("Config", systemImage: "slider.horizontal.3") } - .tag(SettingsTab.config) - - PermissionsSettings( - status: self.permissionMonitor.status, - refresh: self.refreshPerms, - showOnboarding: { OnboardingController.shared.show() }) - .tabItem { Label("Permissions", systemImage: "lock.shield") } - .tag(SettingsTab.permissions) - - SessionsSettings() - .tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") } - .tag(SettingsTab.sessions) - - ToolsSettings() - .tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") } - .tag(SettingsTab.tools) - - if self.state.debugPaneEnabled { - DebugSettings() - .tabItem { Label("Debug", systemImage: "ant") } - .tag(SettingsTab.debug) - } - - AboutSettings() - .tabItem { Label("About", systemImage: "info.circle") } - .tag(SettingsTab.about) - } - .padding(.horizontal, 28) - .padding(.vertical, 22) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .onReceive(NotificationCenter.default.publisher(for: .clawdisSelectSettingsTab)) { note in - if let tab = note.object as? SettingsTab { - withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { - self.selectedTab = tab - } - } - } - .onAppear { - if let pending = SettingsTabRouter.consumePending() { - self.selectedTab = self.validTab(for: pending) - } - self.updatePermissionMonitoring(for: self.selectedTab) - } - .onChange(of: self.state.debugPaneEnabled) { _, enabled in - if !enabled, self.selectedTab == .debug { - self.selectedTab = .general - } - } - .onChange(of: self.selectedTab) { _, newValue in - self.updatePermissionMonitoring(for: newValue) - } - .onDisappear { self.stopPermissionMonitoring() } - .task { await self.refreshPerms() } - } - - private func validTab(for requested: SettingsTab) -> SettingsTab { - if requested == .debug, !self.state.debugPaneEnabled { return .general } - return requested - } - - @MainActor - private func refreshPerms() async { - await self.permissionMonitor.refreshNow() - } - - private func updatePermissionMonitoring(for tab: SettingsTab) { - let shouldMonitor = tab == .permissions - if shouldMonitor, !self.monitoringPermissions { - self.monitoringPermissions = true - PermissionMonitor.shared.register() - } else if !shouldMonitor, self.monitoringPermissions { - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } - } - - private func stopPermissionMonitoring() { - guard self.monitoringPermissions else { return } - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } -} - -enum SettingsTab: CaseIterable { - case general, tools, sessions, config, voiceWake, permissions, debug, about - static let windowWidth: CGFloat = 520 - static let windowHeight: CGFloat = 624 - var title: String { - switch self { - case .general: "General" - case .tools: "Tools" - case .sessions: "Sessions" - case .config: "Config" - case .voiceWake: "Voice Wake" - case .permissions: "Permissions" - case .debug: "Debug" - case .about: "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") -} - -enum VoiceWakeTestState: Equatable { - case idle - case requesting - case listening - case hearing(String) - case detected(String) - case failed(String) -} - -private struct AudioInputDevice: Identifiable, Equatable { - let uid: String - let name: String - var id: String { self.uid } -} - -actor MicLevelMonitor { - private let engine = AVAudioEngine() - private var update: (@Sendable (Double) -> Void)? - private var running = false - private var smoothedLevel: Double = 0 - - func start(onLevel: @Sendable @escaping (Double) -> Void) async throws { - self.update = onLevel - if self.running { return } - let input = self.engine.inputNode - let format = input.outputFormat(forBus: 0) - input.removeTap(onBus: 0) - input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in - guard let self else { return } - let level = Self.normalizedLevel(from: buffer) - Task { await self.push(level: level) } - } - self.engine.prepare() - try self.engine.start() - self.running = true - } - - func stop() { - guard self.running else { return } - self.engine.inputNode.removeTap(onBus: 0) - self.engine.stop() - self.running = false - } - - private func push(level: Double) { - self.smoothedLevel = (self.smoothedLevel * 0.45) + (level * 0.55) - guard let update else { return } - let value = self.smoothedLevel - Task { @MainActor in update(value) } - } - - private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Double { - guard let channel = buffer.floatChannelData?[0] else { return 0 } - let frameCount = Int(buffer.frameLength) - guard frameCount > 0 else { return 0 } - var sum: Float = 0 - for i in 0.. 0, 0dB -> 1 - return normalized - } -} - -@MainActor -final class VoiceWakeTester { - private let recognizer: SFSpeechRecognizer? - private let audioEngine = AVAudioEngine() - private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - private var recognitionTask: SFSpeechRecognitionTask? - private var isStopping = false - - init(locale: Locale = .current) { - self.recognizer = SFSpeechRecognizer(locale: locale) - } - - func start( - triggers: [String], - micID: String?, - localeID: String?, - onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async throws - { - guard self.recognitionTask == nil else { return } - self.isStopping = false - let chosenLocale = localeID.flatMap { Locale(identifier: $0) } ?? Locale.current - let recognizer = SFSpeechRecognizer(locale: chosenLocale) - guard let recognizer, recognizer.isAvailable else { - throw NSError( - domain: "VoiceWakeTester", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"]) - } - - guard Self.hasPrivacyStrings else { - throw NSError( - domain: "VoiceWakeTester", - code: 3, - userInfo: [ - NSLocalizedDescriptionKey: "Missing mic/speech privacy strings. Rebuild the mac app (scripts/restart-mac.sh) to include usage descriptions.", - ]) - } - - let granted = try await Self.ensurePermissions() - guard granted else { - throw NSError( - domain: "VoiceWakeTester", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "Microphone or speech permission denied"]) - } - - self.configureSession(preferredMicID: micID) - - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - let request = self.recognitionRequest - - let inputNode = self.audioEngine.inputNode - let format = inputNode.outputFormat(forBus: 0) - inputNode.removeTap(onBus: 0) - inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in - request?.append(buffer) - } - - self.audioEngine.prepare() - try self.audioEngine.start() - DispatchQueue.main.async { - onUpdate(.listening) - } - - guard let request = recognitionRequest else { return } - - self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in - let text = result?.bestTranscription.formattedString ?? "" - let matched = Self.matches(text: text, triggers: triggers) - let isFinal = result?.isFinal ?? false - let errorMessage = error?.localizedDescription - Task { @MainActor [weak self] in - // Hop to the MainActor before touching state to avoid isolation assertions - guard let self, !self.isStopping else { return } - self.handleResult( - matched: matched, - text: text, - isFinal: isFinal, - errorMessage: errorMessage, - onUpdate: onUpdate) - } - } - } - - func stop() { - self.isStopping = true - self.audioEngine.stop() - self.recognitionRequest?.endAudio() - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.recognitionRequest = nil - self.audioEngine.inputNode.removeTap(onBus: 0) - } - - @MainActor - private func handleResult( - matched: Bool, - text: String, - isFinal: Bool, - errorMessage: String?, - onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) - { - if matched, !text.isEmpty { - self.stop() - AppStateStore.shared.triggerVoiceEars() - onUpdate(.detected(text)) - return - } - if let errorMessage { - self.stop() - onUpdate(.failed(errorMessage)) - return - } - if isFinal { - self.stop() - onUpdate(text.isEmpty ? .failed("No speech detected") : .failed("No trigger heard: “\(text)”")) - } else { - onUpdate(text.isEmpty ? .listening : .hearing(text)) - } - } - - private func configureSession(preferredMicID: String?) { - // macOS uses the system default input for AVAudioEngine. Selection is stored for future - // pipeline wiring; test currently relies on the system default device. - _ = preferredMicID - } - - private static func matches(text: String, triggers: [String]) -> Bool { - let lowered = text.lowercased() - return triggers.contains { lowered.contains($0.lowercased()) } - } - - private nonisolated static func ensurePermissions() async throws -> Bool { - let speechStatus = SFSpeechRecognizer.authorizationStatus() - if speechStatus == .notDetermined { - let granted = await withCheckedContinuation { continuation in - SFSpeechRecognizer.requestAuthorization { status in - continuation.resume(returning: status == .authorized) - } - } - guard granted else { return false } - } else if speechStatus != .authorized { - return false - } - - let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) - switch micStatus { - case .authorized: return true - - case .notDetermined: - return await withCheckedContinuation { continuation in - AVCaptureDevice.requestAccess(for: .audio) { granted in - continuation.resume(returning: granted) - } - } - - default: - return false - } - } - - private static var hasPrivacyStrings: Bool { - let speech = Bundle.main.object(forInfoDictionaryKey: "NSSpeechRecognitionUsageDescription") as? String - let mic = Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") as? String - return speech?.isEmpty == false && mic?.isEmpty == false - } -} - -@MainActor -struct SettingsToggleRow: View { - let title: String - let subtitle: String? - @Binding var binding: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Toggle(isOn: self.$binding) { - Text(self.title) - .font(.body) - } - .toggleStyle(.checkbox) - - if let subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.footnote) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - -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: 18) { - if !self.state.onboardingSeen { - Text("Complete onboarding to finish setup") - .font(.callout.weight(.semibold)) - .foregroundColor(.accentColor) - .padding(.bottom, 2) - } - - VStack(alignment: .leading, spacing: 12) { - SettingsToggleRow( - title: "Clawdis active", - subtitle: "Pause to stop Clawdis background helpers and notifications.", - binding: self.activeBinding) - - SettingsToggleRow( - title: "Launch at login", - subtitle: "Automatically start Clawdis after you sign in.", - binding: self.$state.launchAtLogin) - - SettingsToggleRow( - title: "Show Dock icon", - subtitle: "Keep Clawdis visible in the Dock instead of menu-bar-only mode.", - binding: self.$state.showDockIcon) - - SettingsToggleRow( - title: "Enable debug tools", - subtitle: "Show the Debug tab with development utilities.", - binding: self.$state.debugPaneEnabled) - - LabeledContent("Default sound") { - Picker("Sound", selection: self.$state.defaultSound) { - Text("None").tag("") - Text("Glass").tag("Glass") - Text("Basso").tag("Basso") - Text("Ping").tag("Ping") - } - .labelsHidden() - .frame(width: 140) - } - } - - VStack(alignment: .leading, spacing: 6) { - Text("CLI helper") - .font(.callout.weight(.semibold)) - self.cliInstaller - } - - Spacer() - HStack { - Spacer() - Button("Quit Clawdis") { NSApp.terminate(nil) } - .buttonStyle(.borderedProminent) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 22) - } - - private var activeBinding: Binding { - Binding( - get: { !self.state.isPaused }, - set: { self.state.isPaused = !$0 }) - } - - private var cliInstaller: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - Button { - Task { await self.installCLI() } - } label: { - if self.isInstallingCLI { - ProgressView().controlSize(.small) - } else { - Text("Install CLI helper") - } - } - .disabled(self.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 !self.isInstallingCLI else { return } - self.isInstallingCLI = true - defer { isInstallingCLI = false } - await CLIInstaller.install { status in - await MainActor.run { self.cliStatus = status } - } - } -} - -struct VoiceWakeSettings: View { - @ObservedObject var state: AppState - @State private var testState: VoiceWakeTestState = .idle - @State private var tester = VoiceWakeTester() - @State private var isTesting = false - @State private var availableMics: [AudioInputDevice] = [] - @State private var loadingMics = false - @State private var meterLevel: Double = 0 - @State private var meterError: String? - private let meter = MicLevelMonitor() - @State private var availableLocales: [Locale] = [] - - private struct IndexedWord: Identifiable { - let id: Int - let value: String - } - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - SettingsToggleRow( - title: "Enable Voice Wake", - subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands. Voice recognition runs fully on-device.", - binding: self.$state.swabbleEnabled) - .disabled(!voiceWakeSupported) - - if !voiceWakeSupported { - Label("Voice Wake requires macOS 26 or newer.", systemImage: "exclamationmark.triangle.fill") - .font(.callout) - .foregroundStyle(.yellow) - .padding(8) - .background(Color.secondary.opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - - self.localePicker - self.micPicker - self.levelMeter - - self.testCard - - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Trigger words") - .font(.callout.weight(.semibold)) - Spacer() - Button { - self.addWord() - } label: { - Label("Add word", systemImage: "plus") - } - .disabled(self.state.swabbleTriggerWords - .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) - - Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } - } - - Table(self.indexedWords) { - TableColumn("Word") { row in - TextField("Wake word", text: self.binding(for: row.id)) - .textFieldStyle(.roundedBorder) - } - TableColumn("") { row in - Button { - self.removeWord(at: row.id) - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - .help("Remove trigger word") - } - .width(36) - } - .frame(minHeight: 180) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.secondary.opacity(0.25), lineWidth: 1)) - - Text( - "Clawdis reacts when any trigger appears in a transcription. Keep them short to avoid false positives.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .task { await self.loadMicsIfNeeded() } - .task { await self.loadLocalesIfNeeded() } - .task { await self.restartMeter() } - .onChange(of: self.state.voiceWakeMicID) { _, _ in - Task { await self.restartMeter() } - } - .onDisappear { - Task { await self.meter.stop() } - } - } - - private var indexedWords: [IndexedWord] { - self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) } - } - - private var testCard: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("Test Voice Wake") - .font(.callout.weight(.semibold)) - Spacer() - Button(action: self.toggleTest) { - Label( - self.isTesting ? "Stop" : "Start test", - systemImage: self.isTesting ? "stop.circle.fill" : "play.circle") - } - .buttonStyle(.borderedProminent) - .tint(self.isTesting ? .red : .accentColor) - } - - HStack(spacing: 8) { - self.statusIcon - VStack(alignment: .leading, spacing: 4) { - Text(self.statusText) - .font(.subheadline) - if case let .detected(text) = testState { - Text("Heard: \(text)") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - Spacer() - } - .padding(10) - .background(.quaternary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .padding(.vertical, 2) - } - - private var statusIcon: some View { - switch self.testState { - case .idle: - AnyView(Image(systemName: "waveform").foregroundStyle(.secondary)) - - case .requesting: - AnyView(ProgressView().controlSize(.small)) - - case .listening, .hearing: - AnyView( - Image(systemName: "ear.and.waveform") - .symbolEffect(.pulse) - .foregroundStyle(Color.accentColor)) - - case .detected: - AnyView(Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)) - - case .failed: - AnyView(Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)) - } - } - - private var statusText: String { - switch self.testState { - case .idle: - "Press start, say a trigger word, and wait for detection." - - case .requesting: - "Requesting mic & speech permission…" - - case .listening: - "Listening… say your trigger word." - - case let .hearing(text): - "Heard: \(text)" - - case .detected: - "Voice wake detected!" - - case let .failed(reason): - reason - } - } - - private func addWord() { - self.state.swabbleTriggerWords.append("") - } - - private func removeWord(at index: Int) { - guard self.state.swabbleTriggerWords.indices.contains(index) else { return } - self.state.swabbleTriggerWords.remove(at: index) - } - - private func binding(for index: Int) -> Binding { - Binding( - get: { - guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" } - return self.state.swabbleTriggerWords[index] - }, - set: { newValue in - guard self.state.swabbleTriggerWords.indices.contains(index) else { return } - self.state.swabbleTriggerWords[index] = newValue - }) - } - - private func toggleTest() { - guard voiceWakeSupported else { - self.testState = .failed("Voice Wake requires macOS 26 or newer.") - return - } - if self.isTesting { - self.tester.stop() - self.isTesting = false - self.testState = .idle - return - } - - let triggers = self.sanitizedTriggers() - self.isTesting = true - self.testState = .requesting - Task { @MainActor in - do { - try await self.tester.start( - triggers: triggers, - micID: self.state.voiceWakeMicID.isEmpty ? nil : self.state.voiceWakeMicID, - localeID: self.state.voiceWakeLocaleID, - onUpdate: { newState in - DispatchQueue.main.async { [self] in - self.testState = newState - if case .detected = newState { self.isTesting = false } - if case .failed = newState { self.isTesting = false } - } - }) - // timeout after 10s - try await Task.sleep(nanoseconds: 10 * 1_000_000_000) - if self.isTesting { - self.tester.stop() - self.testState = .failed("Timeout: no trigger heard") - self.isTesting = false - } - } catch { - self.tester.stop() - self.testState = .failed(error.localizedDescription) - self.isTesting = false - } - } - } - - private func sanitizedTriggers() -> [String] { - let cleaned = self.state.swabbleTriggerWords - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned - } - - private var micPicker: some View { - VStack(alignment: .leading, spacing: 6) { - LabeledContent("Microphone") { - Picker("Microphone", selection: self.$state.voiceWakeMicID) { - Text("System default").tag("") - ForEach(self.availableMics) { mic in - Text(mic.name).tag(mic.uid) - } - } - .labelsHidden() - .frame(width: 260) - } - if self.loadingMics { - ProgressView().controlSize(.small) - } - } - } - - private var localePicker: some View { - VStack(alignment: .leading, spacing: 6) { - LabeledContent("Recognition language") { - Picker("Language", selection: self.$state.voiceWakeLocaleID) { - let current = Locale(identifier: Locale.current.identifier) - Text("\(self.friendlyName(for: current)) (System)").tag(Locale.current.identifier) - ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in - if id != Locale.current.identifier { - Text(self.friendlyName(for: Locale(identifier: id))).tag(id) - } - } - } - .labelsHidden() - .frame(width: 260) - } - - if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Additional languages") - .font(.footnote.weight(.semibold)) - ForEach( - Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()), - id: \.offset) - { idx, localeID in - HStack(spacing: 8) { - Picker("Extra \(idx + 1)", selection: Binding( - get: { localeID }, - set: { newValue in - guard self.state - .voiceWakeAdditionalLocaleIDs.indices - .contains(idx) else { return } - self.state - .voiceWakeAdditionalLocaleIDs[idx] = - newValue - })) { - ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in - Text(self.friendlyName(for: Locale(identifier: id))).tag(id) - } - } - .labelsHidden() - .frame(width: 220) - - Button { - guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return } - self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx) - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - .help("Remove language") - } - } - - Button { - if let first = availableLocales.first { - self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier) - } - } label: { - Label("Add language", systemImage: "plus") - } - .disabled(self.availableLocales.isEmpty) - } - .padding(.top, 4) - } else { - Button { - if let first = availableLocales.first { - self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier) - } - } label: { - Label("Add additional language", systemImage: "plus") - } - .buttonStyle(.link) - .disabled(self.availableLocales.isEmpty) - .padding(.top, 4) - } - - Text("Languages are tried in order. Models may need a first-use download on macOS 26.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - @MainActor - private func loadMicsIfNeeded() async { - guard self.availableMics.isEmpty, !self.loadingMics else { return } - self.loadingMics = true - let discovery = AVCaptureDevice.DiscoverySession( - deviceTypes: [.external, .microphone], - mediaType: .audio, - position: .unspecified) - self.availableMics = discovery.devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) } - self.loadingMics = false - } - - @MainActor - private func loadLocalesIfNeeded() async { - guard self.availableLocales.isEmpty else { return } - self.availableLocales = Array(SFSpeechRecognizer.supportedLocales()).sorted { lhs, rhs in - self.friendlyName(for: lhs) - .localizedCaseInsensitiveCompare(self.friendlyName(for: rhs)) == .orderedAscending - } - } - - /// Produce a human-friendly label without odd BCP-47 variants (rg=zzzz, calendar, collation, numbering). - private func friendlyName(for locale: Locale) -> String { - let cleanedID = self.normalizedLocaleIdentifier(locale.identifier) - let cleanLocale = Locale(identifier: cleanedID) - - if let langCode = cleanLocale.language.languageCode?.identifier, - let lang = cleanLocale.localizedString(forLanguageCode: langCode), - let regionCode = cleanLocale.region?.identifier, - let region = cleanLocale.localizedString(forRegionCode: regionCode) - { - return "\(lang) (\(region))" - } - if let langCode = cleanLocale.language.languageCode?.identifier, - let lang = cleanLocale.localizedString(forLanguageCode: langCode) - { - return lang - } - return cleanLocale.localizedString(forIdentifier: cleanedID) ?? cleanedID - } - - /// Strip uncommon BCP-47 subtags so labels stay readable (e.g. remove @rg=zzzz, -u- extensions). - private func normalizedLocaleIdentifier(_ raw: String) -> String { - var trimmed = raw - if let at = trimmed.firstIndex(of: "@") { - trimmed = String(trimmed[.. 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: self.status, refresh: self.refresh) - .padding(.horizontal, 2) - .padding(.vertical, 6) - - Button("Show onboarding") { self.showOnboarding() } - .buttonStyle(.bordered) - Spacer() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - } -} - -struct DebugSettings: View { - @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath - @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 - @State private var modelsCount: Int? - @State private var modelsLoading = false - @State private var modelsError: String? - @ObservedObject private var relayManager = RelayProcessManager.shared - @State private var relayRootInput: String = RelayProcessManager.shared.projectRootPath() - - 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) } - LabeledContent("Relay status") { - VStack(alignment: .leading, spacing: 2) { - Text(self.relayManager.status.label) - Text("Restarts: \(self.relayManager.restartCount)") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - VStack(alignment: .leading, spacing: 4) { - Text("Relay stdout/stderr") - .font(.caption.weight(.semibold)) - ScrollView { - Text(self.relayManager.log.isEmpty ? "—" : self.relayManager.log) - .font(.caption.monospaced()) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } - .frame(height: 180) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2))) - } - VStack(alignment: .leading, spacing: 6) { - Text("Clawdis project root") - .font(.caption.weight(.semibold)) - HStack(spacing: 8) { - TextField("Path to clawdis repo", text: self.$relayRootInput) - .textFieldStyle(.roundedBorder) - .font(.caption.monospaced()) - .onSubmit { self.saveRelayRoot() } - Button("Save") { self.saveRelayRoot() } - .buttonStyle(.borderedProminent) - Button("Reset") { - let def = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Projects/clawdis").path - self.relayRootInput = def - self.saveRelayRoot() - } - .buttonStyle(.bordered) - } - Text("Used for pnpm/node fallback and PATH population when launching the relay.") - .font(.caption2) - .foregroundStyle(.secondary) - } - LabeledContent("Model catalog") { - VStack(alignment: .leading, spacing: 6) { - Text(self.modelCatalogPath) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(2) - HStack(spacing: 8) { - Button { - self.chooseCatalogFile() - } label: { - Label("Choose models.generated.ts…", systemImage: "folder") - } - .buttonStyle(.bordered) - - Button { - Task { await self.reloadModels() } - } label: { - Label(self.modelsLoading ? "Reloading…" : "Reload models", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .disabled(self.modelsLoading) - } - if let modelsError { - Text(modelsError) - .font(.footnote) - .foregroundStyle(.secondary) - } else if let modelsCount { - Text("Loaded \(modelsCount) models") - .font(.footnote) - .foregroundStyle(.secondary) - } - Text("Used by the Config tab model picker; point at a different build when debugging.") - .font(.footnote) - .foregroundStyle(.tertiary) - } - } - Button("Send Test Notification") { - Task { _ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil) } - } - .buttonStyle(.bordered) - HStack { - Button("Restart app") { self.relaunch() } - Button("Reveal app in Finder") { self.revealApp() } - } - .buttonStyle(.bordered) - Spacer() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .task { await self.reloadModels() } - } - - private func chooseCatalogFile() { - let panel = NSOpenPanel() - panel.title = "Select models.generated.ts" - let tsType = UTType(filenameExtension: "ts") - ?? UTType(tag: "ts", tagClass: .filenameExtension, conformingTo: .sourceCode) - ?? .item - panel.allowedContentTypes = [tsType] - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent() - if panel.runModal() == .OK, let url = panel.url { - self.modelCatalogPath = url.path - self.modelCatalogReloadBump += 1 - Task { await self.reloadModels() } - } - } - - private func reloadModels() async { - guard !self.modelsLoading else { return } - self.modelsLoading = true - self.modelsError = nil - self.modelCatalogReloadBump += 1 - defer { self.modelsLoading = false } - do { - let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath) - self.modelsCount = loaded.count - } catch { - self.modelsCount = nil - self.modelsError = error.localizedDescription - } - } - - 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]) - } - - private func saveRelayRoot() { - RelayProcessManager.shared.setProjectRoot(path: self.relayRootInput) - } -} - -struct AboutSettings: View { - @State private var iconHover = false - - var body: some View { - VStack(spacing: 8) { - let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0) - Button { - if let url = URL(string: "https://github.com/steipete/clawdis") { - NSWorkspace.shared.open(url) - } - } label: { - Image(nsImage: appIcon) - .resizable() - .frame(width: 88, height: 88) - .cornerRadius(16) - .shadow(color: self.iconHover ? .accentColor.opacity(0.25) : .clear, radius: 8) - .scaleEffect(self.iconHover ? 1.06 : 1.0) - } - .buttonStyle(.plain) - .onHover { hover in - withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover } - } - - VStack(spacing: 3) { - Text("Clawdis") - .font(.title3.bold()) - Text("Version \(self.versionString)") - .foregroundStyle(.secondary) - if let buildTimestamp { - Text("Built \(buildTimestamp)") - .font(.footnote) - .foregroundStyle(.secondary) - } - Text("Menu bar companion for notifications, screenshots, and privileged agent actions.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 18) - } - - VStack(alignment: .center, spacing: 6) { - AboutLinkRow( - icon: "chevron.left.slash.chevron.right", - title: "GitHub", - url: "https://github.com/steipete/clawdis") - 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") - } - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - .padding(.vertical, 10) - - Text("© 2025 Peter Steinberger — MIT License.") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 4) - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.top, 4) - .padding(.horizontal, 24) - .padding(.bottom, 24) - } - - 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 - } - - private var buildTimestamp: String? { - guard let raw = Bundle.main.object(forInfoDictionaryKey: "ClawdisBuildTimestamp") as? String else { return nil } - let parser = ISO8601DateFormatter() - parser.formatOptions = [.withInternetDateTime] - guard let date = parser.date(from: raw) else { return raw } - - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - formatter.locale = .current - return formatter.string(from: date) - } -} - -@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: self.icon) - Text(self.title) - .underline(self.hovering, color: .accentColor) - } - .foregroundColor(.accentColor) - } - .buttonStyle(.plain) - .onHover { self.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: self.status[cap] ?? false) { - Task { await self.handle(cap) } - } - } - Button("Refresh status") { Task { await self.refresh() } } - .font(.footnote) - .padding(.top, 2) - } - } - - @MainActor - private func handle(_ cap: Capability) async { - Task { - _ = await PermissionManager.ensure([cap], interactive: true) - await self.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)" - self.runLaunchctl(["kickstart", "-k", userTarget]) - } - - static func stopClawdis() { - let userTarget = "gui/\(getuid())/\(launchdLabel)" - self.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(self.status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15)) - .frame(width: 32, height: 32) - Image(systemName: self.icon) - .foregroundStyle(self.status ? Color.green : Color.secondary) - } - VStack(alignment: .leading, spacing: 2) { - Text(self.title).font(.body.weight(.semibold)) - Text(self.subtitle).font(.caption).foregroundStyle(.secondary) - } - Spacer() - if self.status { - Label("Granted", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } else { - Button("Grant") { self.action() } - .buttonStyle(.bordered) - } - } - .padding(.vertical, 6) - } - - private var title: String { - switch self.capability { - case .notifications: "Notifications" - case .accessibility: "Accessibility" - case .screenRecording: "Screen Recording" - case .microphone: "Microphone" - case .speechRecognition: "Speech Recognition" - } - } - - private var subtitle: String { - switch self.capability { - case .notifications: "Show desktop alerts for agent activity" - case .accessibility: "Control UI elements when an action requires it" - case .screenRecording: "Capture the screen for context or screenshots" - case .microphone: "Allow Voice Wake and audio capture" - case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device" - } - } - - private var icon: String { - switch self.capability { - case .notifications: "bell" - case .accessibility: "hand.raised" - case .screenRecording: "display" - case .microphone: "mic" - case .speechRecognition: "waveform" - } - } -} - -struct MicLevelBar: View { - let level: Double - let segments: Int = 12 - - var body: some View { - HStack(spacing: 3) { - ForEach(0.. Double(idx) - RoundedRectangle(cornerRadius: 2) - .fill(fill ? self.segmentColor(for: idx) : Color.gray.opacity(0.35)) - .frame(width: 14, height: 10) - } - } - .padding(4) - .background( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.gray.opacity(0.25), lineWidth: 1)) - } - - private func segmentColor(for idx: Int) -> Color { - let fraction = Double(idx + 1) / Double(self.segments) - if fraction < 0.65 { return .green } - if fraction < 0.85 { return .yellow } - return .red - } -} - -// MARK: - Onboarding - -@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 = UIStrings.welcomeTitle - window.setContentSize(NSSize(width: 640, height: 560)) - window.styleMask = [.titled, .closable, .fullSizeContentView] - window.titlebarAppearsTransparent = true - window.titleVisibility = .hidden - window.isMovableByWindowBackground = true - window.center() - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - self.window = window - } - - func close() { - self.window?.close() - self.window = nil - } -} - -struct OnboardingView: View { - @State private var currentPage = 0 - @State private var isRequesting = false - @State private var installingCLI = false - @State private var cliStatus: String? - @State private var copied = false - @State private var monitoringPermissions = false - @ObservedObject private var state = AppStateStore.shared - @ObservedObject private var permissionMonitor = PermissionMonitor.shared - - private let pageWidth: CGFloat = 640 - private let contentHeight: CGFloat = 260 - private let permissionsPageIndex = 2 - private var pageCount: Int { 6 } - private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } - private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac" - - var body: some View { - VStack(spacing: 0) { - GlowingClawdisIcon(size: 156) - .padding(.top, 40) - .padding(.bottom, 20) - .frame(height: 240) - - GeometryReader { _ in - HStack(spacing: 0) { - self.welcomePage().frame(width: self.pageWidth) - self.focusPage().frame(width: self.pageWidth) - self.permissionsPage().frame(width: self.pageWidth) - self.cliPage().frame(width: self.pageWidth) - self.launchPage().frame(width: self.pageWidth) - self.readyPage().frame(width: self.pageWidth) - } - .offset(x: CGFloat(-self.currentPage) * self.pageWidth) - .animation( - .interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), - value: self.currentPage) - .frame(height: self.contentHeight, alignment: .top) - .clipped() - } - .frame(height: 260) - - self.navigationBar - } - .frame(width: self.pageWidth, height: 560) - .background(Color(NSColor.windowBackgroundColor)) - .onAppear { - self.currentPage = 0 - self.updatePermissionMonitoring(for: 0) - } - .onChange(of: self.currentPage) { _, newValue in - self.updatePermissionMonitoring(for: newValue) - } - .onDisappear { self.stopPermissionMonitoring() } - .task { await self.refreshPerms() } - } - - private func welcomePage() -> some View { - self.onboardingPage { - Text("Welcome to Clawdis") - .font(.largeTitle.weight(.semibold)) - Text("Your macOS menu bar companion for notifications, screenshots, and privileged agent actions.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) - Text("Quick steps with live permission checks and the helper CLI so you can finish setup in minutes.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func focusPage() -> some View { - self.onboardingPage { - Text("What Clawdis handles") - .font(.largeTitle.weight(.semibold)) - self.onboardingCard { - self.featureRow( - title: "Owns the TCC prompts", - subtitle: "Requests Notifications, Accessibility, and Screen Recording so your agents stay unblocked.", - systemImage: "lock.shield") - self.featureRow( - title: "Native notifications", - subtitle: "Shows desktop toasts for agent events with your preferred sound.", - systemImage: "bell.and.waveform") - self.featureRow( - title: "Privileged helpers", - subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI with the right permissions.", - systemImage: "terminal") - } - } - } - - private func permissionsPage() -> some View { - self.onboardingPage { - Text("Grant permissions") - .font(.largeTitle.weight(.semibold)) - Text("Approve these once and the helper CLI reuses the same grants.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard { - ForEach(Capability.allCases, id: \.self) { cap in - PermissionRow(capability: cap, status: self.permissionMonitor.status[cap] ?? false) { - Task { await self.request(cap) } - } - } - - HStack(spacing: 12) { - Button("Refresh status") { Task { await self.refreshPerms() } } - .controlSize(.small) - if self.isRequesting { - ProgressView() - .controlSize(.small) - } - } - .padding(.top, 4) - } - } - } - - private func cliPage() -> some View { - self.onboardingPage { - Text("Install the helper CLI") - .font(.largeTitle.weight(.semibold)) - Text("Link `clawdis-mac` so scripts and the agent can talk to this app.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard { - HStack(spacing: 12) { - Button { - Task { await self.installCLI() } - } label: { - if self.installingCLI { - ProgressView() - } else { - Text("Install helper") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.installingCLI) - - Button(self.copied ? "Copied" : "Copy dev link") { - self.copyToPasteboard(self.devLinkCommand) - } - .disabled(self.installingCLI) - } - - if let cliStatus { - Text(cliStatus) - .font(.caption) - .foregroundStyle(.secondary) - } - - Text( - "We install into /usr/local/bin and /opt/homebrew/bin. Rerun anytime if you move the build output.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - - private func launchPage() -> some View { - self.onboardingPage { - Text("Keep it running") - .font(.largeTitle.weight(.semibold)) - Text("Let Clawdis launch with macOS so permissions and notifications are ready when automations start.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard { - HStack { - Spacer() - Toggle("Launch at login", isOn: self.$state.launchAtLogin) - .toggleStyle(.switch) - .onChange(of: self.state.launchAtLogin) { _, newValue in - AppStateStore.updateLaunchAtLogin(enabled: newValue) - } - Spacer() - } - Text( - "You can pause from the menu bar anytime. Settings keeps a \"Show onboarding\" button if you need to revisit.") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - } - } - } - - private func readyPage() -> some View { - self.onboardingPage { - Text("All set") - .font(.largeTitle.weight(.semibold)) - self.onboardingCard { - self.featureRow( - title: "Run the dashboard", - subtitle: "Use the CLI helper from your scripts, and reopen onboarding from Settings if you add a new user.", - systemImage: "checkmark.seal") - self.featureRow( - title: "Test a notification", - subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.", - systemImage: "bell.badge") - } - Text("Finish to save this version of onboarding. We'll reshow automatically when steps change.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - } - } - - private var navigationBar: some View { - HStack(spacing: 20) { - ZStack(alignment: .leading) { - Button(action: {}, label: { - Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly) - }) - .buttonStyle(.plain) - .opacity(0) - .disabled(true) - - if self.currentPage > 0 { - Button(action: { self.handleBack() }) { - Label("Back", systemImage: "chevron.left") - .labelStyle(.iconOnly) - } - .buttonStyle(.plain) - .foregroundColor(.secondary) - .opacity(0.8) - .transition(.opacity.combined(with: .scale(scale: 0.9))) - } - } - .frame(minWidth: 80, alignment: .leading) - - Spacer() - - HStack(spacing: 8) { - ForEach(0.. some View) -> some View { - VStack(spacing: 22) { - content() - Spacer() - } - .frame(width: self.pageWidth, alignment: .top) - } - - private func onboardingCard(@ViewBuilder _ content: () -> some View) -> some View { - VStack(alignment: .leading, spacing: 12) { - content() - } - .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 func featureRow(title: String, subtitle: String, systemImage: String) -> some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: systemImage) - .font(.title3.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .frame(width: 26) - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.headline) - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } - - private func handleBack() { - withAnimation { - self.currentPage = max(0, self.currentPage - 1) - } - } - - private func handleNext() { - if self.currentPage < self.pageCount - 1 { - withAnimation { self.currentPage += 1 } - } else { - self.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 { - await self.permissionMonitor.refreshNow() - } - - @MainActor - private func request(_ cap: Capability) async { - guard !self.isRequesting else { return } - self.isRequesting = true - defer { isRequesting = false } - _ = await PermissionManager.ensure([cap], interactive: true) - await self.refreshPerms() - } - - private func updatePermissionMonitoring(for pageIndex: Int) { - let shouldMonitor = pageIndex == self.permissionsPageIndex - if shouldMonitor, !self.monitoringPermissions { - self.monitoringPermissions = true - PermissionMonitor.shared.register() - } else if !shouldMonitor, self.monitoringPermissions { - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } - } - - private func stopPermissionMonitoring() { - guard self.monitoringPermissions else { return } - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } - - private func installCLI() async { - guard !self.installingCLI else { return } - self.installingCLI = true - defer { installingCLI = false } - await CLIInstaller.install { message in - await MainActor.run { self.cliStatus = message } - } - } - - private func copyToPasteboard(_ text: String) { - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(text, forType: .string) - self.copied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false } - } -} - -private struct GlowingClawdisIcon: View { - let size: CGFloat - let glowIntensity: Double - let enableFloating: Bool - - @State private var breathe = false - - init(size: CGFloat = 148, glowIntensity: Double = 0.35, enableFloating: Bool = true) { - self.size = size - self.glowIntensity = glowIntensity - self.enableFloating = enableFloating - } - - var body: some View { - ZStack { - Circle() - .fill( - LinearGradient( - colors: [ - Color.accentColor.opacity(self.glowIntensity), - Color.blue.opacity(self.glowIntensity * 0.6), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .blur(radius: 22) - .scaleEffect(self.breathe ? 1.12 : 0.95) - .opacity(0.9) - - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .frame(width: self.size, height: self.size) - .clipShape(RoundedRectangle(cornerRadius: self.size * 0.22, style: .continuous)) - .shadow(color: .black.opacity(0.18), radius: 14, y: 6) - .scaleEffect(self.breathe ? 1.02 : 1.0) - } - .frame(width: self.size + 60, height: self.size + 60) - .onAppear { - guard self.enableFloating else { return } - withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) { - self.breathe.toggle() - } - } - } -} - -extension VoiceWakeTester: @unchecked Sendable {} diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift new file mode 100644 index 000000000..ddcc44863 --- /dev/null +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -0,0 +1,118 @@ +import AppKit +import Foundation +import ServiceManagement +import SwiftUI + +@MainActor +final class AppState: ObservableObject { + @Published var isPaused: Bool { + didSet { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } + } + + @Published var defaultSound: String { + didSet { UserDefaults.standard.set(self.defaultSound, forKey: "clawdis.defaultSound") } + } + + @Published var launchAtLogin: Bool { + didSet { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } + } + + @Published var onboardingSeen: Bool { + didSet { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") } + } + + @Published var debugPaneEnabled: Bool { + didSet { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") } + } + + @Published var swabbleEnabled: Bool { + didSet { UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey) } + } + + @Published var swabbleTriggerWords: [String] { + didSet { + let cleaned = self.swabbleTriggerWords.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + UserDefaults.standard.set(cleaned, forKey: swabbleTriggersKey) + if cleaned.count != self.swabbleTriggerWords.count { + self.swabbleTriggerWords = cleaned + } + } + } + + @Published var showDockIcon: Bool { + didSet { + UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey) + AppActivationPolicy.apply(showDockIcon: self.showDockIcon) + } + } + + @Published var voiceWakeMicID: String { + didSet { UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey) } + } + + @Published var voiceWakeLocaleID: String { + didSet { UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey) } + } + + @Published var voiceWakeAdditionalLocaleIDs: [String] { + didSet { UserDefaults.standard.set(self.voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) } + } + + @Published var isWorking: Bool = false + @Published var earBoostActive: Bool = false + + private var earBoostTask: Task? = nil + + 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") + let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey) + self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false + self.swabbleTriggerWords = UserDefaults.standard + .stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers + self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey) + self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? "" + self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier + self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard + .stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? [] + } + + func triggerVoiceEars(ttl: TimeInterval = 5) { + self.earBoostTask?.cancel() + self.earBoostActive = true + self.earBoostTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000)) + await MainActor.run { [weak self] in self?.earBoostActive = false } + } + } + + func setWorking(_ working: Bool) { + self.isWorking = working + } +} + +@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() + } + } +} + +@MainActor +enum AppActivationPolicy { + static func apply(showDockIcon: Bool) { + NSApp.setActivationPolicy(showDockIcon ? .regular : .accessory) + } +} diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift new file mode 100644 index 000000000..cbd9c174b --- /dev/null +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -0,0 +1,197 @@ +import SwiftUI + +@MainActor +struct ConfigSettings: View { + @State private var configModel: String = "" + @State private var customModel: String = "" + @State private var configStorePath: String = SessionLoader.defaultStorePath + @State private var configSaving = false + @State private var hasLoaded = false + @State private var models: [ModelChoice] = [] + @State private var modelsLoading = false + @State private var modelError: String? + @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath + @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 + @State private var allowAutosave = false + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Clawdis CLI config") + .font(.title3.weight(.semibold)) + Text("Edit ~/.clawdis/clawdis.json (inbound.reply.agent/session).") + .font(.callout) + .foregroundStyle(.secondary) + + LabeledContent("Model") { + VStack(alignment: .leading, spacing: 6) { + Picker("Model", selection: self.$configModel) { + ForEach(self.models) { choice in + Text( + "\(choice.name) — \(choice.provider.uppercased())") + .tag(choice.id) + } + Text("Manual entry…").tag("__custom__") + } + .labelsHidden() + .frame(width: 360) + .disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty)) + .onChange(of: self.configModel) { _, _ in + self.autosaveConfig() + } + + if self.configModel == "__custom__" { + TextField("Enter model ID", text: self.$customModel) + .textFieldStyle(.roundedBorder) + .frame(width: 320) + .onChange(of: self.customModel) { _, newValue in + self.configModel = newValue + self.autosaveConfig() + } + } + + if let contextLabel = self.selectedContextLabel { + Text(contextLabel) + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let modelError { + Text(modelError) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + LabeledContent("Session store") { + TextField("Path", text: self.$configStorePath) + .textFieldStyle(.roundedBorder) + .frame(width: 360) + .onChange(of: self.configStorePath) { _, _ in + self.autosaveConfig() + } + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .onChange(of: self.modelCatalogPath) { _, _ in + Task { await self.loadModels() } + } + .onChange(of: self.modelCatalogReloadBump) { _, _ in + Task { await self.loadModels() } + } + .task { + guard !self.hasLoaded else { return } + self.hasLoaded = true + self.loadConfig() + await self.loadModels() + self.allowAutosave = true + } + } + + private func configURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".clawdis") + .appendingPathComponent("clawdis.json") + } + + private func loadConfig() { + let url = self.configURL() + guard let data = try? Data(contentsOf: url) else { + self.configModel = SessionLoader.fallbackModel + self.configStorePath = SessionLoader.defaultStorePath + return + } + guard + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let inbound = parsed["inbound"] as? [String: Any], + let reply = inbound["reply"] as? [String: Any] + else { + return + } + + let session = reply["session"] as? [String: Any] + let agent = reply["agent"] as? [String: Any] + self.configStorePath = (session?["store"] as? String) ?? SessionLoader.defaultStorePath + let loadedModel = (agent?["model"] as? String) ?? "" + if !loadedModel.isEmpty { + self.configModel = loadedModel + self.customModel = loadedModel + } else { + self.configModel = SessionLoader.fallbackModel + self.customModel = SessionLoader.fallbackModel + } + } + + private func autosaveConfig() { + guard self.allowAutosave else { return } + Task { await self.saveConfig() } + } + + private func saveConfig() async { + guard !self.configSaving else { return } + self.configSaving = true + defer { self.configSaving = false } + + var session: [String: Any] = [:] + var agent: [String: Any] = [:] + + let trimmedStore = self.configStorePath.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedStore.isEmpty { session["store"] = trimmedStore } + + let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel) + .trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedModel = chosenModel + if !trimmedModel.isEmpty { agent["model"] = trimmedModel } + + let reply: [String: Any] = [ + "session": session, + "agent": agent, + ] + let inbound: [String: Any] = ["reply": reply] + let root: [String: Any] = ["inbound": inbound] + + do { + let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + let url = self.configURL() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + } catch {} + } + + private func loadModels() async { + guard !self.modelsLoading else { return } + self.modelsLoading = true + self.modelError = nil + do { + let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath) + self.models = loaded + if !self.configModel.isEmpty, !loaded.contains(where: { $0.id == self.configModel }) { + self.customModel = self.configModel + self.configModel = "__custom__" + } + } catch { + self.modelError = error.localizedDescription + self.models = [] + } + self.modelsLoading = false + } + + private var selectedContextLabel: String? { + let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel + guard + !chosenId.isEmpty, + let choice = self.models.first(where: { $0.id == chosenId }), + let context = choice.contextWindow + else { + return nil + } + + let human = context >= 1000 ? "\(context / 1000)k" : "\(context)" + return "Context window: \(human) tokens" + } +} diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift new file mode 100644 index 000000000..bc8e6328c --- /dev/null +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -0,0 +1,17 @@ +import Foundation + +let serviceName = "com.steipete.clawdis.xpc" +let launchdLabel = "com.steipete.clawdis" +let onboardingVersionKey = "clawdis.onboardingVersion" +let currentOnboardingVersion = 2 +let pauseDefaultsKey = "clawdis.pauseEnabled" +let swabbleEnabledKey = "clawdis.swabbleEnabled" +let swabbleTriggersKey = "clawdis.swabbleTriggers" +let showDockIconKey = "clawdis.showDockIcon" +let defaultVoiceWakeTriggers = ["clawd", "claude"] +let voiceWakeMicKey = "clawdis.voiceWakeMicID" +let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID" +let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs" +let modelCatalogPathKey = "clawdis.modelCatalogPath" +let modelCatalogReloadKey = "clawdis.modelCatalogReload" +let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift new file mode 100644 index 000000000..2e85b4237 --- /dev/null +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -0,0 +1,164 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +struct DebugSettings: View { + @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath + @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 + @State private var modelsCount: Int? + @State private var modelsLoading = false + @State private var modelsError: String? + @ObservedObject private var relayManager = RelayProcessManager.shared + @State private var relayRootInput: String = RelayProcessManager.shared.projectRootPath() + + 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) } + LabeledContent("Relay status") { + VStack(alignment: .leading, spacing: 2) { + Text(self.relayManager.status.label) + Text("Restarts: \(self.relayManager.restartCount)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + VStack(alignment: .leading, spacing: 4) { + Text("Relay stdout/stderr") + .font(.caption.weight(.semibold)) + ScrollView { + Text(self.relayManager.log.isEmpty ? "—" : self.relayManager.log) + .font(.caption.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(height: 180) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2))) + } + VStack(alignment: .leading, spacing: 6) { + Text("Clawdis project root") + .font(.caption.weight(.semibold)) + HStack(spacing: 8) { + TextField("Path to clawdis repo", text: self.$relayRootInput) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .onSubmit { self.saveRelayRoot() } + Button("Save") { self.saveRelayRoot() } + .buttonStyle(.borderedProminent) + Button("Reset") { + let def = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Projects/clawdis").path + self.relayRootInput = def + self.saveRelayRoot() + } + .buttonStyle(.bordered) + } + Text("Used for pnpm/node fallback and PATH population when launching the relay.") + .font(.caption2) + .foregroundStyle(.secondary) + } + LabeledContent("Model catalog") { + VStack(alignment: .leading, spacing: 6) { + Text(self.modelCatalogPath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + HStack(spacing: 8) { + Button { + self.chooseCatalogFile() + } label: { + Label("Choose models.generated.ts…", systemImage: "folder") + } + .buttonStyle(.bordered) + + Button { + Task { await self.reloadModels() } + } label: { + Label(self.modelsLoading ? "Reloading…" : "Reload models", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(self.modelsLoading) + } + if let modelsError { + Text(modelsError) + .font(.footnote) + .foregroundStyle(.secondary) + } else if let modelsCount { + Text("Loaded \(modelsCount) models") + .font(.footnote) + .foregroundStyle(.secondary) + } + Text("Used by the Config tab model picker; point at a different build when debugging.") + .font(.footnote) + .foregroundStyle(.tertiary) + } + } + Button("Send Test Notification") { + Task { _ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil) } + } + .buttonStyle(.bordered) + HStack { + Button("Restart app") { self.relaunch() } + Button("Reveal app in Finder") { self.revealApp() } + } + .buttonStyle(.bordered) + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .task { await self.reloadModels() } + } + + private func chooseCatalogFile() { + let panel = NSOpenPanel() + panel.title = "Select models.generated.ts" + let tsType = UTType(filenameExtension: "ts") + ?? UTType(tag: "ts", tagClass: .filenameExtension, conformingTo: .sourceCode) + ?? .item + panel.allowedContentTypes = [tsType] + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent() + if panel.runModal() == .OK, let url = panel.url { + self.modelCatalogPath = url.path + self.modelCatalogReloadBump += 1 + Task { await self.reloadModels() } + } + } + + private func reloadModels() async { + guard !self.modelsLoading else { return } + self.modelsLoading = true + self.modelsError = nil + self.modelCatalogReloadBump += 1 + defer { self.modelsLoading = false } + do { + let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath) + self.modelsCount = loaded.count + } catch { + self.modelsCount = nil + self.modelsError = error.localizedDescription + } + } + + 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]) + } + + private func saveRelayRoot() { + RelayProcessManager.shared.setProjectRoot(path: self.relayRootInput) + } +} diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift new file mode 100644 index 000000000..05b7578aa --- /dev/null +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -0,0 +1,110 @@ +import AppKit +import SwiftUI + +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: 18) { + if !self.state.onboardingSeen { + Text("Complete onboarding to finish setup") + .font(.callout.weight(.semibold)) + .foregroundColor(.accentColor) + .padding(.bottom, 2) + } + + VStack(alignment: .leading, spacing: 12) { + SettingsToggleRow( + title: "Clawdis active", + subtitle: "Pause to stop Clawdis background helpers and notifications.", + binding: self.activeBinding) + + SettingsToggleRow( + title: "Launch at login", + subtitle: "Automatically start Clawdis after you sign in.", + binding: self.$state.launchAtLogin) + + SettingsToggleRow( + title: "Show Dock icon", + subtitle: "Keep Clawdis visible in the Dock instead of menu-bar-only mode.", + binding: self.$state.showDockIcon) + + SettingsToggleRow( + title: "Enable debug tools", + subtitle: "Show the Debug tab with development utilities.", + binding: self.$state.debugPaneEnabled) + + LabeledContent("Default sound") { + Picker("Sound", selection: self.$state.defaultSound) { + Text("None").tag("") + Text("Glass").tag("Glass") + Text("Basso").tag("Basso") + Text("Ping").tag("Ping") + } + .labelsHidden() + .frame(width: 140) + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("CLI helper") + .font(.callout.weight(.semibold)) + self.cliInstaller + } + + Spacer() + HStack { + Spacer() + Button("Quit Clawdis") { NSApp.terminate(nil) } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 22) + } + + private var activeBinding: Binding { + Binding( + get: { !self.state.isPaused }, + set: { self.state.isPaused = !$0 }) + } + + private var cliInstaller: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Button { + Task { await self.installCLI() } + } label: { + if self.isInstallingCLI { + ProgressView().controlSize(.small) + } else { + Text("Install CLI helper") + } + } + .disabled(self.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 !self.isInstallingCLI else { return } + self.isInstallingCLI = true + defer { isInstallingCLI = false } + await CLIInstaller.install { status in + await MainActor.run { self.cliStatus = status } + } + } +} diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift new file mode 100644 index 000000000..1c03757e1 --- /dev/null +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -0,0 +1,423 @@ +import AppKit +import Foundation +import MenuBarExtraAccess +import SwiftUI + +@main +struct ClawdisApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + @StateObject private var state: AppState + @StateObject private var relayManager = RelayProcessManager.shared + @State private var statusItem: NSStatusItem? + @State private var isMenuPresented = false + + init() { + _state = StateObject(wrappedValue: AppStateStore.shared) + } + + var body: some Scene { + MenuBarExtra { MenuContent(state: self.state) } label: { + CritterStatusLabel( + isPaused: self.state.isPaused, + isWorking: self.state.isWorking, + earBoostActive: self.state.earBoostActive, + relayStatus: self.relayManager.status) + } + .menuBarExtraStyle(.menu) + .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in + self.statusItem = item + self.applyStatusItemAppearance(paused: self.state.isPaused) + } + .onChange(of: self.state.isPaused) { _, paused in + self.applyStatusItemAppearance(paused: paused) + self.relayManager.setActive(!paused) + } + + Settings { + SettingsRootView(state: self.state) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) + } + .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + .windowResizability(.contentSize) + } + + private func applyStatusItemAppearance(paused: Bool) { + self.statusItem?.button?.appearsDisabled = paused + } +} + +private struct MenuContent: View { + @ObservedObject var state: AppState + @ObservedObject private var relayManager = RelayProcessManager.shared + @Environment(\.openSettings) private var openSettings + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: self.activeBinding) { Text("Clawdis Active") } + Toggle(isOn: self.$state.swabbleEnabled) { Text("Voice Wake") } + .disabled(!voiceWakeSupported) + .opacity(voiceWakeSupported ? 1 : 0.5) + Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) } + self.relayStatusRow + Divider() + Button("Settings…") { self.open(tab: .general) } + .keyboardShortcut(",", modifiers: [.command]) + Button("About Clawdis") { self.open(tab: .about) } + Divider() + Button("Quit") { NSApplication.shared.terminate(nil) } + } + } + + private func open(tab: SettingsTab) { + SettingsTabRouter.request(tab) + NSApp.activate(ignoringOtherApps: true) + self.openSettings() + NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab) + } + + private var relayStatusRow: some View { + let status = self.relayManager.status + return HStack(spacing: 8) { + Circle() + .fill(self.statusColor(status)) + .frame(width: 8, height: 8) + Text(status.label) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + } + .padding(.vertical, 4) + } + + private func statusColor(_ status: RelayProcessManager.Status) -> Color { + switch status { + case .running: return .green + case .starting, .restarting: return .orange + case .failed: return .red + case .stopped: return .secondary + } + } + + private var activeBinding: Binding { + Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 }) + } + + private func primarySessionKey() -> String { + // Prefer canonical main session; fall back to most recent. + let storePath = SessionLoader.defaultStorePath + if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)), + let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) + { + if decoded.keys.contains("main") { return "main" } + + let sorted = decoded.sorted { a, b -> Bool in + let lhs = a.value.updatedAt ?? 0 + let rhs = b.value.updatedAt ?? 0 + return lhs > rhs + } + if let first = sorted.first { return first.key } + } + return "+1003" + } +} + +private struct CritterStatusLabel: View { + var isPaused: Bool + var isWorking: Bool + var earBoostActive: Bool + var relayStatus: RelayProcessManager.Status + + @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 { + ZStack(alignment: .bottomTrailing) { + Group { + if self.isPaused { + Image(nsImage: CritterIconRenderer.makeIcon(blink: 0)) + .frame(width: 18, height: 16) + } else { + Image(nsImage: CritterIconRenderer.makeIcon( + blink: self.blinkAmount, + legWiggle: max(self.legWiggle, self.isWorking ? 0.6 : 0), + earWiggle: self.earWiggle, + earScale: self.earBoostActive ? 1.9 : 1.0)) + .frame(width: 18, height: 16) + .rotationEffect(.degrees(self.wiggleAngle), anchor: .center) + .offset(x: self.wiggleOffset) + .onReceive(self.ticker) { now in + if now >= self.nextBlink { + self.blink() + self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5)) + } + + if now >= self.nextWiggle { + self.wiggle() + self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14)) + } + + if now >= self.nextLegWiggle { + self.wiggleLegs() + self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0)) + } + + if now >= self.nextEarWiggle { + self.wiggleEars() + self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0)) + } + + if self.isWorking { + self.scurry() + } + } + .onChange(of: self.isPaused) { _, _ in self.resetMotion() } + } + } + + if self.relayNeedsAttention { + Circle() + .fill(self.relayBadgeColor) + .frame(width: 8, height: 8) + .offset(x: 4, y: 4) + } + } + } + + private func resetMotion() { + self.blinkAmount = 0 + self.wiggleAngle = 0 + self.wiggleOffset = 0 + self.legWiggle = 0 + self.earWiggle = 0 + } + + private func blink() { + withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { + withAnimation(.easeOut(duration: 0.12)) { self.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)) { + self.wiggleAngle = targetAngle + self.wiggleOffset = targetOffset + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) { + withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) { + self.wiggleAngle = 0 + self.wiggleOffset = 0 + } + } + } + + private func wiggleLegs() { + let target = CGFloat.random(in: 0.35...0.9) + withAnimation(.easeInOut(duration: 0.14)) { + self.legWiggle = target + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 } + } + } + + private func scurry() { + let target = CGFloat.random(in: 0.7...1.0) + withAnimation(.easeInOut(duration: 0.12)) { + self.legWiggle = target + self.wiggleOffset = CGFloat.random(in: -0.6...0.6) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + withAnimation(.easeOut(duration: 0.16)) { + self.legWiggle = 0.25 + self.wiggleOffset = 0 + } + } + } + + private func wiggleEars() { + let target = CGFloat.random(in: -1.2...1.2) + withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { + self.earWiggle = target + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { + withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 } + } + } + + private var relayNeedsAttention: Bool { + switch self.relayStatus { + case .failed, .stopped: + return !self.isPaused + case .starting, .restarting, .running: + return false + } + } + + private var relayBadgeColor: Color { + switch self.relayStatus { + case .failed: return .red + case .stopped: return .orange + default: return .clear + } + } +} + +enum CritterIconRenderer { + private static let size = NSSize(width: 18, height: 16) + + static func makeIcon( + blink: CGFloat, + legWiggle: CGFloat = 0, + earWiggle: CGFloat = 0, + earScale: CGFloat = 1 + ) -> NSImage { + let image = NSImage(size: size) + image.lockFocus() + defer { image.unlockFocus() } + + guard let ctx = NSGraphicsContext.current?.cgContext else { return image } + + let w = self.size.width + let h = self.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 * earScale * (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 + + ctx.setFillColor(NSColor.labelColor.cgColor) + + ctx.addPath(CGPath( + roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH), + cornerWidth: bodyCorner, + cornerHeight: bodyCorner, + transform: nil)) + 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)) + 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() + + 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) { + self.state = AppStateStore.shared + AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) + if let state { + RelayProcessManager.shared.setActive(!state.isPaused) + } + self.startListener() + self.scheduleFirstRunOnboardingIfNeeded() + } + + func applicationWillTerminate(_ notification: Notification) { + RelayProcessManager.shared.stop() + } + + @MainActor + private func startListener() { + guard self.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 + } +} diff --git a/apps/macos/Sources/Clawdis/ModelCatalogLoader.swift b/apps/macos/Sources/Clawdis/ModelCatalogLoader.swift new file mode 100644 index 000000000..ebd5c6e97 --- /dev/null +++ b/apps/macos/Sources/Clawdis/ModelCatalogLoader.swift @@ -0,0 +1,62 @@ +import Foundation +import JavaScriptCore + +enum ModelCatalogLoader { + static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path + + static func load(from path: String) async throws -> [ModelChoice] { + let expanded = (path as NSString).expandingTildeInPath + let source = try String(contentsOfFile: expanded, encoding: .utf8) + let sanitized = self.sanitize(source: source) + + let ctx = JSContext() + ctx?.exceptionHandler = { _, exception in + if let exception { print("JS exception: \(exception)") } + } + ctx?.evaluateScript(sanitized) + guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else { + throw NSError( + domain: "ModelCatalogLoader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"]) + } + + var choices: [ModelChoice] = [] + for (provider, value) in rawModels { + guard let models = value as? [String: Any] else { continue } + for (id, payload) in models { + guard let dict = payload as? [String: Any] else { continue } + let name = dict["name"] as? String ?? id + let ctxWindow = dict["contextWindow"] as? Int + choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow)) + } + } + + return choices.sorted { lhs, rhs in + if lhs.provider == rhs.provider { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending + } + } + + private static func sanitize(source: String) -> String { + guard let exportRange = source.range(of: "export const MODELS"), + let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), + let lastBrace = source.lastIndex(of: "}") + else { + return "var MODELS = {}" + } + var body = String(source[firstBrace...lastBrace]) + body = body.replacingOccurrences( + of: #"(?m)\bsatisfies\s+[^,}\n]+"#, + with: "", + options: .regularExpression) + body = body.replacingOccurrences( + of: #"(?m)\bas\s+[^;,\n]+"#, + with: "", + options: .regularExpression) + return "var MODELS = \(body);" + } +} diff --git a/apps/macos/Sources/Clawdis/NotificationManager.swift b/apps/macos/Sources/Clawdis/NotificationManager.swift new file mode 100644 index 000000000..f2d51bb9b --- /dev/null +++ b/apps/macos/Sources/Clawdis/NotificationManager.swift @@ -0,0 +1,31 @@ +import Foundation +import UserNotifications + +@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 + } + } +} diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift new file mode 100644 index 000000000..dbc0878e9 --- /dev/null +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -0,0 +1,463 @@ +import AppKit +import ClawdisIPC +import SwiftUI + +enum UIStrings { + static let welcomeTitle = "Welcome to Clawdis" +} + +@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 = UIStrings.welcomeTitle + window.setContentSize(NSSize(width: 640, height: 560)) + window.styleMask = [.titled, .closable, .fullSizeContentView] + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + self.window = window + } + + func close() { + self.window?.close() + self.window = nil + } +} + +struct OnboardingView: View { + @State private var currentPage = 0 + @State private var isRequesting = false + @State private var installingCLI = false + @State private var cliStatus: String? + @State private var copied = false + @State private var monitoringPermissions = false + @ObservedObject private var state = AppStateStore.shared + @ObservedObject private var permissionMonitor = PermissionMonitor.shared + + private let pageWidth: CGFloat = 640 + private let contentHeight: CGFloat = 260 + private let permissionsPageIndex = 2 + private var pageCount: Int { 6 } + private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } + private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac" + + var body: some View { + VStack(spacing: 0) { + GlowingClawdisIcon(size: 156) + .padding(.top, 40) + .padding(.bottom, 20) + .frame(height: 240) + + GeometryReader { _ in + HStack(spacing: 0) { + self.welcomePage().frame(width: self.pageWidth) + self.focusPage().frame(width: self.pageWidth) + self.permissionsPage().frame(width: self.pageWidth) + self.cliPage().frame(width: self.pageWidth) + self.launchPage().frame(width: self.pageWidth) + self.readyPage().frame(width: self.pageWidth) + } + .offset(x: CGFloat(-self.currentPage) * self.pageWidth) + .animation( + .interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), + value: self.currentPage) + .frame(height: self.contentHeight, alignment: .top) + .clipped() + } + .frame(height: 260) + + self.navigationBar + } + .frame(width: self.pageWidth, height: 560) + .background(Color(NSColor.windowBackgroundColor)) + .onAppear { + self.currentPage = 0 + self.updatePermissionMonitoring(for: 0) + } + .onChange(of: self.currentPage) { _, newValue in + self.updatePermissionMonitoring(for: newValue) + } + .onDisappear { self.stopPermissionMonitoring() } + .task { await self.refreshPerms() } + } + + private func welcomePage() -> some View { + self.onboardingPage { + Text("Welcome to Clawdis") + .font(.largeTitle.weight(.semibold)) + Text("Your macOS menu bar companion for notifications, screenshots, and privileged agent actions.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + Text("Quick steps with live permission checks and the helper CLI so you can finish setup in minutes.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func focusPage() -> some View { + self.onboardingPage { + Text("What Clawdis handles") + .font(.largeTitle.weight(.semibold)) + self.onboardingCard { + self.featureRow( + title: "Owns the TCC prompts", + subtitle: "Requests Notifications, Accessibility, and Screen Recording so your agents stay unblocked.", + systemImage: "lock.shield") + self.featureRow( + title: "Native notifications", + subtitle: "Shows desktop toasts for agent events with your preferred sound.", + systemImage: "bell.and.waveform") + self.featureRow( + title: "Privileged helpers", + subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI with the right permissions.", + systemImage: "terminal") + } + } + } + + private func permissionsPage() -> some View { + self.onboardingPage { + Text("Grant permissions") + .font(.largeTitle.weight(.semibold)) + Text("Approve these once and the helper CLI reuses the same grants.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard { + ForEach(Capability.allCases, id: \.self) { cap in + PermissionRow(capability: cap, status: self.permissionMonitor.status[cap] ?? false) { + Task { await self.request(cap) } + } + } + + HStack(spacing: 12) { + Button("Refresh status") { Task { await self.refreshPerms() } } + .controlSize(.small) + if self.isRequesting { + ProgressView() + .controlSize(.small) + } + } + .padding(.top, 4) + } + } + } + + private func cliPage() -> some View { + self.onboardingPage { + Text("Install the helper CLI") + .font(.largeTitle.weight(.semibold)) + Text("Link `clawdis-mac` so scripts and the agent can talk to this app.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard { + HStack(spacing: 12) { + Button { + Task { await self.installCLI() } + } label: { + if self.installingCLI { + ProgressView() + } else { + Text("Install helper") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.installingCLI) + + Button(self.copied ? "Copied" : "Copy dev link") { + self.copyToPasteboard(self.devLinkCommand) + } + .disabled(self.installingCLI) + } + + if let cliStatus { + Text(cliStatus) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text( + "We install into /usr/local/bin and /opt/homebrew/bin. Rerun anytime if you move the build output.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + private func launchPage() -> some View { + self.onboardingPage { + Text("Keep it running") + .font(.largeTitle.weight(.semibold)) + Text("Let Clawdis launch with macOS so permissions and notifications are ready when automations start.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard { + HStack { + Spacer() + Toggle("Launch at login", isOn: self.$state.launchAtLogin) + .toggleStyle(.switch) + .onChange(of: self.state.launchAtLogin) { _, newValue in + AppStateStore.updateLaunchAtLogin(enabled: newValue) + } + Spacer() + } + Text( + "You can pause from the menu bar anytime. Settings keeps a \"Show onboarding\" button if you need to revisit.") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + } + } + + private func readyPage() -> some View { + self.onboardingPage { + Text("All set") + .font(.largeTitle.weight(.semibold)) + self.onboardingCard { + self.featureRow( + title: "Run the dashboard", + subtitle: "Use the CLI helper from your scripts, and reopen onboarding from Settings if you add a new user.", + systemImage: "checkmark.seal") + self.featureRow( + title: "Test a notification", + subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.", + systemImage: "bell.badge") + } + Text("Finish to save this version of onboarding. We'll reshow automatically when steps change.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + } + } + + private var navigationBar: some View { + HStack(spacing: 20) { + ZStack(alignment: .leading) { + Button(action: {}, label: { + Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly) + }) + .buttonStyle(.plain) + .opacity(0) + .disabled(true) + + if self.currentPage > 0 { + Button(action: { self.handleBack() }) { + Label("Back", systemImage: "chevron.left") + .labelStyle(.iconOnly) + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + .opacity(0.8) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + } + .frame(minWidth: 80, alignment: .leading) + + Spacer() + + HStack(spacing: 8) { + ForEach(0.. some View) -> some View { + VStack(spacing: 22) { + content() + Spacer() + } + .frame(width: self.pageWidth, alignment: .top) + } + + private func onboardingCard(@ViewBuilder _ content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 12) { + content() + } + .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 func featureRow(title: String, subtitle: String, systemImage: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 26) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + private func handleBack() { + withAnimation { + self.currentPage = max(0, self.currentPage - 1) + } + } + + private func handleNext() { + if self.currentPage < self.pageCount - 1 { + withAnimation { self.currentPage += 1 } + } else { + self.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 { + await self.permissionMonitor.refreshNow() + } + + @MainActor + private func request(_ cap: Capability) async { + guard !self.isRequesting else { return } + self.isRequesting = true + defer { isRequesting = false } + _ = await PermissionManager.ensure([cap], interactive: true) + await self.refreshPerms() + } + + private func updatePermissionMonitoring(for pageIndex: Int) { + let shouldMonitor = pageIndex == self.permissionsPageIndex + if shouldMonitor, !self.monitoringPermissions { + self.monitoringPermissions = true + PermissionMonitor.shared.register() + } else if !shouldMonitor, self.monitoringPermissions { + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + } + + private func stopPermissionMonitoring() { + guard self.monitoringPermissions else { return } + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + + private func installCLI() async { + guard !self.installingCLI else { return } + self.installingCLI = true + defer { installingCLI = false } + await CLIInstaller.install { message in + await MainActor.run { self.cliStatus = message } + } + } + + private func copyToPasteboard(_ text: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + self.copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false } + } +} + +private struct GlowingClawdisIcon: View { + let size: CGFloat + let glowIntensity: Double + let enableFloating: Bool + + @State private var breathe = false + + init(size: CGFloat = 148, glowIntensity: Double = 0.35, enableFloating: Bool = true) { + self.size = size + self.glowIntensity = glowIntensity + self.enableFloating = enableFloating + } + + var body: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [ + Color.accentColor.opacity(self.glowIntensity), + Color.blue.opacity(self.glowIntensity * 0.6), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .blur(radius: 22) + .scaleEffect(self.breathe ? 1.12 : 0.95) + .opacity(0.9) + + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: self.size, height: self.size) + .clipShape(RoundedRectangle(cornerRadius: self.size * 0.22, style: .continuous)) + .shadow(color: .black.opacity(0.18), radius: 14, y: 6) + .scaleEffect(self.breathe ? 1.02 : 1.0) + } + .frame(width: self.size + 60, height: self.size + 60) + .onAppear { + guard self.enableFloating else { return } + withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) { + self.breathe.toggle() + } + } + } +} diff --git a/apps/macos/Sources/Clawdis/PermissionManager.swift b/apps/macos/Sources/Clawdis/PermissionManager.swift new file mode 100644 index 000000000..90dcdd93f --- /dev/null +++ b/apps/macos/Sources/Clawdis/PermissionManager.swift @@ -0,0 +1,211 @@ +import AppKit +import ApplicationServices +import AVFoundation +import ClawdisIPC +import CoreGraphics +import Foundation +import Speech +import UserNotifications + +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 settings = await center.notificationSettings() + + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + results[cap] = true + + case .notDetermined: + if interactive { + let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + let updated = await center.notificationSettings() + results[cap] = granted && (updated.authorizationStatus == .authorized || updated + .authorizationStatus == .provisional) + } else { + results[cap] = false + } + + case .denied: + results[cap] = false + if interactive { + NotificationPermissionHelper.openSettings() + } + + @unknown default: + results[cap] = false + } + + case .accessibility: + let trusted = AXIsProcessTrusted() + results[cap] = trusted + if interactive, !trusted { + let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] + _ = AXIsProcessTrustedWithOptions(opts) + } + + case .screenRecording: + let granted = ScreenRecordingProbe.isAuthorized() + if interactive, !granted { + await ScreenRecordingProbe.requestAuthorization() + } + results[cap] = ScreenRecordingProbe.isAuthorized() + + case .microphone: + let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + if interactive, !granted { + let ok = await AVCaptureDevice.requestAccess(for: .audio) + results[cap] = ok + } else { + results[cap] = granted + } + + case .speechRecognition: + let status = SFSpeechRecognizer.authorizationStatus() + if status == .notDetermined, interactive { + let ok = await withCheckedContinuation { cont in + SFSpeechRecognizer.requestAuthorization { auth in cont.resume(returning: auth == .authorized) } + } + results[cap] = ok + } else { + results[cap] = status == .authorized + } + } + } + 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 + } + + case .microphone: + results[cap] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + + case .speechRecognition: + results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized + } + } + return results + } +} + +enum NotificationPermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.Notifications-Settings.extension", + "x-apple.systempreferences:com.apple.preference.notifications", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + +@MainActor +final class PermissionMonitor: ObservableObject { + static let shared = PermissionMonitor() + + @Published private(set) var status: [Capability: Bool] = [:] + + private var monitorTimer: Timer? + private var isChecking = false + private var registrations = 0 + private var lastCheck: Date? + private let minimumCheckInterval: TimeInterval = 0.5 + + func register() { + self.registrations += 1 + if self.registrations == 1 { + self.startMonitoring() + } + } + + func unregister() { + guard self.registrations > 0 else { return } + self.registrations -= 1 + if self.registrations == 0 { + self.stopMonitoring() + } + } + + func refreshNow() async { + await self.checkStatus(force: true) + } + + private func startMonitoring() { + Task { await self.checkStatus(force: true) } + + self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.checkStatus(force: false) + } + } + } + + private func stopMonitoring() { + self.monitorTimer?.invalidate() + self.monitorTimer = nil + self.lastCheck = nil + } + + private func checkStatus(force: Bool) async { + if self.isChecking { return } + let now = Date() + if !force, let lastCheck, now.timeIntervalSince(lastCheck) < self.minimumCheckInterval { + return + } + + self.isChecking = true + self.lastCheck = now + + let latest = await PermissionManager.status() + if latest != self.status { + self.status = latest + } + + self.isChecking = false + } +} + +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() + } + } +} diff --git a/apps/macos/Sources/Clawdis/PermissionsSettings.swift b/apps/macos/Sources/Clawdis/PermissionsSettings.swift new file mode 100644 index 000000000..8a2c60c24 --- /dev/null +++ b/apps/macos/Sources/Clawdis/PermissionsSettings.swift @@ -0,0 +1,111 @@ +import ClawdisIPC +import SwiftUI + +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: self.status, refresh: self.refresh) + .padding(.horizontal, 2) + .padding(.vertical, 6) + + Button("Show onboarding") { self.showOnboarding() } + .buttonStyle(.bordered) + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + } +} + +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: self.status[cap] ?? false) { + Task { await self.handle(cap) } + } + } + Button("Refresh status") { Task { await self.refresh() } } + .font(.footnote) + .padding(.top, 2) + } + } + + @MainActor + private func handle(_ cap: Capability) async { + Task { + _ = await PermissionManager.ensure([cap], interactive: true) + await self.refresh() + } + } +} + +struct PermissionRow: View { + let capability: Capability + let status: Bool + let action: () -> Void + + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle().fill(self.status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15)) + .frame(width: 32, height: 32) + Image(systemName: self.icon) + .foregroundStyle(self.status ? Color.green : Color.secondary) + } + VStack(alignment: .leading, spacing: 2) { + Text(self.title).font(.body.weight(.semibold)) + Text(self.subtitle).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if self.status { + Label("Granted", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Button("Grant") { self.action() } + .buttonStyle(.bordered) + } + } + .padding(.vertical, 6) + } + + private var title: String { + switch self.capability { + case .notifications: "Notifications" + case .accessibility: "Accessibility" + case .screenRecording: "Screen Recording" + case .microphone: "Microphone" + case .speechRecognition: "Speech Recognition" + } + } + + private var subtitle: String { + switch self.capability { + case .notifications: "Show desktop alerts for agent activity" + case .accessibility: "Control UI elements when an action requires it" + case .screenRecording: "Capture the screen for context or screenshots" + case .microphone: "Allow Voice Wake and audio capture" + case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device" + } + } + + private var icon: String { + switch self.capability { + case .notifications: "bell" + case .accessibility: "hand.raised" + case .screenRecording: "display" + case .microphone: "mic" + case .speechRecognition: "waveform" + } + } +} diff --git a/apps/macos/Sources/Clawdis/Screenshotter.swift b/apps/macos/Sources/Clawdis/Screenshotter.swift new file mode 100644 index 000000000..cc1c6902e --- /dev/null +++ b/apps/macos/Sources/Clawdis/Screenshotter.swift @@ -0,0 +1,80 @@ +import AppKit +import CoreGraphics +import Foundation +@preconcurrency import ScreenCaptureKit +import VideoToolbox + +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 { + content.displays.first(where: { $0.displayID == displayID }) + } else { + 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 self.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 } + + self.delivered = true + self.continuation?.resume(returning: data) + self.continuation = nil + } +} diff --git a/apps/macos/Sources/Clawdis/SessionData.swift b/apps/macos/Sources/Clawdis/SessionData.swift new file mode 100644 index 000000000..0a265edb7 --- /dev/null +++ b/apps/macos/Sources/Clawdis/SessionData.swift @@ -0,0 +1,269 @@ +import Foundation +import SwiftUI + +struct SessionEntryRecord: Decodable { + let sessionId: String? + let updatedAt: Double? + let systemSent: Bool? + let abortedLastRun: Bool? + let thinkingLevel: String? + let verboseLevel: String? + let inputTokens: Int? + let outputTokens: Int? + let totalTokens: Int? + let model: String? + let contextTokens: Int? +} + +struct SessionTokenStats { + let input: Int + let output: Int + let total: Int + let contextTokens: Int + + var percentUsed: Int? { + guard self.contextTokens > 0, self.total > 0 else { return nil } + return min(100, Int(round((Double(self.total) / Double(self.contextTokens)) * 100))) + } + + var summary: String { + let parts = ["in \(input)", "out \(output)", "total \(total)"] + var text = parts.joined(separator: " | ") + if let percentUsed { + text += " (\(percentUsed)% of \(self.contextTokens))" + } + return text + } +} + +struct SessionRow: Identifiable { + let id: String + let key: String + let kind: SessionKind + let updatedAt: Date? + let sessionId: String? + let thinkingLevel: String? + let verboseLevel: String? + let systemSent: Bool + let abortedLastRun: Bool + let tokens: SessionTokenStats + let model: String? + + var ageText: String { relativeAge(from: self.updatedAt) } + + var flagLabels: [String] { + var flags: [String] = [] + if let thinkingLevel { flags.append("think \(thinkingLevel)") } + if let verboseLevel { flags.append("verbose \(verboseLevel)") } + if self.systemSent { flags.append("system sent") } + if self.abortedLastRun { flags.append("aborted") } + return flags + } +} + +enum SessionKind { + case direct, group, global, unknown + + static func from(key: String) -> SessionKind { + if key == "global" { return .global } + if key.hasPrefix("group:") { return .group } + if key == "unknown" { return .unknown } + return .direct + } + + var label: String { + switch self { + case .direct: "Direct" + case .group: "Group" + case .global: "Global" + case .unknown: "Unknown" + } + } + + var tint: Color { + switch self { + case .direct: .accentColor + case .group: .orange + case .global: .purple + case .unknown: .gray + } + } +} + +struct SessionDefaults { + let model: String + let contextTokens: Int +} + +struct ModelChoice: Identifiable, Hashable { + let id: String + let name: String + let provider: String + let contextWindow: Int? +} + +extension String? { + var isNilOrEmpty: Bool { + switch self { + case .none: true + case let .some(value): value.isEmpty + } + } +} + +extension [String] { + fileprivate func dedupedPreserveOrder() -> [String] { + var seen = Set() + var result: [String] = [] + for item in self { + if !seen.contains(item) { + seen.insert(item) + result.append(item) + } + } + return result + } +} + +struct SessionConfigHints { + let storePath: String? + let model: String? + let contextTokens: Int? +} + +enum SessionLoadError: LocalizedError { + case missingStore(String) + case decodeFailed(String) + + var errorDescription: String? { + switch self { + case let .missingStore(path): + "No session store found at \(path) yet. Send or receive a message to create it." + + case let .decodeFailed(reason): + "Could not read the session store: \(reason)" + } + } +} + +enum SessionLoader { + static let fallbackModel = "claude-opus-4-5" + static let fallbackContextTokens = 200_000 + + static let defaultStorePath = standardize( + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".clawdis/sessions/sessions.json").path) + + private static let legacyStorePaths: [String] = [ + standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".clawdis/sessions.json") + .path), + standardize(FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".warelay/sessions/sessions.json").path), + standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".warelay/sessions.json") + .path), + ] + + static func configHints() -> SessionConfigHints { + let configURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".clawdis/clawdis.json") + guard let data = try? Data(contentsOf: configURL) else { + return SessionConfigHints(storePath: nil, model: nil, contextTokens: nil) + } + guard let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return SessionConfigHints(storePath: nil, model: nil, contextTokens: nil) + } + + let inbound = parsed["inbound"] as? [String: Any] + let reply = inbound?["reply"] as? [String: Any] + let session = reply?["session"] as? [String: Any] + let agent = reply?["agent"] as? [String: Any] + + let store = session?["store"] as? String + let model = agent?["model"] as? String + let contextTokens = (agent?["contextTokens"] as? NSNumber)?.intValue + + return SessionConfigHints( + storePath: store.map { self.standardize($0) }, + model: model, + contextTokens: contextTokens) + } + + static func resolveStorePath(override: String?) -> String { + let preferred = self.standardize(override ?? self.defaultStorePath) + let candidates = [preferred] + self.legacyStorePaths + if let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }) { + return existing + } + return preferred + } + + static func availableModels(storeOverride: String?) -> [String] { + let path = self.resolveStorePath(override: storeOverride) + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) + else { + return [self.fallbackModel] + } + let models = decoded.values.compactMap(\.model) + return ([self.fallbackModel] + models).dedupedPreserveOrder() + } + + static func loadRows(at path: String, defaults: SessionDefaults) async throws -> [SessionRow] { + try await Task.detached(priority: .utility) { + guard FileManager.default.fileExists(atPath: path) else { + throw SessionLoadError.missingStore(path) + } + + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + let decoded: [String: SessionEntryRecord] + do { + decoded = try JSONDecoder().decode([String: SessionEntryRecord].self, from: data) + } catch { + throw SessionLoadError.decodeFailed(error.localizedDescription) + } + + return decoded.map { key, entry in + let updated = entry.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) } + let input = entry.inputTokens ?? 0 + let output = entry.outputTokens ?? 0 + let total = entry.totalTokens ?? input + output + let context = entry.contextTokens ?? defaults.contextTokens + let model = entry.model ?? defaults.model + + return SessionRow( + id: key, + key: key, + kind: SessionKind.from(key: key), + updatedAt: updated, + sessionId: entry.sessionId, + thinkingLevel: entry.thinkingLevel, + verboseLevel: entry.verboseLevel, + systemSent: entry.systemSent ?? false, + abortedLastRun: entry.abortedLastRun ?? false, + tokens: SessionTokenStats( + input: input, + output: output, + total: total, + contextTokens: context), + model: model) + } + .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + }.value + } + + private static func standardize(_ path: String) -> String { + (path as NSString).expandingTildeInPath.replacingOccurrences(of: "//", with: "/") + } +} + +func relativeAge(from date: Date?) -> String { + guard let date else { return "unknown" } + let delta = Date().timeIntervalSince(date) + if delta < 60 { return "just now" } + let minutes = Int(round(delta / 60)) + if minutes < 60 { return "\(minutes)m ago" } + let hours = Int(round(Double(minutes) / 60)) + if hours < 48 { return "\(hours)h ago" } + let days = Int(round(Double(hours) / 24)) + return "\(days)d ago" +} diff --git a/apps/macos/Sources/Clawdis/SessionsSettings.swift b/apps/macos/Sources/Clawdis/SessionsSettings.swift new file mode 100644 index 000000000..e3721c3df --- /dev/null +++ b/apps/macos/Sources/Clawdis/SessionsSettings.swift @@ -0,0 +1,207 @@ +import AppKit +import SwiftUI + +@MainActor +struct SessionsSettings: View { + @State private var rows: [SessionRow] = [] + @State private var storePath: String = SessionLoader.defaultStorePath + @State private var lastLoaded: Date? + @State private var errorMessage: String? + @State private var loading = false + @State private var hasLoaded = false + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + self.header + self.storeMetadata + Divider().padding(.vertical, 4) + self.content + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .task { + guard !self.hasLoaded else { return } + self.hasLoaded = true + await self.refresh() + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Sessions") + .font(.title3.weight(.semibold)) + Text("Peek at the stored conversation buckets the CLI reuses for context and rate limits.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var storeMetadata: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text("Session store") + .font(.callout.weight(.semibold)) + if let lastLoaded { + Text("Updated \(relativeAge(from: lastLoaded))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Text(self.storePath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } + + HStack(spacing: 10) { + Button { + Task { await self.refresh() } + } label: { + Label(self.loading ? "Refreshing..." : "Refresh", systemImage: "arrow.clockwise") + .labelStyle(.titleAndIcon) + } + .disabled(self.loading) + + Button { + self.revealStore() + } label: { + Label("Reveal", systemImage: "folder") + .labelStyle(.titleAndIcon) + } + .disabled(!FileManager.default.fileExists(atPath: self.storePath)) + + if self.loading { + ProgressView().controlSize(.small) + } + } + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + } + } + + private var content: some View { + Group { + if self.rows.isEmpty, self.errorMessage == nil { + Text("No sessions yet. They appear after the first inbound message or heartbeat.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 6) + } else { + Table(self.rows) { + TableColumn("Key") { row in + VStack(alignment: .leading, spacing: 4) { + Text(row.key) + .font(.body.weight(.semibold)) + HStack(spacing: 6) { + SessionKindBadge(kind: row.kind) + if !row.flagLabels.isEmpty { + ForEach(row.flagLabels, id: \.self) { flag in + Badge(text: flag) + } + } + } + } + } + .width(170) + + TableColumn("Updated", value: \.ageText) + .width(80) + + TableColumn("Tokens") { row in + Text(row.tokens.summary) + .font(.caption) + .foregroundStyle(.secondary) + } + .width(210) + + TableColumn("Model") { row in + Text(row.model ?? "—") + .font(.caption) + .foregroundStyle(.secondary) + } + .width(120) + + TableColumn("Session ID") { row in + Text(row.sessionId ?? "—") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + .tableStyle(.inset(alternatesRowBackgrounds: true)) + .frame(maxHeight: .infinity, alignment: .top) + } + } + } + + private func refresh() async { + guard !self.loading else { return } + self.loading = true + self.errorMessage = nil + + let hints = SessionLoader.configHints() + let resolvedStore = SessionLoader.resolveStorePath(override: hints.storePath) + let defaults = SessionDefaults( + model: hints.model ?? SessionLoader.fallbackModel, + contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens) + + do { + let newRows = try await SessionLoader.loadRows(at: resolvedStore, defaults: defaults) + self.rows = newRows + self.storePath = resolvedStore + self.lastLoaded = Date() + } catch { + self.rows = [] + self.storePath = resolvedStore + self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + + self.loading = false + } + + private func revealStore() { + let url = URL(fileURLWithPath: storePath) + if FileManager.default.fileExists(atPath: self.storePath) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } else { + NSWorkspace.shared.open(url.deletingLastPathComponent()) + } + } +} + +private struct SessionKindBadge: View { + let kind: SessionKind + + var body: some View { + Text(self.kind.label) + .font(.caption2.weight(.bold)) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .foregroundStyle(self.kind.tint) + .background(self.kind.tint.opacity(0.15)) + .clipShape(Capsule()) + } +} + +private struct Badge: View { + let text: String + + var body: some View { + Text(self.text) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .foregroundStyle(.secondary) + .background(Color.secondary.opacity(0.12)) + .clipShape(Capsule()) + } +} diff --git a/apps/macos/Sources/Clawdis/SettingsComponents.swift b/apps/macos/Sources/Clawdis/SettingsComponents.swift new file mode 100644 index 000000000..f826fd4e5 --- /dev/null +++ b/apps/macos/Sources/Clawdis/SettingsComponents.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct SettingsToggleRow: View { + let title: String + let subtitle: String? + @Binding var binding: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Toggle(isOn: self.$binding) { + Text(self.title) + .font(.body) + } + .toggleStyle(.checkbox) + + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift new file mode 100644 index 000000000..86af1400c --- /dev/null +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct SettingsRootView: View { + @ObservedObject var state: AppState + @ObservedObject private var permissionMonitor = PermissionMonitor.shared + @State private var monitoringPermissions = false + @State private var selectedTab: SettingsTab = .general + + var body: some View { + TabView(selection: self.$selectedTab) { + GeneralSettings(state: self.state) + .tabItem { Label("General", systemImage: "gearshape") } + .tag(SettingsTab.general) + + VoiceWakeSettings(state: self.state) + .tabItem { Label("Voice Wake", systemImage: "waveform.circle") } + .tag(SettingsTab.voiceWake) + + ConfigSettings() + .tabItem { Label("Config", systemImage: "slider.horizontal.3") } + .tag(SettingsTab.config) + + PermissionsSettings( + status: self.permissionMonitor.status, + refresh: self.refreshPerms, + showOnboarding: { OnboardingController.shared.show() }) + .tabItem { Label("Permissions", systemImage: "lock.shield") } + .tag(SettingsTab.permissions) + + SessionsSettings() + .tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") } + .tag(SettingsTab.sessions) + + ToolsSettings() + .tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") } + .tag(SettingsTab.tools) + + if self.state.debugPaneEnabled { + DebugSettings() + .tabItem { Label("Debug", systemImage: "ant") } + .tag(SettingsTab.debug) + } + + AboutSettings() + .tabItem { Label("About", systemImage: "info.circle") } + .tag(SettingsTab.about) + } + .padding(.horizontal, 28) + .padding(.vertical, 22) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onReceive(NotificationCenter.default.publisher(for: .clawdisSelectSettingsTab)) { note in + if let tab = note.object as? SettingsTab { + withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { + self.selectedTab = tab + } + } + } + .onAppear { + if let pending = SettingsTabRouter.consumePending() { + self.selectedTab = self.validTab(for: pending) + } + self.updatePermissionMonitoring(for: self.selectedTab) + } + .onChange(of: self.state.debugPaneEnabled) { _, enabled in + if !enabled, self.selectedTab == .debug { + self.selectedTab = .general + } + } + .onChange(of: self.selectedTab) { _, newValue in + self.updatePermissionMonitoring(for: newValue) + } + .onDisappear { self.stopPermissionMonitoring() } + .task { await self.refreshPerms() } + } + + private func validTab(for requested: SettingsTab) -> SettingsTab { + if requested == .debug, !self.state.debugPaneEnabled { return .general } + return requested + } + + @MainActor + private func refreshPerms() async { + await self.permissionMonitor.refreshNow() + } + + private func updatePermissionMonitoring(for tab: SettingsTab) { + let shouldMonitor = tab == .permissions + if shouldMonitor, !self.monitoringPermissions { + self.monitoringPermissions = true + PermissionMonitor.shared.register() + } else if !shouldMonitor, self.monitoringPermissions { + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + } + + private func stopPermissionMonitoring() { + guard self.monitoringPermissions else { return } + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } +} + +enum SettingsTab: CaseIterable { + case general, tools, sessions, config, voiceWake, permissions, debug, about + static let windowWidth: CGFloat = 520 + static let windowHeight: CGFloat = 624 + var title: String { + switch self { + case .general: "General" + case .tools: "Tools" + case .sessions: "Sessions" + case .config: "Config" + case .voiceWake: "Voice Wake" + case .permissions: "Permissions" + case .debug: "Debug" + case .about: "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") +} diff --git a/apps/macos/Sources/Clawdis/ShellRunner.swift b/apps/macos/Sources/Clawdis/ShellRunner.swift new file mode 100644 index 000000000..f64127c2f --- /dev/null +++ b/apps/macos/Sources/Clawdis/ShellRunner.swift @@ -0,0 +1,45 @@ +import ClawdisIPC +import Foundation + +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) + } +} diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift new file mode 100644 index 000000000..fbbb40d7e --- /dev/null +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -0,0 +1,48 @@ +import Foundation +import AppKit + +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)" + self.runLaunchctl(["kickstart", "-k", userTarget]) + } + + static func stopClawdis() { + let userTarget = "gui/\(getuid())/\(launchdLabel)" + self.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: "; ")) + } +} diff --git a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift new file mode 100644 index 000000000..851002e04 --- /dev/null +++ b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift @@ -0,0 +1,716 @@ +import AVFoundation +import Speech +import SwiftUI + +enum VoiceWakeTestState: Equatable { + case idle + case requesting + case listening + case hearing(String) + case detected(String) + case failed(String) +} + +private struct AudioInputDevice: Identifiable, Equatable { + let uid: String + let name: String + var id: String { self.uid } +} + +actor MicLevelMonitor { + private let engine = AVAudioEngine() + private var update: (@Sendable (Double) -> Void)? + private var running = false + private var smoothedLevel: Double = 0 + + func start(onLevel: @Sendable @escaping (Double) -> Void) async throws { + self.update = onLevel + if self.running { return } + let input = self.engine.inputNode + let format = input.outputFormat(forBus: 0) + input.removeTap(onBus: 0) + input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in + guard let self else { return } + let level = Self.normalizedLevel(from: buffer) + Task { await self.push(level: level) } + } + self.engine.prepare() + try self.engine.start() + self.running = true + } + + func stop() { + guard self.running else { return } + self.engine.inputNode.removeTap(onBus: 0) + self.engine.stop() + self.running = false + } + + private func push(level: Double) { + self.smoothedLevel = (self.smoothedLevel * 0.45) + (level * 0.55) + guard let update else { return } + let value = self.smoothedLevel + Task { @MainActor in update(value) } + } + + private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Double { + guard let channel = buffer.floatChannelData?[0] else { return 0 } + let frameCount = Int(buffer.frameLength) + guard frameCount > 0 else { return 0 } + var sum: Float = 0 + for i in 0.. Void) async throws + { + guard self.recognitionTask == nil else { return } + self.isStopping = false + let chosenLocale = localeID.flatMap { Locale(identifier: $0) } ?? Locale.current + let recognizer = SFSpeechRecognizer(locale: chosenLocale) + guard let recognizer, recognizer.isAvailable else { + throw NSError( + domain: "VoiceWakeTester", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"]) + } + + guard Self.hasPrivacyStrings else { + throw NSError( + domain: "VoiceWakeTester", + code: 3, + userInfo: [ + NSLocalizedDescriptionKey: "Missing mic/speech privacy strings. Rebuild the mac app (scripts/restart-mac.sh) to include usage descriptions.", + ]) + } + + let granted = try await Self.ensurePermissions() + guard granted else { + throw NSError( + domain: "VoiceWakeTester", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Microphone or speech permission denied"]) + } + + self.configureSession(preferredMicID: micID) + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + let request = self.recognitionRequest + + let inputNode = self.audioEngine.inputNode + let format = inputNode.outputFormat(forBus: 0) + inputNode.removeTap(onBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in + request?.append(buffer) + } + + self.audioEngine.prepare() + try self.audioEngine.start() + DispatchQueue.main.async { + onUpdate(.listening) + } + + guard let request = recognitionRequest else { return } + + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + let text = result?.bestTranscription.formattedString ?? "" + let matched = Self.matches(text: text, triggers: triggers) + let isFinal = result?.isFinal ?? false + let errorMessage = error?.localizedDescription + Task { @MainActor [weak self] in + guard let self, !self.isStopping else { return } + self.handleResult( + matched: matched, + text: text, + isFinal: isFinal, + errorMessage: errorMessage, + onUpdate: onUpdate) + } + } + } + + func stop() { + self.isStopping = true + self.audioEngine.stop() + self.recognitionRequest?.endAudio() + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest = nil + self.audioEngine.inputNode.removeTap(onBus: 0) + } + + @MainActor + private func handleResult( + matched: Bool, + text: String, + isFinal: Bool, + errorMessage: String?, + onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) + { + if matched, !text.isEmpty { + self.stop() + AppStateStore.shared.triggerVoiceEars() + onUpdate(.detected(text)) + return + } + if let errorMessage { + self.stop() + onUpdate(.failed(errorMessage)) + return + } + if isFinal { + self.stop() + onUpdate(text.isEmpty ? .failed("No speech detected") : .failed("No trigger heard: “\(text)”")) + } else { + onUpdate(text.isEmpty ? .listening : .hearing(text)) + } + } + + private func configureSession(preferredMicID: String?) { + _ = preferredMicID + } + + private static func matches(text: String, triggers: [String]) -> Bool { + let lowered = text.lowercased() + return triggers.contains { lowered.contains($0.lowercased()) } + } + + private nonisolated static func ensurePermissions() async throws -> Bool { + let speechStatus = SFSpeechRecognizer.authorizationStatus() + if speechStatus == .notDetermined { + let granted = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status == .authorized) + } + } + guard granted else { return false } + } else if speechStatus != .authorized { + return false + } + + let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + switch micStatus { + case .authorized: return true + + case .notDetermined: + return await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { granted in + continuation.resume(returning: granted) + } + } + + default: + return false + } + } + + private static var hasPrivacyStrings: Bool { + let speech = Bundle.main.object(forInfoDictionaryKey: "NSSpeechRecognitionUsageDescription") as? String + let mic = Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") as? String + return speech?.isEmpty == false && mic?.isEmpty == false + } +} + +struct VoiceWakeSettings: View { + @ObservedObject var state: AppState + @State private var testState: VoiceWakeTestState = .idle + @State private var tester = VoiceWakeTester() + @State private var isTesting = false + @State private var availableMics: [AudioInputDevice] = [] + @State private var loadingMics = false + @State private var meterLevel: Double = 0 + @State private var meterError: String? + private let meter = MicLevelMonitor() + @State private var availableLocales: [Locale] = [] + + private struct IndexedWord: Identifiable { + let id: Int + let value: String + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + SettingsToggleRow( + title: "Enable Voice Wake", + subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands. Voice recognition runs fully on-device.", + binding: self.$state.swabbleEnabled) + .disabled(!voiceWakeSupported) + + if !voiceWakeSupported { + Label("Voice Wake requires macOS 26 or newer.", systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.yellow) + .padding(8) + .background(Color.secondary.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + self.localePicker + self.micPicker + self.levelMeter + + self.testCard + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Trigger words") + .font(.callout.weight(.semibold)) + Spacer() + Button { + self.addWord() + } label: { + Label("Add word", systemImage: "plus") + } + .disabled(self.state.swabbleTriggerWords + .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) + + Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } + } + + Table(self.indexedWords) { + TableColumn("Word") { row in + TextField("Wake word", text: self.binding(for: row.id)) + .textFieldStyle(.roundedBorder) + } + TableColumn("") { row in + Button { + self.removeWord(at: row.id) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Remove trigger word") + } + .width(36) + } + .frame(minHeight: 180) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.25), lineWidth: 1)) + + Text( + "Clawdis reacts when any trigger appears in a transcription. Keep them short to avoid false positives.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .task { await self.loadMicsIfNeeded() } + .task { await self.loadLocalesIfNeeded() } + .task { await self.restartMeter() } + .onChange(of: self.state.voiceWakeMicID) { _, _ in + Task { await self.restartMeter() } + } + .onDisappear { + Task { await self.meter.stop() } + } + } + + private var indexedWords: [IndexedWord] { + self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) } + } + + private var testCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Test Voice Wake") + .font(.callout.weight(.semibold)) + Spacer() + Button(action: self.toggleTest) { + Label( + self.isTesting ? "Stop" : "Start test", + systemImage: self.isTesting ? "stop.circle.fill" : "play.circle") + } + .buttonStyle(.borderedProminent) + .tint(self.isTesting ? .red : .accentColor) + } + + HStack(spacing: 8) { + self.statusIcon + VStack(alignment: .leading, spacing: 4) { + Text(self.statusText) + .font(.subheadline) + if case let .detected(text) = testState { + Text("Heard: \(text)") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + Spacer() + } + .padding(10) + .background(.quaternary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(.vertical, 2) + } + + private var statusIcon: some View { + switch self.testState { + case .idle: + AnyView(Image(systemName: "waveform").foregroundStyle(.secondary)) + + case .requesting: + AnyView(ProgressView().controlSize(.small)) + + case .listening, .hearing: + AnyView( + Image(systemName: "ear.and.waveform") + .symbolEffect(.pulse) + .foregroundStyle(Color.accentColor)) + + case .detected: + AnyView(Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)) + + case .failed: + AnyView(Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)) + } + } + + private var statusText: String { + switch self.testState { + case .idle: + "Press start, say a trigger word, and wait for detection." + + case .requesting: + "Requesting mic & speech permission…" + + case .listening: + "Listening… say your trigger word." + + case let .hearing(text): + "Heard: \(text)" + + case .detected: + "Voice wake detected!" + + case let .failed(reason): + reason + } + } + + private func addWord() { + self.state.swabbleTriggerWords.append("") + } + + private func removeWord(at index: Int) { + guard self.state.swabbleTriggerWords.indices.contains(index) else { return } + self.state.swabbleTriggerWords.remove(at: index) + } + + private func binding(for index: Int) -> Binding { + Binding( + get: { + guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" } + return self.state.swabbleTriggerWords[index] + }, + set: { newValue in + guard self.state.swabbleTriggerWords.indices.contains(index) else { return } + self.state.swabbleTriggerWords[index] = newValue + }) + } + + private func toggleTest() { + guard voiceWakeSupported else { + self.testState = .failed("Voice Wake requires macOS 26 or newer.") + return + } + if self.isTesting { + self.tester.stop() + self.isTesting = false + self.testState = .idle + return + } + + let triggers = self.sanitizedTriggers() + self.isTesting = true + self.testState = .requesting + Task { @MainActor in + do { + try await self.tester.start( + triggers: triggers, + micID: self.state.voiceWakeMicID.isEmpty ? nil : self.state.voiceWakeMicID, + localeID: self.state.voiceWakeLocaleID, + onUpdate: { newState in + DispatchQueue.main.async { [self] in + self.testState = newState + if case .detected = newState { self.isTesting = false } + if case .failed = newState { self.isTesting = false } + } + }) + try await Task.sleep(nanoseconds: 10 * 1_000_000_000) + if self.isTesting { + self.tester.stop() + self.testState = .failed("Timeout: no trigger heard") + self.isTesting = false + } + } catch { + self.tester.stop() + self.testState = .failed(error.localizedDescription) + self.isTesting = false + } + } + } + + private func sanitizedTriggers() -> [String] { + let cleaned = self.state.swabbleTriggerWords + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned + } + + private var micPicker: some View { + VStack(alignment: .leading, spacing: 6) { + LabeledContent("Microphone") { + Picker("Microphone", selection: self.$state.voiceWakeMicID) { + Text("System default").tag("") + ForEach(self.availableMics) { mic in + Text(mic.name).tag(mic.uid) + } + } + .labelsHidden() + .frame(width: 260) + } + if self.loadingMics { + ProgressView().controlSize(.small) + } + } + } + + private var localePicker: some View { + VStack(alignment: .leading, spacing: 6) { + LabeledContent("Recognition language") { + Picker("Language", selection: self.$state.voiceWakeLocaleID) { + let current = Locale(identifier: Locale.current.identifier) + Text("\(self.friendlyName(for: current)) (System)").tag(Locale.current.identifier) + ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in + if id != Locale.current.identifier { + Text(self.friendlyName(for: Locale(identifier: id))).tag(id) + } + } + } + .labelsHidden() + .frame(width: 260) + } + + if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Additional languages") + .font(.footnote.weight(.semibold)) + ForEach( + Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()), + id: \.offset) + { idx, localeID in + HStack(spacing: 8) { + Picker("Extra \(idx + 1)", selection: Binding( + get: { localeID }, + set: { newValue in + guard self.state + .voiceWakeAdditionalLocaleIDs.indices + .contains(idx) else { return } + self.state + .voiceWakeAdditionalLocaleIDs[idx] = + newValue + })) { + ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in + Text(self.friendlyName(for: Locale(identifier: id))).tag(id) + } + } + .labelsHidden() + .frame(width: 220) + + Button { + guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return } + self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Remove language") + } + } + + Button { + if let first = availableLocales.first { + self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier) + } + } label: { + Label("Add language", systemImage: "plus") + } + .disabled(self.availableLocales.isEmpty) + } + .padding(.top, 4) + } else { + Button { + if let first = availableLocales.first { + self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier) + } + } label: { + Label("Add additional language", systemImage: "plus") + } + .buttonStyle(.link) + .disabled(self.availableLocales.isEmpty) + .padding(.top, 4) + } + + Text("Languages are tried in order. Models may need a first-use download on macOS 26.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @MainActor + private func loadMicsIfNeeded() async { + guard self.availableMics.isEmpty, !self.loadingMics else { return } + self.loadingMics = true + let discovery = AVCaptureDevice.DiscoverySession( + deviceTypes: [.external, .microphone], + mediaType: .audio, + position: .unspecified) + self.availableMics = discovery.devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) } + self.loadingMics = false + } + + @MainActor + private func loadLocalesIfNeeded() async { + guard self.availableLocales.isEmpty else { return } + self.availableLocales = Array(SFSpeechRecognizer.supportedLocales()).sorted { lhs, rhs in + self.friendlyName(for: lhs) + .localizedCaseInsensitiveCompare(self.friendlyName(for: rhs)) == .orderedAscending + } + } + + private func friendlyName(for locale: Locale) -> String { + let cleanedID = self.normalizedLocaleIdentifier(locale.identifier) + let cleanLocale = Locale(identifier: cleanedID) + + if let langCode = cleanLocale.language.languageCode?.identifier, + let lang = cleanLocale.localizedString(forLanguageCode: langCode), + let regionCode = cleanLocale.region?.identifier, + let region = cleanLocale.localizedString(forRegionCode: regionCode) + { + return "\(lang) (\(region))" + } + if let langCode = cleanLocale.language.languageCode?.identifier, + let lang = cleanLocale.localizedString(forLanguageCode: langCode) + { + return lang + } + return cleanLocale.localizedString(forIdentifier: cleanedID) ?? cleanedID + } + + private func normalizedLocaleIdentifier(_ raw: String) -> String { + var trimmed = raw + if let at = trimmed.firstIndex(of: "@") { + trimmed = String(trimmed[.. Double(idx) + RoundedRectangle(cornerRadius: 2) + .fill(fill ? self.segmentColor(for: idx) : Color.gray.opacity(0.35)) + .frame(width: 14, height: 10) + } + } + .padding(4) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.gray.opacity(0.25), lineWidth: 1)) + } + + private func segmentColor(for idx: Int) -> Color { + let fraction = Double(idx + 1) / Double(self.segments) + if fraction < 0.65 { return .green } + if fraction < 0.85 { return .yellow } + return .red + } +} + +extension VoiceWakeTester: @unchecked Sendable {} diff --git a/apps/macos/Sources/Clawdis/XPCService.swift b/apps/macos/Sources/Clawdis/XPCService.swift new file mode 100644 index 000000000..66d742144 --- /dev/null +++ b/apps/macos/Sources/Clawdis/XPCService.swift @@ -0,0 +1,72 @@ +import Foundation +import OSLog +import ClawdisIPC + +@objc protocol ClawdisXPCProtocol { + func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void) +} + +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 { sound } else { 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(\.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) + } + } +}