chore(swift): run swiftformat and clear swiftlint

This commit is contained in:
Peter Steinberger
2025-12-13 19:53:17 +00:00
parent 39c232548c
commit 6143338116
18 changed files with 713 additions and 723 deletions

View File

@@ -51,8 +51,7 @@ actor BridgeClient {
nodeId: hello.nodeId, nodeId: hello.nodeId,
displayName: hello.displayName, displayName: hello.displayName,
platform: hello.platform, platform: hello.platform,
version: hello.version version: hello.version),
),
over: connection) over: connection)
onStatus?("Waiting for approval…") onStatus?("Waiting for approval…")

View File

@@ -42,36 +42,36 @@ final class NodeAppModel: ObservableObject {
} }
} }
func setVoiceWakeEnabled(_ enabled: Bool) { func setVoiceWakeEnabled(_ enabled: Bool) {
self.voiceWake.setEnabled(enabled) self.voiceWake.setEnabled(enabled)
} }
func connectToBridge( func connectToBridge(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello) hello: BridgeHello)
{ {
self.bridgeTask?.cancel() self.bridgeTask?.cancel()
self.bridgeStatusText = "Connecting…" self.bridgeStatusText = "Connecting…"
self.bridgeServerName = nil self.bridgeServerName = nil
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint) self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
self.bridgeTask = Task { self.bridgeTask = Task {
do { do {
try await self.bridge.connect( try await self.bridge.connect(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
onConnected: { [weak self] serverName in onConnected: { [weak self] serverName in
guard let self else { return } guard let self else { return }
await MainActor.run { await MainActor.run {
self.bridgeStatusText = "Connected" self.bridgeStatusText = "Connected"
self.bridgeServerName = serverName self.bridgeServerName = serverName
} }
if let addr = await self.bridge.currentRemoteAddress() { if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run { await MainActor.run {
self.bridgeRemoteAddress = addr self.bridgeRemoteAddress = addr
} }
} }
}, },
onInvoke: { [weak self] req in onInvoke: { [weak self] req in
guard let self else { guard let self else {
@@ -110,20 +110,20 @@ final class NodeAppModel: ObservableObject {
self.connectedBridgeID = nil self.connectedBridgeID = nil
} }
func sendVoiceTranscript(text: String, sessionKey: String?) async throws { func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
struct Payload: Codable { struct Payload: Codable {
var text: String var text: String
var sessionKey: String? var sessionKey: String?
} }
let payload = Payload(text: text, sessionKey: sessionKey) let payload = Payload(text: text, sessionKey: sessionKey)
let data = try JSONEncoder().encode(payload) let data = try JSONEncoder().encode(payload)
guard let json = String(bytes: data, encoding: .utf8) else { guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ throw NSError(domain: "NodeAppModel", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
]) ])
} }
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json) try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
} }
func handleDeepLink(url: URL) async { func handleDeepLink(url: URL) async {
guard let route = DeepLinkParser.parse(url) else { return } guard let route = DeepLinkParser.parse(url) else { return }
@@ -163,16 +163,16 @@ final class NodeAppModel: ObservableObject {
]) ])
} }
// iOS bridge forwards to the gateway; no local auth prompts here. // iOS bridge forwards to the gateway; no local auth prompts here.
// (Key-based unattended auth is handled on macOS for clawdis:// links.) // (Key-based unattended auth is handled on macOS for clawdis:// links.)
let data = try JSONEncoder().encode(link) let data = try JSONEncoder().encode(link)
guard let json = String(bytes: data, encoding: .utf8) else { guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
]) ])
} }
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json) try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
} }
private func isBridgeConnected() async -> Bool { private func isBridgeConnected() async -> Bool {
if case .connected = await self.bridge.state { return true } if case .connected = await self.bridge.state { return true }
@@ -243,13 +243,13 @@ final class NodeAppModel: ObservableObject {
return try JSONDecoder().decode(type, from: data) return try JSONDecoder().decode(type, from: data)
} }
private static func encodePayload(_ obj: some Encodable) throws -> String { private static func encodePayload(_ obj: some Encodable) throws -> String {
let data = try JSONEncoder().encode(obj) let data = try JSONEncoder().encode(obj)
guard let json = String(bytes: data, encoding: .utf8) else { guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [ throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8", NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8",
]) ])
} }
return json return json
} }
} }

View File

@@ -105,19 +105,19 @@ final class ScreenController: ObservableObject {
#000; #000;
overflow: hidden; overflow: hidden;
} }
body::before { body::before {
content:""; content:"";
position: fixed; position: fixed;
inset: -20%; inset: -20%;
background: background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
transparent 1px, transparent 48px), transparent 1px, transparent 48px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
transparent 1px, transparent 48px); transparent 1px, transparent 48px);
transform: rotate(-7deg); transform: rotate(-7deg);
opacity: 0.55; opacity: 0.55;
pointer-events: none; pointer-events: none;
} }
canvas { canvas {
display:block; display:block;
width:100vw; width:100vw;

View File

@@ -1,5 +1,13 @@
import ClawdisKit
import SwiftUI import SwiftUI
@MainActor
private final class ConnectStatusStore: ObservableObject {
@Published var text: String?
}
extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View { struct SettingsTab: View {
@EnvironmentObject private var appModel: NodeAppModel @EnvironmentObject private var appModel: NodeAppModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -8,7 +16,7 @@ struct SettingsTab: View {
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = "" @AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@StateObject private var discovery = BridgeDiscoveryModel() @StateObject private var discovery = BridgeDiscoveryModel()
@State private var connectStatus: String? @StateObject private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String? @State private var connectingBridgeID: String?
@State private var didAutoConnect = false @State private var didAutoConnect = false
@@ -47,8 +55,8 @@ struct SettingsTab: View {
self.bridgeList(showing: .all) self.bridgeList(showing: .all)
} }
if let connectStatus { if let text = self.connectStatus.text {
Text(connectStatus) Text(text)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -77,22 +85,20 @@ struct SettingsTab: View {
guard let existing, !existing.isEmpty else { return } guard let existing, !existing.isEmpty else { return }
guard let target = self.pickAutoConnectBridge(from: newValue) else { return } guard let target = self.pickAutoConnectBridge(from: newValue) else { return }
self.didAutoConnect = true self.didAutoConnect = true
self.preferredBridgeStableID = target.stableID self.preferredBridgeStableID = target.stableID
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: target.endpoint, endpoint: target.endpoint,
hello: BridgeHello( hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
token: existing, token: existing,
platform: self.platformString(), platform: self.platformString(),
version: self.appVersion() version: self.appVersion()))
) self.connectStatus.text = nil
) }
self.connectStatus = nil
}
.onChange(of: self.appModel.bridgeServerName) { _, _ in .onChange(of: self.appModel.bridgeServerName) { _, _ in
self.connectStatus = nil self.connectStatus.text = nil
} }
} }
} }
@@ -173,22 +179,21 @@ struct SettingsTab: View {
existing : existing :
nil nil
let hello = BridgeHello( let hello = BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
token: existingToken, token: existingToken,
platform: self.platformString(), platform: self.platformString(),
version: self.appVersion() version: self.appVersion())
) let token = try await BridgeClient().pairAndHello(
let token = try await BridgeClient().pairAndHello( endpoint: bridge.endpoint,
endpoint: bridge.endpoint, hello: hello,
hello: hello, onStatus: { status in
onStatus: { status in let store = self.connectStatus
Task { @MainActor in Task { @MainActor in
self.connectStatus = status store.text = status
} }
} })
)
if !token.isEmpty, token != existingToken { if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString( _ = KeychainStore.saveString(
@@ -197,19 +202,17 @@ struct SettingsTab: View {
account: self.keychainAccount()) account: self.keychainAccount())
} }
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: bridge.endpoint, endpoint: bridge.endpoint,
hello: BridgeHello( hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
token: token, token: token,
platform: self.platformString(), platform: self.platformString(),
version: self.appVersion() version: self.appVersion()))
)
)
} catch { } catch {
self.connectStatus = "Failed: \(error.localizedDescription)" self.connectStatus.text = "Failed: \(error.localizedDescription)"
} }
} }

View File

@@ -87,22 +87,22 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return self.html("Forbidden", title: "Canvas: 403") return self.html("Forbidden", title: "Canvas: 403")
} }
do { do {
let data = try Data(contentsOf: standardizedFile) let data = try Data(contentsOf: standardizedFile)
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension) let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
let servedPath = standardizedFile.path let servedPath = standardizedFile.path
canvasLogger.debug( canvasLogger.debug(
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)") "served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
return CanvasResponse(mime: mime, data: data) return CanvasResponse(mime: mime, data: data)
} catch { } catch {
let failedPath = standardizedFile.path let failedPath = standardizedFile.path
let errorText = error.localizedDescription let errorText = error.localizedDescription
canvasLogger canvasLogger
.error( .error(
"failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)") "failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)")
return self.html("Failed to read file.", title: "Canvas error") return self.html("Failed to read file.", title: "Canvas error")
} }
} }
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
let fm = FileManager.default let fm = FileManager.default

View File

@@ -4,6 +4,11 @@ import SwiftUI
struct ConfigSettings: View { struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
private let labelColumnWidth: CGFloat = 120 private let labelColumnWidth: CGFloat = 120
private static let browserAttachOnlyHelp =
"When enabled, the browser server will only connect if the clawd browser is already running."
private static let browserProfileNote =
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it wont interfere with your daily browser."
@State private var configModel: String = "" @State private var configModel: String = ""
@State private var customModel: String = "" @State private var customModel: String = ""
@State private var configSaving = false @State private var configSaving = false
@@ -203,16 +208,12 @@ struct ConfigSettings: View {
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.disabled(!self.browserEnabled) .disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() } .onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help( .help(Self.browserAttachOnlyHelp)
"When enabled, the browser server will only connect if the clawd browser is already running."
)
} }
GridRow { GridRow {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(Self.browserProfileNote)
"Clawd uses a separate Chrome profile and ports (default 18791/18792) so it wont interfere with your daily browser."
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -144,15 +144,15 @@ final class ControlChannel: ObservableObject {
} }
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it. // If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
if let urlErr = error as? URLError, if let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{ {
let reason = urlErr.failureURLString ?? urlErr.localizedDescription let reason = urlErr.failureURLString ?? urlErr.localizedDescription
return return
"Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment " + "Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment " +
"or clear it on the gateway. " + "or clear it on the gateway. " +
"Reason: \(reason)" "Reason: \(reason)"
} }
// Common misfire: we connected to localhost:18789 but the port is occupied // Common misfire: we connected to localhost:18789 but the port is occupied
// by some other process (e.g. a local dev gateway or a stuck SSH forward). // by some other process (e.g. a local dev gateway or a stuck SSH forward).

View File

@@ -234,12 +234,12 @@ final actor ControlSocketServer {
#if DEBUG #if DEBUG
// Debug-only escape hatch: allow unsigned/same-UID clients when explicitly opted in. // Debug-only escape hatch: allow unsigned/same-UID clients when explicitly opted in.
// This keeps local development workable (e.g. a SwiftPM-built `clawdis-mac` binary). // This keeps local development workable (e.g. a SwiftPM-built `clawdis-mac` binary).
let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"] let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"]
if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() { if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() {
self.logger.warning( self.logger.warning(
"allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)") "allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)")
return true return true
} }
#endif #endif
if let callerUID = self.uid(for: pid) { if let callerUID = self.uid(for: pid) {

View File

@@ -69,13 +69,12 @@ struct CronSettings: View {
.font(.headline) .font(.headline)
Spacer() Spacer()
} }
Text( Text(
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " + "Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
"and the Gateway restarts." "and the Gateway restarts.")
) .font(.footnote)
.font(.footnote) .foregroundStyle(.secondary)
.foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true)
.fixedSize(horizontal: false, vertical: true)
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty { if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
Text(storePath) Text(storePath)
.font(.caption.monospaced()) .font(.caption.monospaced())
@@ -497,6 +496,21 @@ private struct CronJobEditor: View {
let onSave: ([String: Any]) -> Void let onSave: ([String: Any]) -> Void
private let labelColumnWidth: CGFloat = 160 private let labelColumnWidth: CGFloat = 160
private static let introText =
"Create a schedule that wakes clawd via the Gateway. "
+ "Use an isolated session for agent turns so your main chat stays clean."
private static let sessionTargetNote =
"Main jobs post a system event into the current main session. "
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/etc)."
private static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
private static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a surface, "
+ "and a short summary is posted back to your main chat."
private static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
private static let mainSummaryNote =
"Controls the label used when posting the completion summary back to the main session."
@State private var name: String = "" @State private var name: String = ""
@State private var enabled: Bool = true @State private var enabled: Bool = true
@@ -527,9 +541,7 @@ private struct CronJobEditor: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(self.job == nil ? "New cron job" : "Edit cron job") Text(self.job == nil ? "New cron job" : "Edit cron job")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text( Text(Self.introText)
"Create a schedule that wakes clawd via the Gateway. Use an isolated session for agent turns so your main chat stays clean."
)
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -575,8 +587,7 @@ private struct CronJobEditor: View {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(
"Main jobs post a system event into the current main session. Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/etc)." Self.sessionTargetNote)
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -601,8 +612,7 @@ private struct CronJobEditor: View {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." Self.scheduleKindNote)
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -646,9 +656,7 @@ private struct CronJobEditor: View {
GroupBox("Payload") { GroupBox("Payload") {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
if self.sessionTarget == .isolated { if self.sessionTarget == .isolated {
Text( Text(Self.isolatedPayloadNote)
"Isolated jobs always run an agent turn. The result can be delivered to a surface, and a short summary is posted back to your main chat."
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -669,8 +677,7 @@ private struct CronJobEditor: View {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(
"System events are injected into the current main session. Agent turns require an isolated session target." Self.mainPayloadNote)
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -703,8 +710,7 @@ private struct CronJobEditor: View {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(
"Controls the label used when posting the completion summary back to the main session." Self.mainSummaryNote)
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -914,14 +920,14 @@ private struct CronJobEditor: View {
}() }()
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
throw NSError( throw NSError(
domain: "Cron", domain: "Cron",
code: 0, code: 0,
userInfo: [ userInfo: [
NSLocalizedDescriptionKey: NSLocalizedDescriptionKey:
"Main session jobs require systemEvent payloads (switch Session target to isolated).", "Main session jobs require systemEvent payloads (switch Session target to isolated).",
]) ])
} }
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
throw NSError( throw NSError(

View File

@@ -141,17 +141,16 @@ struct DebugSettings: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
GridRow { GridRow {
self.gridLabel("Attach only") self.gridLabel("Attach only")
Toggle("", isOn: self.$attachExistingGatewayOnly) Toggle("", isOn: self.$attachExistingGatewayOnly)
.labelsHidden() .labelsHidden()
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.help( .help(
"When enabled in local mode, the mac app will only connect " + "When enabled in local mode, the mac app will only connect " +
"to an already-running gateway " + "to an already-running gateway " +
"and will not start one itself." "and will not start one itself.")
) }
}
GridRow { GridRow {
self.gridLabel("Deep links") self.gridLabel("Deep links")
Toggle("", isOn: self.$deepLinkAgentEnabled) Toggle("", isOn: self.$deepLinkAgentEnabled)
@@ -232,17 +231,16 @@ struct DebugSettings: View {
GridRow { GridRow {
self.gridLabel("Diagnostics") self.gridLabel("Diagnostics")
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled) Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.help( .help(
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " + "Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " +
"Enable only while actively debugging." "Enable only while actively debugging.")
) HStack(spacing: 8) {
HStack(spacing: 8) { Button("Open folder") {
Button("Open folder") { NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL()) }
}
.buttonStyle(.bordered) .buttonStyle(.bordered)
Button("Clear") { Button("Clear") {
Task { try? await DiagnosticsFileLog.shared.clear() } Task { try? await DiagnosticsFileLog.shared.clear() }
@@ -485,13 +483,12 @@ struct DebugSettings: View {
private var canvasSection: some View { private var canvasSection: some View {
GroupBox("Canvas") { GroupBox("Canvas") {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled) Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.help( .help(
"When off, agent Canvas requests return “Canvas disabled by user”. " + "When off, agent Canvas requests return “Canvas disabled by user”. " +
"Manual debug actions still work." "Manual debug actions still work.")
)
HStack(spacing: 8) { HStack(spacing: 8) {
TextField("Session", text: self.$canvasSessionKey) TextField("Session", text: self.$canvasSessionKey)
@@ -587,18 +584,17 @@ struct DebugSettings: View {
.labelsHidden() .labelsHidden()
.frame(maxWidth: 280, alignment: .leading) .frame(maxWidth: 280, alignment: .leading)
} }
GridRow { GridRow {
self.gridLabel("Web chat") self.gridLabel("Web chat")
Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled) Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled)
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.help( .help(
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the " + "When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the " +
"bundled WKWebView." "bundled WKWebView.")
) }
} }
} }
} }
}
@MainActor @MainActor
private func runPortCheck() async { private func runPortCheck() async {
@@ -752,12 +748,12 @@ struct DebugSettings: View {
} }
} }
private func configURL() -> URL { private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis") .appendingPathComponent(".clawdis")
.appendingPathComponent("clawdis.json") .appendingPathComponent("clawdis.json")
} }
} }
extension DebugSettings { extension DebugSettings {
// MARK: - Canvas debug actions // MARK: - Canvas debug actions
@@ -854,8 +850,7 @@ extension DebugSettings {
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let result = try await CanvasManager.shared.eval( let result = try await CanvasManager.shared.eval(
sessionKey: session.isEmpty ? "main" : session, sessionKey: session.isEmpty ? "main" : session,
javaScript: self.canvasEvalJS javaScript: self.canvasEvalJS)
)
self.canvasEvalResult = result self.canvasEvalResult = result
} catch { } catch {
self.canvasError = error.localizedDescription self.canvasError = error.localizedDescription
@@ -870,8 +865,7 @@ extension DebugSettings {
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let path = try await CanvasManager.shared.snapshot( let path = try await CanvasManager.shared.snapshot(
sessionKey: session.isEmpty ? "main" : session, sessionKey: session.isEmpty ? "main" : session,
outPath: nil outPath: nil)
)
self.canvasSnapshotPath = path self.canvasSnapshotPath = path
} catch { } catch {
self.canvasError = error.localizedDescription self.canvasError = error.localizedDescription
@@ -879,22 +873,22 @@ extension DebugSettings {
} }
} }
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle { private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
configuration.label configuration.label
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
configuration.content configuration.content
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
#if DEBUG #if DEBUG
struct DebugSettings_Previews: PreviewProvider { struct DebugSettings_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
DebugSettings() DebugSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
} }
} }

View File

@@ -125,18 +125,18 @@ actor GatewayEndpointStore {
for (_, continuation) in self.subscribers { for (_, continuation) in self.subscribers {
continuation.yield(next) continuation.yield(next)
} }
switch next { switch next {
case let .ready(mode, url, _): case let .ready(mode, url, _):
let modeDesc = String(describing: mode) let modeDesc = String(describing: mode)
let urlDesc = url.absoluteString let urlDesc = url.absoluteString
self.logger self.logger
.debug( .debug(
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)") "resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
case let .unavailable(mode, reason): case let .unavailable(mode, reason):
let modeDesc = String(describing: mode) let modeDesc = String(describing: mode)
self.logger self.logger
.debug( .debug(
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)") "endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
} }
} }
} }

View File

@@ -1,15 +1,15 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
struct GeneralSettings: View { struct GeneralSettings: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
@ObservedObject private var healthStore = HealthStore.shared @ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared @ObservedObject private var gatewayManager = GatewayProcessManager.shared
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery = MasterDiscoveryModel() @StateObject private var masterDiscovery = MasterDiscoveryModel()
@State private var isInstallingCLI = false @State private var isInstallingCLI = false
@State private var cliStatus: String? @State private var cliStatus: String?
@State private var cliInstalled = false @State private var cliInstalled = false
@State private var cliInstallLocation: String? @State private var cliInstallLocation: String?
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking @State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstallMessage: String? @State private var gatewayInstallMessage: String?
@@ -577,12 +577,12 @@ extension GeneralSettings {
alert.runModal() alert.runModal()
} }
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) { private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost let host = master.tailnetDns ?? master.lanHost
guard let host else { return } guard let host else { return }
let user = NSUserName() let user = NSUserName()
var target = "\(user)@\(host)" var target = "\(user)@\(host)"
if master.sshPort != 22 { if master.sshPort != 22 {
target += ":\(master.sshPort)" target += ":\(master.sshPort)"
} }

View File

@@ -45,16 +45,16 @@ struct OnboardingView: View {
@State private var cliStatus: String? @State private var cliStatus: String?
@State private var copied = false @State private var copied = false
@State private var monitoringPermissions = false @State private var monitoringPermissions = false
@State private var monitoringDiscovery = false @State private var monitoringDiscovery = false
@State private var cliInstalled = false @State private var cliInstalled = false
@State private var cliInstallLocation: String? @State private var cliInstallLocation: String?
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking @State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstalling = false @State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String? @State private var gatewayInstallMessage: String?
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery = MasterDiscoveryModel() @StateObject private var masterDiscovery = MasterDiscoveryModel()
@ObservedObject private var state = AppStateStore.shared @ObservedObject private var state = AppStateStore.shared
@ObservedObject private var permissionMonitor = PermissionMonitor.shared @ObservedObject private var permissionMonitor = PermissionMonitor.shared
private let pageWidth: CGFloat = 680 private let pageWidth: CGFloat = 680
private let contentHeight: CGFloat = 520 private let contentHeight: CGFloat = 520
@@ -116,17 +116,16 @@ struct OnboardingView: View {
} }
private func welcomePage() -> some View { private func welcomePage() -> some View {
self.onboardingPage { self.onboardingPage {
Text("Welcome to Clawdis") Text("Welcome to Clawdis")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text( Text(
"Your macOS menu bar companion for notifications, screenshots, and agent automation — " + "Your macOS menu bar companion for notifications, screenshots, and agent automation — " +
"setup takes just a few minutes." "setup takes just a few minutes.")
) .font(.body)
.font(.body) .foregroundStyle(.secondary)
.foregroundStyle(.secondary) .multilineTextAlignment(.center)
.multilineTextAlignment(.center) .lineLimit(2)
.lineLimit(2)
.frame(maxWidth: 560) .frame(maxWidth: 560)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -141,16 +140,16 @@ struct OnboardingView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Security notice") Text("Security notice")
.font(.headline) .font(.headline)
Text( Text(
""" """
The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac,
including running including running
commands, reading/writing files, and capturing screenshots — depending on the commands, reading/writing files, and capturing screenshots — depending on the
permissions you grant. permissions you grant.
Only enable Clawdis if you understand the risks and trust the prompts Only enable Clawdis if you understand the risks and trust the prompts
and integrations you use. and integrations you use.
""") """)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -162,17 +161,16 @@ struct OnboardingView: View {
} }
private func connectionPage() -> some View { private func connectionPage() -> some View {
self.onboardingPage { self.onboardingPage {
Text("Where Clawdis runs") Text("Where Clawdis runs")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text( Text(
"Clawdis has one primary Gateway (“master”) that runs continuously. " + "Clawdis has one primary Gateway (“master”) that runs continuously. " +
"Connect locally or over SSH/Tailscale so the agent can work on any Mac." "Connect locally or over SSH/Tailscale so the agent can work on any Mac.")
) .font(.body)
.font(.body) .foregroundStyle(.secondary)
.foregroundStyle(.secondary) .multilineTextAlignment(.center)
.multilineTextAlignment(.center) .lineLimit(2)
.lineLimit(2)
.frame(maxWidth: 520) .frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -300,26 +298,25 @@ struct OnboardingView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
} else { } else {
Text( Text(
"Uses \"npm install -g clawdis@<version>\" on your PATH. " + "Uses \"npm install -g clawdis@<version>\" on your PATH. " +
"We keep the gateway on port 18789." "We keep the gateway on port 18789.")
) .font(.caption)
.font(.caption) .foregroundStyle(.secondary)
.foregroundStyle(.secondary) .lineLimit(2)
.lineLimit(2)
} }
} }
} }
} }
} }
// swiftlint:disable:next inclusive_language // swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) { private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost let host = master.tailnetDns ?? master.lanHost
guard let host else { return } guard let host else { return }
let user = NSUserName() let user = NSUserName()
var target = "\(user)@\(host)" var target = "\(user)@\(host)"
if master.sshPort != 22 { if master.sshPort != 22 {
target += ":\(master.sshPort)" target += ":\(master.sshPort)"
} }
@@ -460,13 +457,13 @@ struct OnboardingView: View {
Text("Telegram") Text("Telegram")
.font(.headline) .font(.headline)
self.featureRow( self.featureRow(
title: "Set `TELEGRAM_BOT_TOKEN`", title: "Set `TELEGRAM_BOT_TOKEN`",
subtitle: """ subtitle: """
Create a bot with @BotFather and set the token as an env var Create a bot with @BotFather and set the token as an env var
(or `telegram.botToken` in `~/.clawdis/clawdis.json`). (or `telegram.botToken` in `~/.clawdis/clawdis.json`).
""", """,
systemImage: "key") systemImage: "key")
self.featureRow( self.featureRow(
title: "Verify with `clawdis status --deep`", title: "Verify with `clawdis status --deep`",
subtitle: "This probes both WhatsApp and the Telegram API and prints whats configured.", subtitle: "This probes both WhatsApp and the Telegram API and prints whats configured.",
@@ -491,11 +488,11 @@ struct OnboardingView: View {
title: "Try Voice Wake", title: "Try Voice Wake",
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
systemImage: "waveform.circle") systemImage: "waveform.circle")
self.featureRow( self.featureRow(
title: "Use the panel + Canvas", title: "Use the panel + Canvas",
subtitle: "Open the menu bar panel for quick chat; the agent can show previews " + subtitle: "Open the menu bar panel for quick chat; the agent can show previews " +
"and richer visuals in Canvas.", "and richer visuals in Canvas.",
systemImage: "rectangle.inset.filled.and.person.filled") systemImage: "rectangle.inset.filled.and.person.filled")
self.featureRow( self.featureRow(
title: "Test a notification", title: "Test a notification",
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.", subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",

View File

@@ -9,109 +9,110 @@ import Speech
import UserNotifications import UserNotifications
enum PermissionManager { enum PermissionManager {
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
var results: [Capability: Bool] = [:] var results: [Capability: Bool] = [:]
for cap in caps { for cap in caps {
results[cap] = await self.ensureCapability(cap, interactive: interactive) results[cap] = await self.ensureCapability(cap, interactive: interactive)
} }
return results return results
} }
private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool { private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool {
switch cap { switch cap {
case .notifications: case .notifications:
return await self.ensureNotifications(interactive: interactive) await self.ensureNotifications(interactive: interactive)
case .appleScript: case .appleScript:
return await self.ensureAppleScript(interactive: interactive) await self.ensureAppleScript(interactive: interactive)
case .accessibility: case .accessibility:
return await self.ensureAccessibility(interactive: interactive) await self.ensureAccessibility(interactive: interactive)
case .screenRecording: case .screenRecording:
return await self.ensureScreenRecording(interactive: interactive) await self.ensureScreenRecording(interactive: interactive)
case .microphone: case .microphone:
return await self.ensureMicrophone(interactive: interactive) await self.ensureMicrophone(interactive: interactive)
case .speechRecognition: case .speechRecognition:
return await self.ensureSpeechRecognition(interactive: interactive) await self.ensureSpeechRecognition(interactive: interactive)
} }
} }
private static func ensureNotifications(interactive: Bool) async -> Bool { private static func ensureNotifications(interactive: Bool) async -> Bool {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings() let settings = await center.notificationSettings()
switch settings.authorizationStatus { switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral: case .authorized, .provisional, .ephemeral:
return true return true
case .notDetermined: case .notDetermined:
guard interactive else { return false } guard interactive else { return false }
let granted = await (try? 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()
return granted && (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional) return granted &&
case .denied: (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional)
if interactive { case .denied:
NotificationPermissionHelper.openSettings() if interactive {
} NotificationPermissionHelper.openSettings()
return false }
@unknown default: return false
return false @unknown default:
} return false
} }
}
private static func ensureAppleScript(interactive: Bool) async -> Bool { private static func ensureAppleScript(interactive: Bool) async -> Bool {
let granted = await MainActor.run { AppleScriptPermission.isAuthorized() } let granted = await MainActor.run { AppleScriptPermission.isAuthorized() }
if interactive, !granted { if interactive, !granted {
await AppleScriptPermission.requestAuthorization() await AppleScriptPermission.requestAuthorization()
} }
return await MainActor.run { AppleScriptPermission.isAuthorized() } return await MainActor.run { AppleScriptPermission.isAuthorized() }
} }
private static func ensureAccessibility(interactive: Bool) async -> Bool { private static func ensureAccessibility(interactive: Bool) async -> Bool {
let trusted = await MainActor.run { AXIsProcessTrusted() } let trusted = await MainActor.run { AXIsProcessTrusted() }
if interactive, !trusted { if interactive, !trusted {
await MainActor.run { await MainActor.run {
let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
_ = AXIsProcessTrustedWithOptions(opts) _ = AXIsProcessTrustedWithOptions(opts)
} }
} }
return await MainActor.run { AXIsProcessTrusted() } return await MainActor.run { AXIsProcessTrusted() }
} }
private static func ensureScreenRecording(interactive: Bool) async -> Bool { private static func ensureScreenRecording(interactive: Bool) async -> Bool {
let granted = ScreenRecordingProbe.isAuthorized() let granted = ScreenRecordingProbe.isAuthorized()
if interactive, !granted { if interactive, !granted {
await ScreenRecordingProbe.requestAuthorization() await ScreenRecordingProbe.requestAuthorization()
} }
return ScreenRecordingProbe.isAuthorized() return ScreenRecordingProbe.isAuthorized()
} }
private static func ensureMicrophone(interactive: Bool) async -> Bool { private static func ensureMicrophone(interactive: Bool) async -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: .audio) let status = AVCaptureDevice.authorizationStatus(for: .audio)
switch status { switch status {
case .authorized: case .authorized:
return true return true
case .notDetermined: case .notDetermined:
guard interactive else { return false } guard interactive else { return false }
return await AVCaptureDevice.requestAccess(for: .audio) return await AVCaptureDevice.requestAccess(for: .audio)
case .denied, .restricted: case .denied, .restricted:
if interactive { if interactive {
MicrophonePermissionHelper.openSettings() MicrophonePermissionHelper.openSettings()
} }
return false return false
@unknown default: @unknown default:
return false return false
} }
} }
private static func ensureSpeechRecognition(interactive: Bool) async -> Bool { private static func ensureSpeechRecognition(interactive: Bool) async -> Bool {
let status = SFSpeechRecognizer.authorizationStatus() let status = SFSpeechRecognizer.authorizationStatus()
if status == .notDetermined, interactive { if status == .notDetermined, interactive {
await withUnsafeContinuation { (cont: UnsafeContinuation<Void, Never>) in await withUnsafeContinuation { (cont: UnsafeContinuation<Void, Never>) in
SFSpeechRecognizer.requestAuthorization { _ in SFSpeechRecognizer.requestAuthorization { _ in
DispatchQueue.main.async { cont.resume() } DispatchQueue.main.async { cont.resume() }
} }
} }
} }
return SFSpeechRecognizer.authorizationStatus() == .authorized return SFSpeechRecognizer.authorizationStatus() == .authorized
} }
static func voiceWakePermissionsGranted() -> Bool { static func voiceWakePermissionsGranted() -> Bool {
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized

View File

@@ -451,8 +451,7 @@ struct WebChatView: View {
Text( Text(
self.viewModel.healthOK self.viewModel.healthOK
? "This is the native SwiftUI debug chat." ? "This is the native SwiftUI debug chat."
: "Connecting to the gateway…" : "Connecting to the gateway…")
)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }

View File

@@ -105,8 +105,8 @@ enum BrowserCLI {
sub: String, sub: String,
options: RunOptions, options: RunOptions,
baseURL: URL, baseURL: URL,
jsonOutput: Bool jsonOutput: Bool) async throws -> Int32
) async throws -> Int32 { {
switch sub { switch sub {
case "status": case "status":
return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput) return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput)
@@ -172,8 +172,7 @@ enum BrowserCLI {
method: "POST", method: "POST",
url: url, url: url,
body: ["url": urlString], body: ["url": urlString],
timeoutInterval: 15.0 timeoutInterval: 15.0)
)
self.printResult(jsonOutput: jsonOutput, res: res) self.printResult(jsonOutput: jsonOutput, res: res)
return 0 return 0
} }
@@ -188,8 +187,7 @@ enum BrowserCLI {
method: "POST", method: "POST",
url: url, url: url,
body: ["targetId": id], body: ["targetId": id],
timeoutInterval: 5.0 timeoutInterval: 5.0)
)
self.printResult(jsonOutput: jsonOutput, res: res) self.printResult(jsonOutput: jsonOutput, res: res)
return 0 return 0
} }
@@ -250,8 +248,7 @@ enum BrowserCLI {
"targetId": options.targetId ?? "", "targetId": options.targetId ?? "",
"await": options.awaitPromise, "await": options.awaitPromise,
], ],
timeoutInterval: 15.0 timeoutInterval: 15.0)
)
if jsonOutput { if jsonOutput {
self.printJSON(ok: true, result: res) self.printJSON(ok: true, result: res)

View File

@@ -58,276 +58,269 @@ struct ClawdisCLI {
enum Kind { enum Kind {
case generic case generic
} }
} }
private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest { private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest {
var args = args var args = args
guard !args.isEmpty else { throw CLIError.help } guard !args.isEmpty else { throw CLIError.help }
let command = args.removeFirst() let command = args.removeFirst()
switch command { switch command {
case "--help", "-h", "help": case "--help", "-h", "help":
throw CLIError.help throw CLIError.help
case "--version", "-V", "version": case "--version", "-V", "version":
throw CLIError.version throw CLIError.version
case "notify": case "notify":
return try self.parseNotify(args: &args) return try self.parseNotify(args: &args)
case "ensure-permissions": case "ensure-permissions":
return self.parseEnsurePermissions(args: &args) return self.parseEnsurePermissions(args: &args)
case "run": case "run":
return self.parseRunShell(args: &args) return self.parseRunShell(args: &args)
case "status": case "status":
return ParsedCLIRequest(request: .status, kind: .generic) return ParsedCLIRequest(request: .status, kind: .generic)
case "rpc-status": case "rpc-status":
return ParsedCLIRequest(request: .rpcStatus, kind: .generic) return ParsedCLIRequest(request: .rpcStatus, kind: .generic)
case "agent": case "agent":
return try self.parseAgent(args: &args) return try self.parseAgent(args: &args)
case "node": case "node":
return try self.parseNode(args: &args) return try self.parseNode(args: &args)
case "canvas": case "canvas":
return try self.parseCanvas(args: &args) return try self.parseCanvas(args: &args)
default: default:
throw CLIError.help throw CLIError.help
} }
} }
private static func parseNotify(args: inout [String]) throws -> ParsedCLIRequest { private static func parseNotify(args: inout [String]) throws -> ParsedCLIRequest {
var title: String? var title: String?
var body: String? var body: String?
var sound: String? var sound: String?
var priority: NotificationPriority? var priority: NotificationPriority?
var delivery: NotificationDelivery? var delivery: NotificationDelivery?
while !args.isEmpty { while !args.isEmpty {
let arg = args.removeFirst() let arg = args.removeFirst()
switch arg { switch arg {
case "--title": title = args.popFirst() case "--title": title = args.popFirst()
case "--body": body = args.popFirst() case "--body": body = args.popFirst()
case "--sound": sound = args.popFirst() case "--sound": sound = args.popFirst()
case "--priority": case "--priority":
if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p } if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p }
case "--delivery": case "--delivery":
if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d } if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d }
default: break default: break
} }
} }
guard let t = title, let b = body else { throw CLIError.help } guard let t = title, let b = body else { throw CLIError.help }
return ParsedCLIRequest( return ParsedCLIRequest(
request: .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery), request: .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery),
kind: .generic kind: .generic)
) }
}
private static func parseEnsurePermissions(args: inout [String]) -> ParsedCLIRequest { private static func parseEnsurePermissions(args: inout [String]) -> ParsedCLIRequest {
var caps: [Capability] = [] var caps: [Capability] = []
var interactive = false var interactive = false
while !args.isEmpty { while !args.isEmpty {
let arg = args.removeFirst() let arg = args.removeFirst()
switch arg { switch arg {
case "--cap": case "--cap":
if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) } if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) }
case "--interactive": case "--interactive":
interactive = true interactive = true
default: default:
break break
} }
} }
if caps.isEmpty { caps = Capability.allCases } if caps.isEmpty { caps = Capability.allCases }
return ParsedCLIRequest(request: .ensurePermissions(caps, interactive: interactive), kind: .generic) return ParsedCLIRequest(request: .ensurePermissions(caps, interactive: interactive), kind: .generic)
} }
private static func parseRunShell(args: inout [String]) -> ParsedCLIRequest { private static func parseRunShell(args: inout [String]) -> ParsedCLIRequest {
var cwd: String? var cwd: String?
var env: [String: String] = [:] var env: [String: String] = [:]
var timeout: Double? var timeout: Double?
var needsSR = false var needsSR = false
var cmd: [String] = [] var cmd: [String] = []
while !args.isEmpty { while !args.isEmpty {
let arg = args.removeFirst() let arg = args.removeFirst()
switch arg { switch arg {
case "--cwd": case "--cwd":
cwd = args.popFirst() cwd = args.popFirst()
case "--env": case "--env":
if let pair = args.popFirst() { if let pair = args.popFirst() {
self.parseEnvPair(pair, into: &env) self.parseEnvPair(pair, into: &env)
} }
case "--timeout": case "--timeout":
if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl } if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl }
case "--needs-screen-recording": case "--needs-screen-recording":
needsSR = true needsSR = true
default: default:
cmd.append(arg) cmd.append(arg)
} }
} }
return ParsedCLIRequest( return ParsedCLIRequest(
request: .runShell( request: .runShell(
command: cmd, command: cmd,
cwd: cwd, cwd: cwd,
env: env.isEmpty ? nil : env, env: env.isEmpty ? nil : env,
timeoutSec: timeout, timeoutSec: timeout,
needsScreenRecording: needsSR needsScreenRecording: needsSR),
), kind: .generic)
kind: .generic }
)
}
private static func parseEnvPair(_ pair: String, into env: inout [String: String]) { private static func parseEnvPair(_ pair: String, into env: inout [String: String]) {
guard let eq = pair.firstIndex(of: "=") else { return } guard let eq = pair.firstIndex(of: "=") else { return }
let key = String(pair[..<eq]) let key = String(pair[..<eq])
let value = String(pair[pair.index(after: eq)...]) let value = String(pair[pair.index(after: eq)...])
env[key] = value env[key] = value
} }
private static func parseAgent(args: inout [String]) throws -> ParsedCLIRequest { private static func parseAgent(args: inout [String]) throws -> ParsedCLIRequest {
var message: String? var message: String?
var thinking: String? var thinking: String?
var session: String? var session: String?
var deliver = false var deliver = false
var to: String? var to: String?
while !args.isEmpty { while !args.isEmpty {
let arg = args.removeFirst() let arg = args.removeFirst()
switch arg { switch arg {
case "--message": message = args.popFirst() case "--message": message = args.popFirst()
case "--thinking": thinking = args.popFirst() case "--thinking": thinking = args.popFirst()
case "--session": session = args.popFirst() case "--session": session = args.popFirst()
case "--deliver": deliver = true case "--deliver": deliver = true
case "--to": to = args.popFirst() case "--to": to = args.popFirst()
default: default:
if message == nil { if message == nil {
message = arg message = arg
} }
} }
} }
guard let message else { throw CLIError.help } guard let message else { throw CLIError.help }
return ParsedCLIRequest( return ParsedCLIRequest(
request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to), request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to),
kind: .generic kind: .generic)
) }
}
private static func parseNode(args: inout [String]) throws -> ParsedCLIRequest { private static func parseNode(args: inout [String]) throws -> ParsedCLIRequest {
guard let sub = args.popFirst() else { throw CLIError.help } guard let sub = args.popFirst() else { throw CLIError.help }
switch sub { switch sub {
case "list": case "list":
return ParsedCLIRequest(request: .nodeList, kind: .generic) return ParsedCLIRequest(request: .nodeList, kind: .generic)
case "invoke": case "invoke":
var nodeId: String? var nodeId: String?
var command: String? var command: String?
var paramsJSON: String? var paramsJSON: String?
while !args.isEmpty { while !args.isEmpty {
let arg = args.removeFirst() let arg = args.removeFirst()
switch arg { switch arg {
case "--node": nodeId = args.popFirst() case "--node": nodeId = args.popFirst()
case "--command": command = args.popFirst() case "--command": command = args.popFirst()
case "--params-json": paramsJSON = args.popFirst() case "--params-json": paramsJSON = args.popFirst()
default: break default: break
} }
} }
guard let nodeId, let command else { throw CLIError.help } guard let nodeId, let command else { throw CLIError.help }
return ParsedCLIRequest( return ParsedCLIRequest(
request: .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON), request: .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON),
kind: .generic kind: .generic)
) default:
default: throw CLIError.help
throw CLIError.help }
} }
}
private static func parseCanvas(args: inout [String]) throws -> ParsedCLIRequest { private static func parseCanvas(args: inout [String]) throws -> ParsedCLIRequest {
guard let sub = args.popFirst() else { throw CLIError.help } guard let sub = args.popFirst() else { throw CLIError.help }
switch sub { switch sub {
case "show": case "show":
var session = "main" var session = "main"
var path: String? var path: String?
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path) let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
return ParsedCLIRequest( return ParsedCLIRequest(
request: .canvasShow(session: session, path: path, placement: placement), request: .canvasShow(session: session, path: path, placement: placement),
kind: .generic kind: .generic)
) case "hide":
case "hide": var session = "main"
var session = "main" while !args.isEmpty {
while !args.isEmpty { let arg = args.removeFirst()
let arg = args.removeFirst() switch arg {
switch arg { case "--session": session = args.popFirst() ?? session
case "--session": session = args.popFirst() ?? session default: break
default: break }
} }
} return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic)
return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic) case "goto":
case "goto": var session = "main"
var session = "main" var path: String?
var path: String? let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path) guard let path else { throw CLIError.help }
guard let path else { throw CLIError.help } return ParsedCLIRequest(
return ParsedCLIRequest( request: .canvasGoto(session: session, path: path, placement: placement),
request: .canvasGoto(session: session, path: path, placement: placement), kind: .generic)
kind: .generic case "eval":
) var session = "main"
case "eval": var js: String?
var session = "main" while !args.isEmpty {
var js: String? let arg = args.removeFirst()
while !args.isEmpty { switch arg {
let arg = args.removeFirst() case "--session": session = args.popFirst() ?? session
switch arg { case "--js": js = args.popFirst()
case "--session": session = args.popFirst() ?? session default: break
case "--js": js = args.popFirst() }
default: break }
} guard let js else { throw CLIError.help }
} return ParsedCLIRequest(request: .canvasEval(session: session, javaScript: js), kind: .generic)
guard let js else { throw CLIError.help } case "snapshot":
return ParsedCLIRequest(request: .canvasEval(session: session, javaScript: js), kind: .generic) var session = "main"
case "snapshot": var outPath: String?
var session = "main" while !args.isEmpty {
var outPath: String? let arg = args.removeFirst()
while !args.isEmpty { switch arg {
let arg = args.removeFirst() case "--session": session = args.popFirst() ?? session
switch arg { case "--out": outPath = args.popFirst()
case "--session": session = args.popFirst() ?? session default: break
case "--out": outPath = args.popFirst() }
default: break }
} return ParsedCLIRequest(request: .canvasSnapshot(session: session, outPath: outPath), kind: .generic)
} default:
return ParsedCLIRequest(request: .canvasSnapshot(session: session, outPath: outPath), kind: .generic) throw CLIError.help
default: }
throw CLIError.help }
}
}
private static func parseCanvasPlacement( private static func parseCanvasPlacement(
args: inout [String], args: inout [String],
session: inout String, session: inout String,
path: inout String? path: inout String?) -> CanvasPlacement?
) -> CanvasPlacement? { {
var x: Double? var x: Double?
var y: Double? var y: Double?
var width: Double? var width: Double?
var height: Double? var height: Double?
while !args.isEmpty { while !args.isEmpty {
let arg = args.removeFirst() let arg = args.removeFirst()
switch arg { switch arg {
case "--session": session = args.popFirst() ?? session case "--session": session = args.popFirst() ?? session
case "--path": path = args.popFirst() case "--path": path = args.popFirst()
case "--x": x = args.popFirst().flatMap(Double.init) case "--x": x = args.popFirst().flatMap(Double.init)
case "--y": y = args.popFirst().flatMap(Double.init) case "--y": y = args.popFirst().flatMap(Double.init)
case "--width": width = args.popFirst().flatMap(Double.init) case "--width": width = args.popFirst().flatMap(Double.init)
case "--height": height = args.popFirst().flatMap(Double.init) case "--height": height = args.popFirst().flatMap(Double.init)
default: break default: break
} }
} }
if x == nil, y == nil, width == nil, height == nil { return nil } if x == nil, y == nil, width == nil, height == nil { return nil }
return CanvasPlacement(x: x, y: y, width: width, height: height) return CanvasPlacement(x: x, y: y, width: width, height: height)
} }
private static func printText(parsed: ParsedCLIRequest, response: Response) throws { private static func printText(parsed: ParsedCLIRequest, response: Response) throws {
guard response.ok else { guard response.ok else {
@@ -506,13 +499,13 @@ struct ClawdisCLI {
_NSGetExecutablePath(ptr.baseAddress, &size) _NSGetExecutablePath(ptr.baseAddress, &size)
} }
guard result2 == 0 else { return nil } guard result2 == 0 else { return nil }
} }
let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count
let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) } let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) }
guard let path = String(bytes: bytes, encoding: .utf8) else { return nil } guard let path = String(bytes: bytes, encoding: .utf8) else { return nil }
return URL(fileURLWithPath: path).resolvingSymlinksInPath() return URL(fileURLWithPath: path).resolvingSymlinksInPath()
} }
private static func loadPackageJSONVersion() -> String? { private static func loadPackageJSONVersion() -> String? {
guard let exeURL = self.resolveExecutableURL() else { return nil } guard let exeURL = self.resolveExecutableURL() else { return nil }

View File

@@ -323,17 +323,17 @@ enum UICLI {
"screenshotPath": screenshotPath, "screenshotPath": screenshotPath,
"result": self.toJSONObject(detection), "result": self.toJSONObject(detection),
]) ])
} else { } else {
FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8)) FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8))
for el in detection.elements.all { for el in detection.elements.all {
let b = el.bounds let b = el.bounds
let label = (el.label ?? el.value ?? "").replacingOccurrences(of: "\n", with: " ") let label = (el.label ?? el.value ?? "").replacingOccurrences(of: "\n", with: " ")
let coords = "\(Int(b.origin.x)),\(Int(b.origin.y))" let coords = "\(Int(b.origin.x)),\(Int(b.origin.y))"
let size = "\(Int(b.size.width))x\(Int(b.size.height))" let size = "\(Int(b.size.width))x\(Int(b.size.height))"
let line = "\(el.id)\t\(el.type)\t\(coords) \(size)\t\(label)\n" let line = "\(el.id)\t\(el.type)\t\(coords) \(size)\t\(label)\n"
FileHandle.standardOutput.write(Data(line.utf8)) FileHandle.standardOutput.write(Data(line.utf8))
} }
} }
return 0 return 0
} }
@@ -522,16 +522,16 @@ enum UICLI {
]) ])
} }
do { do {
return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle) return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle)
} catch { } catch {
let command = "clawdis-mac ui see --bundle-id \(resolvedBundle)" let command = "clawdis-mac ui see --bundle-id \(resolvedBundle)"
let help = "No recent snapshot for \(resolvedBundle). Run `\(command)` first." let help = "No recent snapshot for \(resolvedBundle). Run `\(command)` first."
throw NSError(domain: "clawdis.ui", code: 6, userInfo: [ throw NSError(domain: "clawdis.ui", code: 6, userInfo: [
NSLocalizedDescriptionKey: help, NSLocalizedDescriptionKey: help,
]) ])
} }
} }
// MARK: - IO helpers // MARK: - IO helpers