Files
clawdbot/apps/macos/Sources/Clawdis/AppMain.swift
2025-12-06 23:45:16 +01:00

3629 lines
130 KiB
Swift

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<Void, Never>? = 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<Data?, Never>?
private var delivered = false
func awaitPNG() async -> Data? {
await withCheckedContinuation { cont in
self.continuation = cont
}
}
nonisolated func stream(
_ stream: SCStream,
didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
of outputType: SCStreamOutputType)
{
guard outputType == .screen else { return }
if 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
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
private let relayManager = RelayProcessManager.shared
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)
}
.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<Bool> {
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
@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 {
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() }
}
}
}
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 }
}
}
}
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<String>()
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..<frameCount {
let s = channel[i]
sum += s * s
}
let rms = sqrt(sum / Float(frameCount) + 1e-12)
let db = 20 * log10(Double(rms))
let normalized = max(0, min(1, (db + 50) / 50)) // -50dB -> 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<Bool> {
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<String> {
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[..<at])
}
if let u = trimmed.range(of: "-u-") {
trimmed = String(trimmed[..<u.lowerBound])
}
if let t = trimmed.range(of: "-t-") { // transform extension
trimmed = String(trimmed[..<t.lowerBound])
}
return trimmed
}
private var levelMeter: some View {
VStack(alignment: .leading, spacing: 6) {
LabeledContent {
HStack(spacing: 10) {
MicLevelBar(level: self.meterLevel)
Text(self.levelLabel)
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
} label: {
Text("Live level")
.font(.callout.weight(.semibold))
}
if let meterError {
Text(meterError)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
private var levelLabel: String {
let db = (meterLevel * 50) - 50
return String(format: "%.0f dB", db)
}
@MainActor
private func restartMeter() async {
self.meterError = nil
await self.meter.stop()
do {
try await self.meter.start { [weak state] level in
Task { @MainActor in
guard state != nil else { return }
self.meterLevel = level
}
}
} catch {
self.meterError = error.localizedDescription
}
}
}
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 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
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)))
}
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])
}
}
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..<self.segments, id: \.self) { idx in
let fill = self.level * Double(self.segments) > 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..<self.pageCount, id: \.self) { index in
Button {
withAnimation { self.currentPage = index }
} label: {
Circle()
.fill(index == self.currentPage ? Color.accentColor : Color.gray.opacity(0.3))
.frame(width: 8, height: 8)
}
.buttonStyle(.plain)
}
}
Spacer()
Button(action: self.handleNext) {
Text(self.buttonTitle)
.frame(minWidth: 88)
}
.keyboardShortcut(.return)
.buttonStyle(.borderedProminent)
}
.padding(.horizontal, 20)
.frame(height: 60)
}
private func onboardingPage(@ViewBuilder _ content: () -> 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 {}