chore: fix swiftlint after split

main
Peter Steinberger 2025-12-07 00:14:03 +01:00
parent 82e751a153
commit 7b7c4bd116
12 changed files with 123 additions and 124 deletions

View File

@ -62,7 +62,7 @@ final class AppState: ObservableObject {
@Published var isWorking: Bool = false @Published var isWorking: Bool = false
@Published var earBoostActive: Bool = false @Published var earBoostActive: Bool = false
private var earBoostTask: Task<Void, Never>? = nil private var earBoostTask: Task<Void, Never>?
init() { init() {
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)

View File

@ -23,15 +23,15 @@ struct ClawdisApp: App {
earBoostActive: self.state.earBoostActive, earBoostActive: self.state.earBoostActive,
relayStatus: self.relayManager.status) relayStatus: self.relayManager.status)
} }
.menuBarExtraStyle(.menu) .menuBarExtraStyle(.menu)
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
self.statusItem = item self.statusItem = item
self.applyStatusItemAppearance(paused: self.state.isPaused) self.applyStatusItemAppearance(paused: self.state.isPaused)
} }
.onChange(of: self.state.isPaused) { _, paused in .onChange(of: self.state.isPaused) { _, paused in
self.applyStatusItemAppearance(paused: paused) self.applyStatusItemAppearance(paused: paused)
self.relayManager.setActive(!paused) self.relayManager.setActive(!paused)
} }
Settings { Settings {
SettingsRootView(state: self.state) SettingsRootView(state: self.state)
@ -90,10 +90,10 @@ private struct MenuContent: View {
private func statusColor(_ status: RelayProcessManager.Status) -> Color { private func statusColor(_ status: RelayProcessManager.Status) -> Color {
switch status { switch status {
case .running: return .green case .running: .green
case .starting, .restarting: return .orange case .starting, .restarting: .orange
case .failed: return .red case .failed: .red
case .stopped: return .secondary case .stopped: .secondary
} }
} }
@ -257,17 +257,17 @@ private struct CritterStatusLabel: View {
private var relayNeedsAttention: Bool { private var relayNeedsAttention: Bool {
switch self.relayStatus { switch self.relayStatus {
case .failed, .stopped: case .failed, .stopped:
return !self.isPaused !self.isPaused
case .starting, .restarting, .running: case .starting, .restarting, .running:
return false false
} }
} }
private var relayBadgeColor: Color { private var relayBadgeColor: Color {
switch self.relayStatus { switch self.relayStatus {
case .failed: return .red case .failed: .red
case .stopped: return .orange case .stopped: .orange
default: return .clear default: .clear
} }
} }
} }
@ -279,8 +279,8 @@ enum CritterIconRenderer {
blink: CGFloat, blink: CGFloat,
legWiggle: CGFloat = 0, legWiggle: CGFloat = 0,
earWiggle: CGFloat = 0, earWiggle: CGFloat = 0,
earScale: CGFloat = 1 earScale: CGFloat = 1) -> NSImage
) -> NSImage { {
let image = NSImage(size: size) let image = NSImage(size: size)
image.lockFocus() image.lockFocus()
defer { image.unlockFocus() } defer { image.unlockFocus() }

View File

@ -120,7 +120,8 @@ struct OnboardingView: View {
self.onboardingCard { self.onboardingCard {
self.featureRow( self.featureRow(
title: "Owns the TCC prompts", title: "Owns the TCC prompts",
subtitle: "Requests Notifications, Accessibility, and Screen Recording so your agents stay unblocked.", subtitle: "Requests Notifications, Accessibility, and Screen Recording "
+ "so your agents stay unblocked.",
systemImage: "lock.shield") systemImage: "lock.shield")
self.featureRow( self.featureRow(
title: "Native notifications", title: "Native notifications",
@ -128,7 +129,8 @@ struct OnboardingView: View {
systemImage: "bell.and.waveform") systemImage: "bell.and.waveform")
self.featureRow( self.featureRow(
title: "Privileged helpers", title: "Privileged helpers",
subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI with the right permissions.", subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI "
+ "with the right permissions.",
systemImage: "terminal") systemImage: "terminal")
} }
} }
@ -232,7 +234,8 @@ struct OnboardingView: View {
Spacer() Spacer()
} }
Text( Text(
"You can pause from the menu bar anytime. Settings keeps a \"Show onboarding\" button if you need to revisit.") "You can pause from the menu bar anytime. Settings keeps a \"Show onboarding\" "
+ "button if you need to revisit.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
@ -247,7 +250,8 @@ struct OnboardingView: View {
self.onboardingCard { self.onboardingCard {
self.featureRow( self.featureRow(
title: "Run the dashboard", title: "Run the dashboard",
subtitle: "Use the CLI helper from your scripts, and reopen onboarding from Settings if you add a new user.", subtitle: "Use the CLI helper from your scripts, and reopen onboarding from "
+ "Settings if you add a new user.",
systemImage: "checkmark.seal") systemImage: "checkmark.seal")
self.featureRow( self.featureRow(
title: "Test a notification", title: "Test a notification",
@ -273,10 +277,10 @@ struct OnboardingView: View {
.disabled(true) .disabled(true)
if self.currentPage > 0 { if self.currentPage > 0 {
Button(action: { self.handleBack() }) { Button(action: self.handleBack, label: {
Label("Back", systemImage: "chevron.left") Label("Back", systemImage: "chevron.left")
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
} })
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.opacity(0.8) .opacity(0.8)

View File

@ -23,7 +23,8 @@ enum PermissionManager {
case .notDetermined: case .notDetermined:
if interactive { if interactive {
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ??
false
let updated = await center.notificationSettings() let updated = await center.notificationSettings()
results[cap] = granted && (updated.authorizationStatus == .authorized || updated results[cap] = granted && (updated.authorizationStatus == .authorized || updated
.authorizationStatus == .provisional) .authorizationStatus == .provisional)

View File

@ -46,7 +46,7 @@ final class RelayProcessManager: ObservableObject {
private var recentCrashes: [Date] = [] private var recentCrashes: [Date] = []
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "relay") private let logger = Logger(subsystem: "com.steipete.clawdis", category: "relay")
private let logLimit = 20_000 // characters to keep in-memory private let logLimit = 20000 // characters to keep in-memory
private let maxCrashes = 3 private let maxCrashes = 3
private let crashWindow: TimeInterval = 120 // seconds private let crashWindow: TimeInterval = 120 // seconds
@ -98,8 +98,8 @@ final class RelayProcessManager: ObservableObject {
.name(command.first ?? "clawdis"), .name(command.first ?? "clawdis"),
arguments: Arguments(Array(command.dropFirst())), arguments: Arguments(Array(command.dropFirst())),
environment: self.makeEnvironment(), environment: self.makeEnvironment(),
workingDirectory: FilePath(cwd) workingDirectory: FilePath(cwd))
) { execution, stdin, stdout, stderr in { execution, stdin, stdout, stderr in
self.didStart(execution) self.didStart(execution)
async let out: Void = self.stream(output: stdout, label: "stdout") async let out: Void = self.stream(output: stdout, label: "stdout")
async let err: Void = self.stream(output: stderr, label: "stderr") async let err: Void = self.stream(output: stderr, label: "stderr")
@ -122,12 +122,10 @@ final class RelayProcessManager: ObservableObject {
} }
private func handleTermination(status: TerminationStatus) async { private func handleTermination(status: TerminationStatus) async {
let code: Int32 = { let code: Int32 = switch status {
switch status { case let .exited(exitCode): exitCode
case let .exited(exitCode): return exitCode case let .unhandledException(sig): -Int32(sig)
case let .unhandledException(sig): return -Int32(sig) }
}
}()
self.execution = nil self.execution = nil
if self.stopping || !self.desiredActive { if self.stopping || !self.desiredActive {
@ -161,7 +159,7 @@ final class RelayProcessManager: ObservableObject {
} }
self.appendLog("[relay] failed: \(message)\n") self.appendLog("[relay] failed: \(message)\n")
self.logger.error("relay failed: \(message, privacy: .public)") self.logger.error("relay failed: \(message, privacy: .public)")
if self.desiredActive && !self.shouldGiveUpAfterCrashes() { if self.desiredActive, !self.shouldGiveUpAfterCrashes() {
self.status = .restarting self.status = .restarting
self.recentCrashes.append(Date()) self.recentCrashes.append(Date())
self.startIfNeeded() self.startIfNeeded()
@ -200,7 +198,8 @@ final class RelayProcessManager: ObservableObject {
// Keep it simple: rely on system-installed clawdis/warelay. // Keep it simple: rely on system-installed clawdis/warelay.
// Default to `clawdis relay`; users can provide an override via env if needed. // Default to `clawdis relay`; users can provide an override via env if needed.
if let override = ProcessInfo.processInfo.environment["CLAWDIS_RELAY_CMD"], if let override = ProcessInfo.processInfo.environment["CLAWDIS_RELAY_CMD"],
!override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return override.split(separator: " ").map(String.init) return override.split(separator: " ").map(String.init)
} }
@ -257,7 +256,8 @@ final class RelayProcessManager: ObservableObject {
private func defaultProjectRoot() -> URL { private func defaultProjectRoot() -> URL {
if let stored = UserDefaults.standard.string(forKey: Defaults.projectRootPath), if let stored = UserDefaults.standard.string(forKey: Defaults.projectRootPath),
let url = self.expandPath(stored) { let url = self.expandPath(stored)
{
return url return url
} }
let fallback = FileManager.default.homeDirectoryForCurrentUser let fallback = FileManager.default.homeDirectoryForCurrentUser
@ -275,7 +275,7 @@ final class RelayProcessManager: ObservableObject {
func projectRootPath() -> String { func projectRootPath() -> String {
UserDefaults.standard.string(forKey: Defaults.projectRootPath) UserDefaults.standard.string(forKey: Defaults.projectRootPath)
?? FileManager.default.homeDirectoryForCurrentUser ?? FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdis").path .appendingPathComponent("Projects/clawdis").path
} }
private func expandPath(_ path: String) -> URL? { private func expandPath(_ path: String) -> URL? {

View File

@ -115,11 +115,9 @@ extension [String] {
fileprivate func dedupedPreserveOrder() -> [String] { fileprivate func dedupedPreserveOrder() -> [String] {
var seen = Set<String>() var seen = Set<String>()
var result: [String] = [] var result: [String] = []
for item in self { for item in self where !seen.contains(item) {
if !seen.contains(item) { seen.insert(item)
seen.insert(item) result.append(item)
result.append(item)
}
} }
return result return result
} }

View File

@ -16,11 +16,11 @@ private enum InstallMethod: Equatable {
let .npm(_, binary), let .npm(_, binary),
let .go(_, binary), let .go(_, binary),
let .pnpm(_, _, binary): let .pnpm(_, _, binary):
return binary binary
case .gitClone: case .gitClone:
return nil nil
case .mcporter: case .mcporter:
return "mcporter" "mcporter"
} }
} }
} }
@ -57,80 +57,70 @@ struct ToolsSettings: View {
url: URL(string: "https://github.com/steipete/mcporter")!, url: URL(string: "https://github.com/steipete/mcporter")!,
description: "MCP runtime/CLI to discover servers, run tools, and sync configs across AI clients.", description: "MCP runtime/CLI to discover servers, run tools, and sync configs across AI clients.",
method: .npm(package: "mcporter", binary: "mcporter"), method: .npm(package: "mcporter", binary: "mcporter"),
kind: .tool kind: .tool),
),
ToolEntry( ToolEntry(
id: "peekaboo", id: "peekaboo",
name: "Peekaboo", name: "Peekaboo",
url: URL(string: "https://github.com/steipete/Peekaboo")!, url: URL(string: "https://github.com/steipete/Peekaboo")!,
description: "Lightning-fast macOS screenshots with AI vision helpers for step-by-step automation.", description: "Lightning-fast macOS screenshots with AI vision helpers for step-by-step automation.",
method: .brew(formula: "steipete/tap/peekaboo", binary: "peekaboo"), method: .brew(formula: "steipete/tap/peekaboo", binary: "peekaboo"),
kind: .tool kind: .tool),
),
ToolEntry( ToolEntry(
id: "camsnap", id: "camsnap",
name: "camsnap", name: "camsnap",
url: URL(string: "https://github.com/steipete/camsnap")!, url: URL(string: "https://github.com/steipete/camsnap")!,
description: "One command to grab frames, clips, or motion alerts from RTSP/ONVIF cameras.", description: "One command to grab frames, clips, or motion alerts from RTSP/ONVIF cameras.",
method: .brew(formula: "steipete/tap/camsnap", binary: "camsnap"), method: .brew(formula: "steipete/tap/camsnap", binary: "camsnap"),
kind: .tool kind: .tool),
),
ToolEntry( ToolEntry(
id: "oracle", id: "oracle",
name: "oracle", name: "oracle",
url: URL(string: "https://github.com/steipete/oracle")!, url: URL(string: "https://github.com/steipete/oracle")!,
description: "Runs OpenAI-ready agent workflows from the CLI with session replay and browser control.", description: "Runs OpenAI-ready agent workflows from the CLI with session replay and browser control.",
method: .npm(package: "@steipete/oracle", binary: "oracle"), method: .npm(package: "@steipete/oracle", binary: "oracle"),
kind: .tool kind: .tool),
),
ToolEntry( ToolEntry(
id: "eightctl", id: "eightctl",
name: "eightctl", name: "eightctl",
url: URL(string: "https://github.com/steipete/eightctl")!, url: URL(string: "https://github.com/steipete/eightctl")!,
description: "Control Eight Sleep Pods (temp, alarms, schedules, metrics) from scripts or cron.", description: "Control Eight Sleep Pods (temp, alarms, schedules, metrics) from scripts or cron.",
method: .go(module: "github.com/steipete/eightctl/cmd/eightctl@latest", binary: "eightctl"), method: .go(module: "github.com/steipete/eightctl/cmd/eightctl@latest", binary: "eightctl"),
kind: .tool kind: .tool),
),
ToolEntry( ToolEntry(
id: "imsg", id: "imsg",
name: "imsg", name: "imsg",
url: URL(string: "https://github.com/steipete/imsg")!, url: URL(string: "https://github.com/steipete/imsg")!,
description: "CLI for macOS Messages: read/tail chats and send iMessage/SMS with attachments.", description: "CLI for macOS Messages: read/tail chats and send iMessage/SMS with attachments.",
method: .go(module: "github.com/steipete/imsg/cmd/imsg@latest", binary: "imsg"), method: .go(module: "github.com/steipete/imsg/cmd/imsg@latest", binary: "imsg"),
kind: .tool kind: .tool),
),
ToolEntry( ToolEntry(
id: "spotify-player", id: "spotify-player",
name: "spotify-player", name: "spotify-player",
url: URL(string: "https://github.com/aome510/spotify-player")!, url: URL(string: "https://github.com/aome510/spotify-player")!,
description: "Terminal Spotify client to queue, search, and control playback without leaving chat.", description: "Terminal Spotify client to queue, search, and control playback without leaving chat.",
method: .brew(formula: "spotify_player", binary: "spotify_player"), method: .brew(formula: "spotify_player", binary: "spotify_player"),
kind: .tool kind: .tool),
),
ToolEntry( ToolEntry(
id: "openhue-cli", id: "openhue-cli",
name: "OpenHue CLI", name: "OpenHue CLI",
url: URL(string: "https://github.com/openhue/openhue-cli")!, url: URL(string: "https://github.com/openhue/openhue-cli")!,
description: "Control Philips Hue lights from scripts—scenes, dimming, and automations.", description: "Control Philips Hue lights from scripts—scenes, dimming, and automations.",
method: .brew(formula: "openhue/cli/openhue-cli", binary: "openhue"), method: .brew(formula: "openhue/cli/openhue-cli", binary: "openhue"),
kind: .tool kind: .tool),
),
ToolEntry( ToolEntry(
id: "openai-whisper", id: "openai-whisper",
name: "OpenAI Whisper", name: "OpenAI Whisper",
url: URL(string: "https://github.com/openai/whisper")!, url: URL(string: "https://github.com/openai/whisper")!,
description: "On-device speech-to-text for quick note taking or voicemail transcription.", description: "On-device speech-to-text for quick note taking or voicemail transcription.",
method: .brew(formula: "openai-whisper", binary: "whisper"), method: .brew(formula: "openai-whisper", binary: "whisper"),
kind: .tool kind: .tool),
),
ToolEntry( ToolEntry(
id: "gemini-cli", id: "gemini-cli",
name: "Gemini CLI", name: "Gemini CLI",
url: URL(string: "https://github.com/google-gemini/gemini-cli")!, url: URL(string: "https://github.com/google-gemini/gemini-cli")!,
description: "Google Gemini models from the terminal for fast Q&A and web-grounded summaries.", description: "Google Gemini models from the terminal for fast Q&A and web-grounded summaries.",
method: .brew(formula: "gemini-cli", binary: "gemini"), method: .brew(formula: "gemini-cli", binary: "gemini"),
kind: .tool kind: .tool),
),
ToolEntry( ToolEntry(
id: "bird", id: "bird",
name: "bird", name: "bird",
@ -139,10 +129,8 @@ struct ToolsSettings: View {
method: .pnpm( method: .pnpm(
repoPath: "\(NSHomeDirectory())/Projects/bird", repoPath: "\(NSHomeDirectory())/Projects/bird",
script: "binary", script: "binary",
binary: "bird" binary: "bird"),
), kind: .tool),
kind: .tool
),
ToolEntry( ToolEntry(
id: "agent-tools", id: "agent-tools",
name: "agent-tools", name: "agent-tools",
@ -150,10 +138,8 @@ struct ToolsSettings: View {
description: "Collection of utilities and scripts tuned for autonomous agents and MCP clients.", description: "Collection of utilities and scripts tuned for autonomous agents and MCP clients.",
method: .gitClone( method: .gitClone(
url: "https://github.com/badlogic/agent-tools.git", url: "https://github.com/badlogic/agent-tools.git",
destination: "\(NSHomeDirectory())/agent-tools" destination: "\(NSHomeDirectory())/agent-tools"),
), kind: .tool),
kind: .tool
),
ToolEntry( ToolEntry(
id: "gmail-mcp", id: "gmail-mcp",
name: "Gmail MCP", name: "Gmail MCP",
@ -162,10 +148,8 @@ struct ToolsSettings: View {
method: .mcporter( method: .mcporter(
name: "gmail", name: "gmail",
command: "npx -y @gongrzhe/server-gmail-autoauth-mcp", command: "npx -y @gongrzhe/server-gmail-autoauth-mcp",
summary: "Adds Gmail MCP via mcporter (stdio transport, auto-auth)." summary: "Adds Gmail MCP via mcporter (stdio transport, auto-auth)."),
), kind: .mcp),
kind: .mcp
),
ToolEntry( ToolEntry(
id: "google-calendar-mcp", id: "google-calendar-mcp",
name: "Google Calendar MCP", name: "Google Calendar MCP",
@ -174,10 +158,8 @@ struct ToolsSettings: View {
method: .mcporter( method: .mcporter(
name: "google-calendar", name: "google-calendar",
command: "npx -y @cocal/google-calendar-mcp", command: "npx -y @cocal/google-calendar-mcp",
summary: "Adds Google Calendar MCP via mcporter (stdio transport)." summary: "Adds Google Calendar MCP via mcporter (stdio transport)."),
), kind: .mcp),
kind: .mcp
),
] ]
var body: some View { var body: some View {
@ -188,8 +170,8 @@ struct ToolsSettings: View {
ScrollView { ScrollView {
LazyVStack(spacing: 12) { LazyVStack(spacing: 12) {
section(for: .tool, title: "CLI Tools") self.section(for: .tool, title: "CLI Tools")
section(for: .mcp, title: "MCP Servers") self.section(for: .mcp, title: "MCP Servers")
} }
} }
} }
@ -198,7 +180,7 @@ struct ToolsSettings: View {
} }
private func section(for kind: ToolEntry.Kind, title: String) -> some View { private func section(for kind: ToolEntry.Kind, title: String) -> some View {
let filtered = tools.filter { $0.kind == kind } let filtered = self.tools.filter { $0.kind == kind }
return VStack(alignment: .leading, spacing: 10) { return VStack(alignment: .leading, spacing: 10) {
Text(title) Text(title)
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
@ -212,8 +194,7 @@ struct ToolsSettings: View {
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.stroke(Color.secondary.opacity(0.15), lineWidth: 1) .stroke(Color.secondary.opacity(0.15), lineWidth: 1))
)
} }
} }
} }
@ -231,15 +212,15 @@ private struct ToolRow: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 10) { HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Link(tool.name, destination: tool.url) Link(self.tool.name, destination: self.tool.url)
.font(.headline) .font(.headline)
Text(tool.description) Text(self.tool.description)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
Spacer() Spacer()
actionButton self.actionButton
} }
if let statusMessage, !statusMessage.isEmpty { if let statusMessage, !statusMessage.isEmpty {
@ -248,12 +229,12 @@ private struct ToolRow: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.onAppear { refresh() } .onAppear { self.refresh() }
} }
private var actionButton: some View { private var actionButton: some View {
VStack { VStack {
switch state { switch self.state {
case .installed: case .installed:
Label("Installed", systemImage: "checkmark.circle.fill") Label("Installed", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green) .foregroundStyle(.green)
@ -261,12 +242,12 @@ private struct ToolRow: View {
case .installing: case .installing:
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
case .failed: case .failed:
Button("Retry") { install() } Button("Retry") { self.install() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
case .checking: case .checking:
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
case .notInstalled: case .notInstalled:
Button("Install") { install() } Button("Install") { self.install() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
} }
@ -274,21 +255,21 @@ private struct ToolRow: View {
private func refresh() { private func refresh() {
Task { Task {
state = .checking self.state = .checking
let installed = await ToolInstaller.isInstalled(tool.method) let installed = await ToolInstaller.isInstalled(self.tool.method)
await MainActor.run { await MainActor.run {
state = installed ? .installed : .notInstalled self.state = installed ? .installed : .notInstalled
} }
} }
} }
private func install() { private func install() {
Task { Task {
state = .installing self.state = .installing
let result = await ToolInstaller.install(tool.method) let result = await ToolInstaller.install(self.tool.method)
await MainActor.run { await MainActor.run {
statusMessage = result.message self.statusMessage = result.message
state = result.installed ? .installed : .failed(result.message) self.state = result.installed ? .installed : .failed(result.message)
} }
} }
} }
@ -305,30 +286,30 @@ private enum ToolInstaller {
static func isInstalled(_ method: InstallMethod) async -> Bool { static func isInstalled(_ method: InstallMethod) async -> Bool {
switch method { switch method {
case let .brew(formula, _): case let .brew(formula, _):
return await shellSucceeds("brew list --versions \(formula)") return await self.shellSucceeds("brew list --versions \(formula)")
case let .npm(_, binary), case let .npm(_, binary),
let .go(_, binary), let .go(_, binary),
let .pnpm(_, _, binary): let .pnpm(_, _, binary):
return await commandExists(binary) return await self.commandExists(binary)
case let .gitClone(_, destination): case let .gitClone(_, destination):
return FileManager.default.fileExists(atPath: destination) return FileManager.default.fileExists(atPath: destination)
case let .mcporter(name, _, _): case let .mcporter(name, _, _):
guard await commandExists("mcporter") else { return false } guard await self.commandExists("mcporter") else { return false }
return await shellSucceeds("mcporter config get \(name) --json") return await self.shellSucceeds("mcporter config get \(name) --json")
} }
} }
static func install(_ method: InstallMethod) async -> InstallResult { static func install(_ method: InstallMethod) async -> InstallResult {
switch method { switch method {
case let .brew(formula, _): case let .brew(formula, _):
return await runInstall("brew install \(formula)") return await self.runInstall("brew install \(formula)")
case let .npm(package, _): case let .npm(package, _):
return await runInstall("npm install -g \(package)") return await self.runInstall("npm install -g \(package)")
case let .go(module, _): case let .go(module, _):
return await runInstall("GO111MODULE=on go install \(module)") return await self.runInstall("GO111MODULE=on go install \(module)")
case let .pnpm(repoPath, script, _): case let .pnpm(repoPath, script, _):
let cmd = "cd \(escape(repoPath)) && pnpm install && pnpm run \(script)" let cmd = "cd \(escape(repoPath)) && pnpm install && pnpm run \(script)"
return await runInstall(cmd) return await self.runInstall(cmd)
case let .gitClone(url, destination): case let .gitClone(url, destination):
let cmd = """ let cmd = """
if [ -d \(escape(destination)) ]; then if [ -d \(escape(destination)) ]; then
@ -337,19 +318,19 @@ private enum ToolInstaller {
git clone \(url) \(escape(destination)) git clone \(url) \(escape(destination))
fi fi
""" """
return await runInstall(cmd) return await self.runInstall(cmd)
case let .mcporter(name, command, summary): case let .mcporter(name, command, summary):
let cmd = """ let cmd = """
mcporter config add \(name) --command "\(command)" --transport stdio --scope home --description "\(summary)" mcporter config add \(name) --command "\(command)" --transport stdio --scope home --description "\(summary)"
""" """
return await runInstall(cmd) return await self.runInstall(cmd)
} }
} }
// MARK: - Helpers // MARK: - Helpers
private static func commandExists(_ binary: String) async -> Bool { private static func commandExists(_ binary: String) async -> Bool {
await shellSucceeds("command -v \(binary)") await self.shellSucceeds("command -v \(binary)")
} }
private static func shellSucceeds(_ command: String) async -> Bool { private static func shellSucceeds(_ command: String) async -> Bool {

View File

@ -1,5 +1,5 @@
import Foundation
import AppKit import AppKit
import Foundation
enum LaunchdManager { enum LaunchdManager {
private static func runLaunchctl(_ args: [String]) { private static func runLaunchctl(_ args: [String]) {

View File

@ -103,7 +103,10 @@ final class VoiceWakeTester {
domain: "VoiceWakeTester", domain: "VoiceWakeTester",
code: 3, code: 3,
userInfo: [ userInfo: [
NSLocalizedDescriptionKey: "Missing mic/speech privacy strings. Rebuild the mac app (scripts/restart-mac.sh) to include usage descriptions.", NSLocalizedDescriptionKey: """
Missing mic/speech privacy strings. Rebuild the mac app (scripts/restart-mac.sh) \
to include usage descriptions.
""",
]) ])
} }
@ -256,7 +259,8 @@ struct VoiceWakeSettings: View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
SettingsToggleRow( SettingsToggleRow(
title: "Enable Voice Wake", title: "Enable Voice Wake",
subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands. Voice recognition runs fully on-device.", subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands. "
+ "Voice recognition runs fully on-device.",
binding: self.$state.swabbleEnabled) binding: self.$state.swabbleEnabled)
.disabled(!voiceWakeSupported) .disabled(!voiceWakeSupported)
@ -314,7 +318,8 @@ struct VoiceWakeSettings: View {
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)) .stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text( Text(
"Clawdis reacts when any trigger appears in a transcription. Keep them short to avoid false positives.") "Clawdis reacts when any trigger appears in a transcription. "
+ "Keep them short to avoid false positives.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)

View File

@ -69,7 +69,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
} }
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
private func loadPage() { private func loadPage() {
let messagesJSON = self.initialMessagesJSON.replacingOccurrences(of: "</script>", with: "<\\/script>") let messagesJSON = self.initialMessagesJSON.replacingOccurrences(of: "</script>", with: "<\\/script>")
@ -112,7 +112,13 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
"{}" "{}"
} }
let html = """ let html = self.makeHTML(importMapJSON: importMapJSON, messagesJSON: messagesJSON)
self.webView.loadHTMLString(html, baseURL: webChatURL)
}
// swiftlint:disable line_length
private func makeHTML(importMapJSON: String, messagesJSON: String) -> String {
"""
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
@ -156,7 +162,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
class NativeTransport { class NativeTransport {
async *run(messages, userMessage, cfg, signal) { async *run(messages, userMessage, cfg, signal) {
const result = await window.__clawdisSend({ type: 'chat', payload: { text: userMessage.content?.[0]?.text ?? '', sessionKey: '\( const result = await window.__clawdisSend({ type: 'chat', payload: { text: userMessage.content?.[0]?.text ?? '', sessionKey: '\(
sessionKey)' } }); self.sessionKey)' } });
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }; const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
const assistant = { const assistant = {
role: 'assistant', role: 'assistant',
@ -225,9 +231,10 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
</body> </body>
</html> </html>
""" """
self.webView.loadHTMLString(html, baseURL: webChatURL)
} }
// swiftlint:enable line_length
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.evaluateJavaScript("document.body.innerText") { result, error in webView.evaluateJavaScript("document.body.innerText") { result, error in
if let error { if let error {

View File

@ -1,6 +1,6 @@
import ClawdisIPC
import Foundation import Foundation
import OSLog import OSLog
import ClawdisIPC
@objc protocol ClawdisXPCProtocol { @objc protocol ClawdisXPCProtocol {
func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void) func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void)

View File

@ -37,6 +37,7 @@ struct ClawdisCLI {
} }
} }
// swiftlint:disable cyclomatic_complexity
private static func parseCommandLine() throws -> Request { private static func parseCommandLine() throws -> Request {
var args = Array(CommandLine.arguments.dropFirst()) var args = Array(CommandLine.arguments.dropFirst())
guard let command = args.first else { throw CLIError.help } guard let command = args.first else { throw CLIError.help }
@ -126,6 +127,8 @@ struct ClawdisCLI {
} }
} }
// swiftlint:enable cyclomatic_complexity
private static func send(request: Request) async throws -> Response { private static func send(request: Request) async throws -> Response {
let conn = NSXPCConnection(machServiceName: serviceName) let conn = NSXPCConnection(machServiceName: serviceName)
let interface = NSXPCInterface(with: ClawdisXPCProtocol.self) let interface = NSXPCInterface(with: ClawdisXPCProtocol.self)