chore: run formatters and lint

This commit is contained in:
Peter Steinberger
2025-12-06 04:03:48 +01:00
parent c6e3b490f5
commit fbf5333b39
5 changed files with 262 additions and 239 deletions

View File

@@ -6,7 +6,6 @@
--wrapcollections before-first
--trimwhitespace always
--stripunusedargs closure-only
--stripunusedimports
--self init-only
--disable redundantSelf
--enable wrapMultilineStatementBraces

View File

@@ -1,19 +1,18 @@
import AppKit
import ApplicationServices
import AsyncXPCConnection
import ClawdisIPC
import Foundation
import class Foundation.Bundle
import OSLog
import CoreGraphics
@preconcurrency import ScreenCaptureKit
import AVFoundation
import Speech
import VideoToolbox
import ClawdisIPC
import CoreGraphics
import Foundation
import MenuBarExtraAccess
import OSLog
@preconcurrency import ScreenCaptureKit
import ServiceManagement
import Speech
import SwiftUI
import UserNotifications
import MenuBarExtraAccess
import VideoToolbox
private let serviceName = "com.steipete.clawdis.xpc"
private let launchdLabel = "com.steipete.clawdis"
@@ -22,6 +21,7 @@ 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"
@@ -35,21 +35,27 @@ final class AppState: ObservableObject {
@Published var isPaused: Bool {
didSet { UserDefaults.standard.set(isPaused, forKey: pauseDefaultsKey) }
}
@Published var defaultSound: String {
didSet { UserDefaults.standard.set(defaultSound, forKey: "clawdis.defaultSound") }
}
@Published var launchAtLogin: Bool {
didSet { Task { AppStateStore.updateLaunchAtLogin(enabled: launchAtLogin) } }
}
@Published var onboardingSeen: Bool {
didSet { UserDefaults.standard.set(onboardingSeen, forKey: "clawdis.onboardingSeen") }
}
@Published var debugPaneEnabled: Bool {
didSet { UserDefaults.standard.set(debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
}
@Published var swabbleEnabled: Bool {
didSet { UserDefaults.standard.set(swabbleEnabled, forKey: swabbleEnabledKey) }
}
@Published var swabbleTriggerWords: [String] {
didSet {
let cleaned = swabbleTriggerWords.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
@@ -60,12 +66,22 @@ final class AppState: ObservableObject {
}
}
}
@Published var showDockIcon: Bool {
didSet {
UserDefaults.standard.set(showDockIcon, forKey: showDockIconKey)
AppActivationPolicy.apply(showDockIcon: showDockIcon)
}
}
@Published var voiceWakeMicID: String {
didSet { UserDefaults.standard.set(voiceWakeMicID, forKey: voiceWakeMicKey) }
}
@Published var voiceWakeLocaleID: String {
didSet { UserDefaults.standard.set(voiceWakeLocaleID, forKey: voiceWakeLocaleKey) }
}
@Published var voiceWakeAdditionalLocaleIDs: [String] {
didSet { UserDefaults.standard.set(voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) }
}
@@ -79,6 +95,7 @@ final class AppState: ObservableObject {
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) ?? []
@@ -100,6 +117,13 @@ enum AppStateStore {
}
}
@MainActor
enum AppActivationPolicy {
static func apply(showDockIcon: Bool) {
NSApp.setActivationPolicy(showDockIcon ? .regular : .accessory)
}
}
// MARK: - XPC service protocol
@objc protocol ClawdisXPCProtocol {
@@ -135,18 +159,20 @@ final class ClawdisXPCService: NSObject, ClawdisXPCProtocol {
switch request {
case let .notify(title, body, sound):
let chosenSound: String
if let sound { chosenSound = sound } else { chosenSound = await MainActor.run { AppStateStore.defaultSound } }
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 { $0.key.rawValue }
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") }
@@ -154,6 +180,7 @@ final class ClawdisXPCService: NSObject, ClawdisXPCProtocol {
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
@@ -206,37 +233,41 @@ enum PermissionManager {
case .notifications:
let center = UNUserNotificationCenter.current()
let status = await center.notificationSettings()
if status.authorizationStatus == .notDetermined && interactive {
if status.authorizationStatus == .notDetermined, interactive {
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
let post = await center.notificationSettings()
results[cap] = post.authorizationStatus == .authorized
} else {
results[cap] = status.authorizationStatus == .authorized
}
case .accessibility:
// Accessing AX APIs must be on main thread.
let trusted = AXIsProcessTrusted()
results[cap] = trusted
if interactive && !trusted {
if interactive, !trusted {
_ = AXIsProcessTrustedWithOptions(nil)
}
case .screenRecording:
let granted = ScreenRecordingProbe.isAuthorized()
if interactive && !granted {
if interactive, !granted {
await ScreenRecordingProbe.requestAuthorization()
}
results[cap] = ScreenRecordingProbe.isAuthorized()
case .microphone:
let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
if interactive && !granted {
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 {
if status == .notDetermined, interactive {
let ok = await withCheckedContinuation { cont in
SFSpeechRecognizer.requestAuthorization { auth in cont.resume(returning: auth == .authorized) }
}
@@ -258,16 +289,20 @@ enum PermissionManager {
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
}
@@ -299,11 +334,10 @@ enum Screenshotter {
static func capture(displayID: UInt32?, windowID: UInt32?) async -> Data? {
guard let content = try? await SCShareableContent.current else { return nil }
let targetDisplay: SCDisplay?
if let displayID {
targetDisplay = content.displays.first(where: { $0.displayID == displayID })
let targetDisplay: SCDisplay? = if let displayID {
content.displays.first(where: { $0.displayID == displayID })
} else {
targetDisplay = content.displays.first
content.displays.first
}
let filter: SCContentFilter
@@ -394,7 +428,7 @@ enum ShellRunner {
}
if let timeout, timeout > 0 {
let nanos = UInt64(timeout * 1_000_000_000)
let nanos = UInt64(timeout * 1000000000)
try? await Task.sleep(nanoseconds: nanos)
if process.isRunning {
process.terminate()
@@ -481,7 +515,7 @@ private struct MenuContent: View {
let storePath = SessionLoader.defaultStorePath
if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)),
let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) {
let sorted = decoded.sorted { (a, b) -> Bool in
let sorted = decoded.sorted { a, b -> Bool in
let lhs = a.value.updatedAt ?? 0
let rhs = b.value.updatedAt ?? 0
return lhs > rhs
@@ -514,11 +548,9 @@ private struct CritterStatusLabel: View {
Image(nsImage: CritterIconRenderer.makeIcon(blink: 0))
.frame(width: 18, height: 16)
} else {
Image(nsImage: CritterIconRenderer.makeIcon(
blink: blinkAmount,
legWiggle: legWiggle,
earWiggle: earWiggle
))
Image(nsImage: CritterIconRenderer.makeIcon(blink: blinkAmount,
legWiggle: legWiggle,
earWiggle: earWiggle))
.frame(width: 18, height: 16)
.rotationEffect(.degrees(wiggleAngle), anchor: .center)
.offset(x: wiggleOffset)
@@ -641,22 +673,20 @@ enum CritterIconRenderer {
// 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))
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)
@@ -814,19 +844,19 @@ private enum SessionKind {
var label: String {
switch self {
case .direct: return "Direct"
case .group: return "Group"
case .global: return "Global"
case .unknown: return "Unknown"
case .direct: "Direct"
case .group: "Group"
case .global: "Global"
case .unknown: "Unknown"
}
}
var tint: Color {
switch self {
case .direct: return .accentColor
case .group: return .orange
case .global: return .purple
case .unknown: return .gray
case .direct: .accentColor
case .group: .orange
case .global: .purple
case .unknown: .gray
}
}
}
@@ -849,16 +879,17 @@ private enum SessionLoadError: LocalizedError {
var errorDescription: String? {
switch self {
case let .missingStore(path):
return "No session store found at \(path) yet. Send or receive a message to create it."
"No session store found at \(path) yet. Send or receive a message to create it."
case let .decodeFailed(reason):
return "Could not read the session store: \(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 fallbackContextTokens = 200000
static let defaultStorePath = standardize(
FileManager.default.homeDirectoryForCurrentUser
@@ -868,7 +899,7 @@ private enum SessionLoader {
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),
standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".warelay/sessions.json").path)
]
static func configHints() -> SessionConfigHints {
@@ -890,11 +921,9 @@ private enum SessionLoader {
let model = agent?["model"] as? String
let contextTokens = (agent?["contextTokens"] as? NSNumber)?.intValue
return SessionConfigHints(
storePath: store.map { standardize($0) },
model: model,
contextTokens: contextTokens
)
return SessionConfigHints(storePath: store.map { standardize($0) },
model: model,
contextTokens: contextTokens)
}
static func resolveStorePath(override: String?) -> String {
@@ -928,24 +957,20 @@ private enum SessionLoader {
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
)
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
@@ -1056,7 +1081,7 @@ struct SessionsSettings: View {
private var content: some View {
Group {
if rows.isEmpty && errorMessage == nil {
if rows.isEmpty, errorMessage == nil {
Text("No sessions yet. They appear after the first inbound message or heartbeat.")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -1117,10 +1142,8 @@ struct SessionsSettings: View {
let hints = SessionLoader.configHints()
let resolvedStore = SessionLoader.resolveStorePath(override: hints.storePath)
let defaults = SessionDefaults(
model: hints.model ?? SessionLoader.fallbackModel,
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens
)
let defaults = SessionDefaults(model: hints.model ?? SessionLoader.fallbackModel,
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
do {
let newRows = try await SessionLoader.loadRows(at: resolvedStore, defaults: defaults)
@@ -1288,7 +1311,7 @@ struct SettingsRootView: View {
}
}
.onChange(of: state.debugPaneEnabled) { _, enabled in
if !enabled && selectedTab == .debug {
if !enabled, selectedTab == .debug {
selectedTab = .general
}
}
@@ -1296,7 +1319,7 @@ struct SettingsRootView: View {
}
private func validTab(for requested: SettingsTab) -> SettingsTab {
if requested == .debug && !state.debugPaneEnabled { return .general }
if requested == .debug, !state.debugPaneEnabled { return .general }
return requested
}
@@ -1315,12 +1338,12 @@ enum SettingsTab: CaseIterable {
static let windowHeight: CGFloat = 520
var title: String {
switch self {
case .general: return "General"
case .sessions: return "Sessions"
case .voiceWake: return "Voice Wake"
case .permissions: return "Permissions"
case .debug: return "Debug"
case .about: return "About"
case .general: "General"
case .sessions: "Sessions"
case .voiceWake: "Voice Wake"
case .permissions: "Permissions"
case .debug: "Debug"
case .about: "About"
}
}
}
@@ -1399,7 +1422,7 @@ actor MicLevelMonitor {
let frameCount = Int(buffer.frameLength)
guard frameCount > 0 else { return 0 }
var sum: Float = 0
for i in 0..<frameCount {
for i in 0 ..< frameCount {
let s = channel[i]
sum += s * s
}
@@ -1484,13 +1507,11 @@ final class VoiceWakeTester {
}
@MainActor
private func handleResult(
matched: Bool,
text: String,
isFinal: Bool,
errorMessage: String?,
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void
) {
private func handleResult(matched: Bool,
text: String,
isFinal: Bool,
errorMessage: String?,
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
if matched, !text.isEmpty {
stop()
onUpdate(.detected(text))
@@ -1520,7 +1541,7 @@ final class VoiceWakeTester {
return triggers.contains { lowered.contains($0.lowercased()) }
}
nonisolated private static func ensurePermissions() async throws -> Bool {
private nonisolated static func ensurePermissions() async throws -> Bool {
let speechStatus = SFSpeechRecognizer.authorizationStatus()
if speechStatus == .notDetermined {
let granted = await withCheckedContinuation { continuation in
@@ -1536,12 +1557,14 @@ final class VoiceWakeTester {
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
}
@@ -1593,20 +1616,17 @@ struct GeneralSettings: View {
}
VStack(alignment: .leading, spacing: 12) {
SettingsToggleRow(
title: "Clawdis active",
subtitle: "Pause to stop Clawdis background helpers and notifications.",
binding: activeBinding)
SettingsToggleRow(title: "Clawdis active",
subtitle: "Pause to stop Clawdis background helpers and notifications.",
binding: activeBinding)
SettingsToggleRow(
title: "Launch at login",
subtitle: "Automatically start Clawdis after you sign in.",
binding: $state.launchAtLogin)
SettingsToggleRow(title: "Launch at login",
subtitle: "Automatically start Clawdis after you sign in.",
binding: $state.launchAtLogin)
SettingsToggleRow(
title: "Enable debug tools",
subtitle: "Show the Debug tab with development utilities.",
binding: $state.debugPaneEnabled)
SettingsToggleRow(title: "Enable debug tools",
subtitle: "Show the Debug tab with development utilities.",
binding: $state.debugPaneEnabled)
LabeledContent("Default sound") {
Picker("Sound", selection: $state.defaultSound) {
@@ -1638,10 +1658,8 @@ struct GeneralSettings: View {
}
private var activeBinding: Binding<Bool> {
Binding(
get: { !state.isPaused },
set: { state.isPaused = !$0 }
)
Binding(get: { !state.isPaused },
set: { state.isPaused = !$0 })
}
private var cliInstaller: some View {
@@ -1701,12 +1719,10 @@ struct VoiceWakeSettings: View {
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.",
binding: $state.swabbleEnabled
)
.disabled(!voiceWakeSupported)
SettingsToggleRow(title: "Enable Voice Wake",
subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands.",
binding: $state.swabbleEnabled)
.disabled(!voiceWakeSupported)
if !voiceWakeSupported {
Label("Voice Wake requires macOS 26 or newer.", systemImage: "exclamationmark.triangle.fill")
@@ -1767,16 +1783,16 @@ struct VoiceWakeSettings: View {
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.task { await loadMicsIfNeeded() }
.task { await loadLocalesIfNeeded() }
.task { await restartMeter() }
.onChange(of: state.voiceWakeMicID) { _, _ in
Task { await restartMeter() }
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.task { await loadMicsIfNeeded() }
.task { await loadLocalesIfNeeded() }
.task { await restartMeter() }
.onChange(of: state.voiceWakeMicID) { _, _ in
Task { await restartMeter() }
}
.onDisappear {
Task { await meter.stop() }
}
@@ -1824,16 +1840,20 @@ struct VoiceWakeSettings: View {
switch 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))
}
@@ -1842,17 +1862,22 @@ struct VoiceWakeSettings: View {
private var statusText: String {
switch testState {
case .idle:
return "Press start, say a trigger word, and wait for detection."
"Press start, say a trigger word, and wait for detection."
case .requesting:
return "Requesting mic & speech permission…"
"Requesting mic & speech permission…"
case .listening:
return "Listening… say your trigger word."
"Listening… say your trigger word."
case let .hearing(text):
return "Heard: \(text)"
"Heard: \(text)"
case .detected:
return "Voice wake detected!"
"Voice wake detected!"
case let .failed(reason):
return reason
reason
}
}
@@ -1866,16 +1891,14 @@ struct VoiceWakeSettings: View {
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard state.swabbleTriggerWords.indices.contains(index) else { return "" }
return state.swabbleTriggerWords[index]
},
set: { newValue in
guard state.swabbleTriggerWords.indices.contains(index) else { return }
state.swabbleTriggerWords[index] = newValue
}
)
Binding(get: {
guard state.swabbleTriggerWords.indices.contains(index) else { return "" }
return state.swabbleTriggerWords[index]
},
set: { newValue in
guard state.swabbleTriggerWords.indices.contains(index) else { return }
state.swabbleTriggerWords[index] = newValue
})
}
private func toggleTest() {
@@ -1895,20 +1918,18 @@ struct VoiceWakeSettings: View {
testState = .requesting
Task { @MainActor in
do {
try await tester.start(
triggers: triggers,
micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID,
localeID: state.voiceWakeLocaleID,
onUpdate: { newState in
DispatchQueue.main.async { [self] in
testState = newState
if case .detected = newState { isTesting = false }
if case .failed = newState { isTesting = false }
}
}
)
try await tester.start(triggers: triggers,
micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID,
localeID: state.voiceWakeLocaleID,
onUpdate: { newState in
DispatchQueue.main.async { [self] in
testState = newState
if case .detected = newState { isTesting = false }
if case .failed = newState { isTesting = false }
}
})
// timeout after 10s
try await Task.sleep(nanoseconds: 10 * 1_000_000_000)
try await Task.sleep(nanoseconds: 10 * 1000000000)
if isTesting {
tester.stop()
testState = .failed("Timeout: no trigger heard")
@@ -1953,7 +1974,7 @@ struct VoiceWakeSettings: View {
Picker("Language", selection: $state.voiceWakeLocaleID) {
let current = Locale(identifier: Locale.current.identifier)
Text("\(friendlyName(for: current)) (System)").tag(Locale.current.identifier)
ForEach(availableLocales.map { $0.identifier }, id: \.self) { id in
ForEach(availableLocales.map(\.identifier), id: \.self) { id in
if id != Locale.current.identifier {
Text(friendlyName(for: Locale(identifier: id))).tag(id)
}
@@ -1969,14 +1990,12 @@ struct VoiceWakeSettings: View {
.font(.footnote.weight(.semibold))
ForEach(Array(state.voiceWakeAdditionalLocaleIDs.enumerated()), id: \.offset) { idx, localeID in
HStack(spacing: 8) {
Picker("Extra \(idx + 1)", selection: Binding(
get: { localeID },
set: { newValue in
guard state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
state.voiceWakeAdditionalLocaleIDs[idx] = newValue
}
)) {
ForEach(availableLocales.map { $0.identifier }, id: \.self) { id in
Picker("Extra \(idx + 1)", selection: Binding(get: { localeID },
set: { newValue in
guard state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
state.voiceWakeAdditionalLocaleIDs[idx] = newValue
})) {
ForEach(availableLocales.map(\.identifier), id: \.self) { id in
Text(friendlyName(for: Locale(identifier: id))).tag(id)
}
}
@@ -2027,11 +2046,9 @@ struct VoiceWakeSettings: View {
private func loadMicsIfNeeded() async {
guard availableMics.isEmpty, !loadingMics else { return }
loadingMics = true
let discovery = AVCaptureDevice.DiscoverySession(
deviceTypes: [.external, .microphone],
mediaType: .audio,
position: .unspecified
)
let discovery = AVCaptureDevice.DiscoverySession(deviceTypes: [.external, .microphone],
mediaType: .audio,
position: .unspecified)
availableMics = discovery.devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
loadingMics = false
}
@@ -2305,12 +2322,16 @@ struct PermissionStatusList: View {
case .notifications:
let center = UNUserNotificationCenter.current()
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
case .accessibility:
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
case .screenRecording:
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording")
case .microphone:
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
case .speechRecognition:
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition")
}
@@ -2400,31 +2421,31 @@ private struct PermissionRow: View {
private var title: String {
switch capability {
case .notifications: return "Notifications"
case .accessibility: return "Accessibility"
case .screenRecording: return "Screen Recording"
case .microphone: return "Microphone"
case .speechRecognition: return "Speech Recognition"
case .notifications: "Notifications"
case .accessibility: "Accessibility"
case .screenRecording: "Screen Recording"
case .microphone: "Microphone"
case .speechRecognition: "Speech Recognition"
}
}
private var subtitle: String {
switch capability {
case .notifications: return "Show desktop alerts for agent activity"
case .accessibility: return "Control UI elements when an action requires it"
case .screenRecording: return "Capture the screen for context or screenshots"
case .microphone: return "Allow Voice Wake and audio capture"
case .speechRecognition: return "Transcribe Voice Wake trigger phrases on-device"
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 capability {
case .notifications: return "bell"
case .accessibility: return "hand.raised"
case .screenRecording: return "display"
case .microphone: return "mic"
case .speechRecognition: return "waveform"
case .notifications: "bell"
case .accessibility: "hand.raised"
case .screenRecording: "display"
case .microphone: "mic"
case .speechRecognition: "waveform"
}
}
}
@@ -2435,7 +2456,7 @@ struct MicLevelBar: View {
var body: some View {
HStack(spacing: 3) {
ForEach(0..<segments, id: \.self) { idx in
ForEach(0 ..< segments, id: \.self) { idx in
let fill = level * Double(segments) > Double(idx)
RoundedRectangle(cornerRadius: 2)
.fill(fill ? segmentColor(for: idx) : Color.gray.opacity(0.35))
@@ -2519,10 +2540,8 @@ struct OnboardingView: View {
readyPage().frame(width: pageWidth)
}
.offset(x: CGFloat(-currentPage) * pageWidth)
.animation(
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
value: currentPage
)
.animation(.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
value: currentPage)
.frame(width: pageWidth, height: contentHeight, alignment: .top)
.clipped()
}
@@ -2560,21 +2579,15 @@ struct OnboardingView: View {
Text("What Clawdis handles")
.font(.largeTitle.weight(.semibold))
onboardingCard {
featureRow(
title: "Owns the TCC prompts",
subtitle: "Requests Notifications, Accessibility, and Screen Recording so your agents stay unblocked.",
systemImage: "lock.shield"
)
featureRow(
title: "Native notifications",
subtitle: "Shows desktop toasts for agent events with your preferred sound.",
systemImage: "bell.and.waveform"
)
featureRow(
title: "Privileged helpers",
subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI with the right permissions.",
systemImage: "terminal"
)
featureRow(title: "Owns the TCC prompts",
subtitle: "Requests Notifications, Accessibility, and Screen Recording so your agents stay unblocked.",
systemImage: "lock.shield")
featureRow(title: "Native notifications",
subtitle: "Shows desktop toasts for agent events with your preferred sound.",
systemImage: "bell.and.waveform")
featureRow(title: "Privileged helpers",
subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI with the right permissions.",
systemImage: "terminal")
}
}
}
@@ -2683,16 +2696,12 @@ struct OnboardingView: View {
Text("All set")
.font(.largeTitle.weight(.semibold))
onboardingCard {
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"
)
featureRow(
title: "Test a notification",
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
systemImage: "bell.badge"
)
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")
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)
@@ -2728,7 +2737,7 @@ struct OnboardingView: View {
Spacer()
HStack(spacing: 8) {
ForEach(0..<pageCount, id: \.self) { index in
ForEach(0 ..< pageCount, id: \.self) { index in
Button {
withAnimation { currentPage = index }
} label: {
@@ -2858,14 +2867,12 @@ private struct GlowingClawdisIcon: View {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [
Color.accentColor.opacity(glowIntensity),
Color.blue.opacity(glowIntensity * 0.6)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
LinearGradient(colors: [
Color.accentColor.opacity(glowIntensity),
Color.blue.opacity(glowIntensity * 0.6)
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
)
.blur(radius: 22)
.scaleEffect(breathe ? 1.12 : 0.95)

View File

@@ -14,16 +14,15 @@ struct ClawdisCLI {
do {
let request = try parseCommandLine()
let response = try await send(request: request)
let payloadString: String?
if let payload = response.payload, let text = String(data: payload, encoding: .utf8) {
payloadString = text
let payloadString: String? = if let payload = response.payload, let text = String(data: payload, encoding: .utf8) {
text
} else {
payloadString = nil
nil
}
let output: [String: Any] = [
"ok": response.ok,
"message": response.message ?? "",
"payload": payloadString ?? "",
"payload": payloadString ?? ""
]
let json = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted])
FileHandle.standardOutput.write(json)
@@ -56,6 +55,7 @@ struct ClawdisCLI {
}
guard let t = title, let b = body else { throw CLIError.help }
return .notify(title: t, body: b, sound: sound)
case "ensure-permissions":
var caps: [Capability] = []
var interactive = false
@@ -70,6 +70,7 @@ struct ClawdisCLI {
}
if caps.isEmpty { caps = Capability.allCases }
return .ensurePermissions(caps, interactive: interactive)
case "screenshot":
var displayID: UInt32?
var windowID: UInt32?
@@ -82,6 +83,7 @@ struct ClawdisCLI {
}
}
return .screenshot(displayID: displayID, windowID: windowID, format: "png")
case "run":
var cwd: String?
var env: [String: String] = [:]
@@ -92,19 +94,23 @@ struct ClawdisCLI {
let arg = args.removeFirst()
switch arg {
case "--cwd": cwd = args.popFirst()
case "--env":
if let pair = args.popFirst(), let eq = pair.firstIndex(of: "=") {
let k = String(pair[..<eq]); let v = String(pair[pair.index(after: eq)...]); env[k] = v
}
case "--timeout": if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl }
case "--needs-screen-recording": needsSR = true
default:
cmd.append(arg)
}
}
return .runShell(command: cmd, cwd: cwd, env: env.isEmpty ? nil : env, timeoutSec: timeout, needsScreenRecording: needsSR)
case "status":
return .status
default:
throw CLIError.help
}
@@ -131,7 +137,7 @@ struct ClawdisCLI {
enum CLIError: Error { case help }
extension Array where Element == String {
extension [String] {
mutating func popFirst() -> String? {
guard let first else { return nil }
self = Array(self.dropFirst())

View File

@@ -62,15 +62,18 @@ extension Request: Codable {
try container.encode(title, forKey: .title)
try container.encode(body, forKey: .body)
try container.encodeIfPresent(sound, forKey: .sound)
case let .ensurePermissions(caps, interactive):
try container.encode(Kind.ensurePermissions, forKey: .type)
try container.encode(caps, forKey: .caps)
try container.encode(interactive, forKey: .interactive)
case let .screenshot(displayID, windowID, format):
try container.encode(Kind.screenshot, forKey: .type)
try container.encodeIfPresent(displayID, forKey: .displayID)
try container.encodeIfPresent(windowID, forKey: .windowID)
try container.encode(format, forKey: .format)
case let .runShell(command, cwd, env, timeoutSec, needsSR):
try container.encode(Kind.runShell, forKey: .type)
try container.encode(command, forKey: .command)
@@ -78,6 +81,7 @@ extension Request: Codable {
try container.encodeIfPresent(env, forKey: .env)
try container.encodeIfPresent(timeoutSec, forKey: .timeoutSec)
try container.encode(needsSR, forKey: .needsScreenRecording)
case .status:
try container.encode(Kind.status, forKey: .type)
}
@@ -92,15 +96,18 @@ extension Request: Codable {
let body = try container.decode(String.self, forKey: .body)
let sound = try container.decodeIfPresent(String.self, forKey: .sound)
self = .notify(title: title, body: body, sound: sound)
case .ensurePermissions:
let caps = try container.decode([Capability].self, forKey: .caps)
let interactive = try container.decode(Bool.self, forKey: .interactive)
self = .ensurePermissions(caps, interactive: interactive)
case .screenshot:
let displayID = try container.decodeIfPresent(UInt32.self, forKey: .displayID)
let windowID = try container.decodeIfPresent(UInt32.self, forKey: .windowID)
let format = try container.decode(String.self, forKey: .format)
self = .screenshot(displayID: displayID, windowID: windowID, format: format)
case .runShell:
let command = try container.decode([String].self, forKey: .command)
let cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
@@ -108,6 +115,7 @@ extension Request: Codable {
let timeout = try container.decodeIfPresent(Double.self, forKey: .timeoutSec)
let needsSR = try container.decode(Bool.self, forKey: .needsScreenRecording)
self = .runShell(command: command, cwd: cwd, env: env, timeoutSec: timeout, needsScreenRecording: needsSR)
case .status:
self = .status
}

View File

@@ -36,7 +36,8 @@ const TOKENS_PAD = 20;
const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0);
const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
const formatKTokens = (value: number) =>
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
const truncateKey = (key: string) => {
if (key.length <= KEY_PAD) return key;
@@ -60,7 +61,9 @@ const formatTokensCell = (
if (!total) return "-".padEnd(TOKENS_PAD);
const totalLabel = formatKTokens(total);
const ctxLabel = contextTokens ? formatKTokens(contextTokens) : "?";
const pct = contextTokens ? Math.min(999, Math.round((total / contextTokens) * 100)) : null;
const pct = contextTokens
? Math.min(999, Math.round((total / contextTokens) * 100))
: null;
const label = `${totalLabel}/${ctxLabel} (${pct ?? "?"}%)`;
const padded = label.padEnd(TOKENS_PAD);
return colorByPct(padded, pct, rich);