diff --git a/apps/ios/Sources/Bridge/BridgeClient.swift b/apps/ios/Sources/Bridge/BridgeClient.swift index b6988fdee..8933a33bc 100644 --- a/apps/ios/Sources/Bridge/BridgeClient.swift +++ b/apps/ios/Sources/Bridge/BridgeClient.swift @@ -51,8 +51,7 @@ actor BridgeClient { nodeId: hello.nodeId, displayName: hello.displayName, platform: hello.platform, - version: hello.version - ), + version: hello.version), over: connection) onStatus?("Waiting for approval…") diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 5e27828ca..4a4f0e098 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -42,36 +42,36 @@ final class NodeAppModel: ObservableObject { } } - func setVoiceWakeEnabled(_ enabled: Bool) { - self.voiceWake.setEnabled(enabled) - } + func setVoiceWakeEnabled(_ enabled: Bool) { + self.voiceWake.setEnabled(enabled) + } - func connectToBridge( - endpoint: NWEndpoint, - hello: BridgeHello) - { - self.bridgeTask?.cancel() - self.bridgeStatusText = "Connecting…" - self.bridgeServerName = nil - self.bridgeRemoteAddress = nil - self.connectedBridgeID = BridgeEndpointID.stableID(endpoint) + func connectToBridge( + endpoint: NWEndpoint, + hello: BridgeHello) + { + self.bridgeTask?.cancel() + self.bridgeStatusText = "Connecting…" + self.bridgeServerName = nil + self.bridgeRemoteAddress = nil + self.connectedBridgeID = BridgeEndpointID.stableID(endpoint) self.bridgeTask = Task { - do { - try await self.bridge.connect( - endpoint: endpoint, - hello: hello, - onConnected: { [weak self] serverName in - guard let self else { return } - await MainActor.run { - self.bridgeStatusText = "Connected" + do { + try await self.bridge.connect( + endpoint: endpoint, + hello: hello, + onConnected: { [weak self] serverName in + guard let self else { return } + await MainActor.run { + self.bridgeStatusText = "Connected" self.bridgeServerName = serverName } if let addr = await self.bridge.currentRemoteAddress() { await MainActor.run { self.bridgeRemoteAddress = addr - } - } + } + } }, onInvoke: { [weak self] req in guard let self else { @@ -110,20 +110,20 @@ final class NodeAppModel: ObservableObject { self.connectedBridgeID = nil } - func sendVoiceTranscript(text: String, sessionKey: String?) async throws { - struct Payload: Codable { - var text: String - var sessionKey: String? - } - let payload = Payload(text: text, sessionKey: sessionKey) - let data = try JSONEncoder().encode(payload) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", - ]) - } - try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json) - } + func sendVoiceTranscript(text: String, sessionKey: String?) async throws { + struct Payload: Codable { + var text: String + var sessionKey: String? + } + let payload = Payload(text: text, sessionKey: sessionKey) + let data = try JSONEncoder().encode(payload) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", + ]) + } + try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json) + } func handleDeepLink(url: URL) async { 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. - // (Key-based unattended auth is handled on macOS for clawdis:// links.) - let data = try JSONEncoder().encode(link) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", - ]) - } - try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json) - } + // iOS bridge forwards to the gateway; no local auth prompts here. + // (Key-based unattended auth is handled on macOS for clawdis:// links.) + let data = try JSONEncoder().encode(link) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", + ]) + } + try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json) + } private func isBridgeConnected() async -> Bool { if case .connected = await self.bridge.state { return true } @@ -243,13 +243,13 @@ final class NodeAppModel: ObservableObject { return try JSONDecoder().decode(type, from: data) } - private static func encodePayload(_ obj: some Encodable) throws -> String { - let data = try JSONEncoder().encode(obj) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 21, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8", - ]) - } - return json - } + private static func encodePayload(_ obj: some Encodable) throws -> String { + let data = try JSONEncoder().encode(obj) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8", + ]) + } + return json + } } diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 778f3e0bf..e48a797b9 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -105,19 +105,19 @@ final class ScreenController: ObservableObject { #000; overflow: hidden; } - body::before { - content:""; - position: fixed; - inset: -20%; - background: - repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, - transparent 1px, transparent 48px), - repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, - transparent 1px, transparent 48px); - transform: rotate(-7deg); - opacity: 0.55; - pointer-events: none; - } + body::before { + content:""; + position: fixed; + inset: -20%; + background: + repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, + transparent 1px, transparent 48px), + repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, + transparent 1px, transparent 48px); + transform: rotate(-7deg); + opacity: 0.55; + pointer-events: none; + } canvas { display:block; width:100vw; diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index dd3062093..e747f5f05 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -1,5 +1,13 @@ +import ClawdisKit import SwiftUI +@MainActor +private final class ConnectStatusStore: ObservableObject { + @Published var text: String? +} + +extension ConnectStatusStore: @unchecked Sendable {} + struct SettingsTab: View { @EnvironmentObject private var appModel: NodeAppModel @Environment(\.dismiss) private var dismiss @@ -8,7 +16,7 @@ struct SettingsTab: View { @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false @AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = "" @StateObject private var discovery = BridgeDiscoveryModel() - @State private var connectStatus: String? + @StateObject private var connectStatus = ConnectStatusStore() @State private var connectingBridgeID: String? @State private var didAutoConnect = false @@ -47,8 +55,8 @@ struct SettingsTab: View { self.bridgeList(showing: .all) } - if let connectStatus { - Text(connectStatus) + if let text = self.connectStatus.text { + Text(text) .font(.footnote) .foregroundStyle(.secondary) } @@ -77,22 +85,20 @@ struct SettingsTab: View { guard let existing, !existing.isEmpty else { return } guard let target = self.pickAutoConnectBridge(from: newValue) else { return } - self.didAutoConnect = true - self.preferredBridgeStableID = target.stableID - self.appModel.connectToBridge( - endpoint: target.endpoint, - hello: BridgeHello( - nodeId: self.instanceId, - displayName: self.displayName, - token: existing, - platform: self.platformString(), - version: self.appVersion() - ) - ) - self.connectStatus = nil - } + self.didAutoConnect = true + self.preferredBridgeStableID = target.stableID + self.appModel.connectToBridge( + endpoint: target.endpoint, + hello: BridgeHello( + nodeId: self.instanceId, + displayName: self.displayName, + token: existing, + platform: self.platformString(), + version: self.appVersion())) + self.connectStatus.text = nil + } .onChange(of: self.appModel.bridgeServerName) { _, _ in - self.connectStatus = nil + self.connectStatus.text = nil } } } @@ -173,22 +179,21 @@ struct SettingsTab: View { existing : nil - let hello = BridgeHello( - nodeId: self.instanceId, - displayName: self.displayName, - token: existingToken, - platform: self.platformString(), - version: self.appVersion() - ) - let token = try await BridgeClient().pairAndHello( - endpoint: bridge.endpoint, - hello: hello, - onStatus: { status in - Task { @MainActor in - self.connectStatus = status - } - } - ) + let hello = BridgeHello( + nodeId: self.instanceId, + displayName: self.displayName, + token: existingToken, + platform: self.platformString(), + version: self.appVersion()) + let token = try await BridgeClient().pairAndHello( + endpoint: bridge.endpoint, + hello: hello, + onStatus: { status in + let store = self.connectStatus + Task { @MainActor in + store.text = status + } + }) if !token.isEmpty, token != existingToken { _ = KeychainStore.saveString( @@ -197,19 +202,17 @@ struct SettingsTab: View { account: self.keychainAccount()) } - self.appModel.connectToBridge( - endpoint: bridge.endpoint, - hello: BridgeHello( - nodeId: self.instanceId, - displayName: self.displayName, - token: token, - platform: self.platformString(), - version: self.appVersion() - ) - ) + self.appModel.connectToBridge( + endpoint: bridge.endpoint, + hello: BridgeHello( + nodeId: self.instanceId, + displayName: self.displayName, + token: token, + platform: self.platformString(), + version: self.appVersion())) } catch { - self.connectStatus = "Failed: \(error.localizedDescription)" + self.connectStatus.text = "Failed: \(error.localizedDescription)" } } diff --git a/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift b/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift index a387f4cff..1256890a2 100644 --- a/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift +++ b/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift @@ -87,22 +87,22 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { return self.html("Forbidden", title: "Canvas: 403") } - do { - let data = try Data(contentsOf: standardizedFile) - let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension) - let servedPath = standardizedFile.path - canvasLogger.debug( - "served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)") - return CanvasResponse(mime: mime, data: data) - } catch { - let failedPath = standardizedFile.path - let errorText = error.localizedDescription - canvasLogger - .error( - "failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)") - return self.html("Failed to read file.", title: "Canvas error") - } - } + do { + let data = try Data(contentsOf: standardizedFile) + let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension) + let servedPath = standardizedFile.path + canvasLogger.debug( + "served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)") + return CanvasResponse(mime: mime, data: data) + } catch { + let failedPath = standardizedFile.path + let errorText = error.localizedDescription + canvasLogger + .error( + "failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)") + return self.html("Failed to read file.", title: "Canvas error") + } + } private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { let fm = FileManager.default diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index 318003882..feaf1d3c0 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -4,6 +4,11 @@ import SwiftUI struct ConfigSettings: View { private let isPreview = ProcessInfo.processInfo.isPreview 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 won’t interfere with your daily browser." @State private var configModel: String = "" @State private var customModel: String = "" @State private var configSaving = false @@ -203,16 +208,12 @@ struct ConfigSettings: View { .toggleStyle(.checkbox) .disabled(!self.browserEnabled) .onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() } - .help( - "When enabled, the browser server will only connect if the clawd browser is already running." - ) + .help(Self.browserAttachOnlyHelp) } GridRow { Color.clear .frame(width: self.labelColumnWidth, height: 1) - Text( - "Clawd uses a separate Chrome profile and ports (default 18791/18792) so it won’t interfere with your daily browser." - ) + Text(Self.browserProfileNote) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 42fe9bb09..c62fa5d1d 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -144,15 +144,15 @@ final class ControlChannel: ObservableObject { } // If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it. - if let urlErr = error as? URLError, - urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures - { - let reason = urlErr.failureURLString ?? urlErr.localizedDescription - return - "Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment " + - "or clear it on the gateway. " + - "Reason: \(reason)" - } + if let urlErr = error as? URLError, + urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures + { + let reason = urlErr.failureURLString ?? urlErr.localizedDescription + return + "Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment " + + "or clear it on the gateway. " + + "Reason: \(reason)" + } // 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). diff --git a/apps/macos/Sources/Clawdis/ControlSocketServer.swift b/apps/macos/Sources/Clawdis/ControlSocketServer.swift index c13a4c742..4f6bcac8b 100644 --- a/apps/macos/Sources/Clawdis/ControlSocketServer.swift +++ b/apps/macos/Sources/Clawdis/ControlSocketServer.swift @@ -234,12 +234,12 @@ final actor ControlSocketServer { #if DEBUG // 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). - let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"] - if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() { - self.logger.warning( - "allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)") - return true - } + let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"] + if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() { + self.logger.warning( + "allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)") + return true + } #endif if let callerUID = self.uid(for: pid) { diff --git a/apps/macos/Sources/Clawdis/CronSettings.swift b/apps/macos/Sources/Clawdis/CronSettings.swift index ce0dfe315..76cc6f78a 100644 --- a/apps/macos/Sources/Clawdis/CronSettings.swift +++ b/apps/macos/Sources/Clawdis/CronSettings.swift @@ -69,13 +69,12 @@ struct CronSettings: View { .font(.headline) Spacer() } - Text( - "Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " + - "and the Gateway restarts." - ) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + Text( + "Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " + + "and the Gateway restarts.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) if let storePath = self.store.schedulerStorePath, !storePath.isEmpty { Text(storePath) .font(.caption.monospaced()) @@ -497,6 +496,21 @@ private struct CronJobEditor: View { let onSave: ([String: Any]) -> Void 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 enabled: Bool = true @@ -527,9 +541,7 @@ private struct CronJobEditor: View { VStack(alignment: .leading, spacing: 6) { Text(self.job == nil ? "New cron job" : "Edit cron job") .font(.title3.weight(.semibold)) - Text( - "Create a schedule that wakes clawd via the Gateway. Use an isolated session for agent turns so your main chat stays clean." - ) + Text(Self.introText) .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -575,8 +587,7 @@ private struct CronJobEditor: View { Color.clear .frame(width: self.labelColumnWidth, height: 1) 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) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -601,8 +612,7 @@ private struct CronJobEditor: View { Color.clear .frame(width: self.labelColumnWidth, height: 1) Text( - "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." - ) + Self.scheduleKindNote) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -646,9 +656,7 @@ private struct CronJobEditor: View { GroupBox("Payload") { VStack(alignment: .leading, spacing: 10) { if self.sessionTarget == .isolated { - Text( - "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." - ) + Text(Self.isolatedPayloadNote) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -669,8 +677,7 @@ private struct CronJobEditor: View { Color.clear .frame(width: self.labelColumnWidth, height: 1) Text( - "System events are injected into the current main session. Agent turns require an isolated session target." - ) + Self.mainPayloadNote) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -703,8 +710,7 @@ private struct CronJobEditor: View { Color.clear .frame(width: self.labelColumnWidth, height: 1) Text( - "Controls the label used when posting the completion summary back to the main session." - ) + Self.mainSummaryNote) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -914,14 +920,14 @@ private struct CronJobEditor: View { }() if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [ - NSLocalizedDescriptionKey: - "Main session jobs require systemEvent payloads (switch Session target to isolated).", - ]) - } + throw NSError( + domain: "Cron", + code: 0, + userInfo: [ + NSLocalizedDescriptionKey: + "Main session jobs require systemEvent payloads (switch Session target to isolated).", + ]) + } if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { throw NSError( diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index 133754a4d..6b79e24b7 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -141,17 +141,16 @@ struct DebugSettings: View { } .frame(maxWidth: .infinity, alignment: .leading) } - GridRow { - self.gridLabel("Attach only") - Toggle("", isOn: self.$attachExistingGatewayOnly) - .labelsHidden() - .toggleStyle(.checkbox) - .help( - "When enabled in local mode, the mac app will only connect " + - "to an already-running gateway " + - "and will not start one itself." - ) - } + GridRow { + self.gridLabel("Attach only") + Toggle("", isOn: self.$attachExistingGatewayOnly) + .labelsHidden() + .toggleStyle(.checkbox) + .help( + "When enabled in local mode, the mac app will only connect " + + "to an already-running gateway " + + "and will not start one itself.") + } GridRow { self.gridLabel("Deep links") Toggle("", isOn: self.$deepLinkAgentEnabled) @@ -232,17 +231,16 @@ struct DebugSettings: View { GridRow { self.gridLabel("Diagnostics") - VStack(alignment: .leading, spacing: 6) { - Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled) - .toggleStyle(.checkbox) - .help( - "Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " + - "Enable only while actively debugging." - ) - HStack(spacing: 8) { - Button("Open folder") { - NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL()) - } + VStack(alignment: .leading, spacing: 6) { + Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled) + .toggleStyle(.checkbox) + .help( + "Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " + + "Enable only while actively debugging.") + HStack(spacing: 8) { + Button("Open folder") { + NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL()) + } .buttonStyle(.bordered) Button("Clear") { Task { try? await DiagnosticsFileLog.shared.clear() } @@ -485,13 +483,12 @@ struct DebugSettings: View { private var canvasSection: some View { GroupBox("Canvas") { - VStack(alignment: .leading, spacing: 10) { - Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled) - .toggleStyle(.checkbox) - .help( - "When off, agent Canvas requests return “Canvas disabled by user”. " + - "Manual debug actions still work." - ) + VStack(alignment: .leading, spacing: 10) { + Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled) + .toggleStyle(.checkbox) + .help( + "When off, agent Canvas requests return “Canvas disabled by user”. " + + "Manual debug actions still work.") HStack(spacing: 8) { TextField("Session", text: self.$canvasSessionKey) @@ -587,18 +584,17 @@ struct DebugSettings: View { .labelsHidden() .frame(maxWidth: 280, alignment: .leading) } - GridRow { - self.gridLabel("Web chat") - Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled) - .toggleStyle(.checkbox) - .help( - "When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the " + - "bundled WKWebView." - ) - } - } - } - } + GridRow { + self.gridLabel("Web chat") + Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled) + .toggleStyle(.checkbox) + .help( + "When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the " + + "bundled WKWebView.") + } + } + } + } @MainActor private func runPortCheck() async { @@ -752,12 +748,12 @@ struct DebugSettings: View { } } - private func configURL() -> URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".clawdis") - .appendingPathComponent("clawdis.json") - } - } + private func configURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".clawdis") + .appendingPathComponent("clawdis.json") + } +} extension DebugSettings { // MARK: - Canvas debug actions @@ -854,8 +850,7 @@ extension DebugSettings { let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let result = try await CanvasManager.shared.eval( sessionKey: session.isEmpty ? "main" : session, - javaScript: self.canvasEvalJS - ) + javaScript: self.canvasEvalJS) self.canvasEvalResult = result } catch { self.canvasError = error.localizedDescription @@ -870,8 +865,7 @@ extension DebugSettings { let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let path = try await CanvasManager.shared.snapshot( sessionKey: session.isEmpty ? "main" : session, - outPath: nil - ) + outPath: nil) self.canvasSnapshotPath = path } catch { self.canvasError = error.localizedDescription @@ -879,22 +873,22 @@ extension DebugSettings { } } - private struct PlainSettingsGroupBoxStyle: GroupBoxStyle { - func makeBody(configuration: Configuration) -> some View { - VStack(alignment: .leading, spacing: 10) { - configuration.label - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - configuration.content - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } +private struct PlainSettingsGroupBoxStyle: GroupBoxStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 10) { + configuration.label + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + configuration.content + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} - #if DEBUG - struct DebugSettings_Previews: PreviewProvider { - static var previews: some View { - DebugSettings() +#if DEBUG +struct DebugSettings_Previews: PreviewProvider { + static var previews: some View { + DebugSettings() .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) } } diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index e56ff9f40..20cc38d85 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -125,18 +125,18 @@ actor GatewayEndpointStore { for (_, continuation) in self.subscribers { continuation.yield(next) } - switch next { - case let .ready(mode, url, _): - let modeDesc = String(describing: mode) - let urlDesc = url.absoluteString - self.logger - .debug( - "resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)") - case let .unavailable(mode, reason): - let modeDesc = String(describing: mode) - self.logger - .debug( - "endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)") - } - } + switch next { + case let .ready(mode, url, _): + let modeDesc = String(describing: mode) + let urlDesc = url.absoluteString + self.logger + .debug( + "resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)") + case let .unavailable(mode, reason): + let modeDesc = String(describing: mode) + self.logger + .debug( + "endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)") + } + } } diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index 07b404b16..ca20e73fe 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -1,15 +1,15 @@ import AppKit import SwiftUI - struct GeneralSettings: View { - @ObservedObject var state: AppState - @ObservedObject private var healthStore = HealthStore.shared - @ObservedObject private var gatewayManager = GatewayProcessManager.shared - // swiftlint:disable:next inclusive_language - @StateObject private var masterDiscovery = MasterDiscoveryModel() - @State private var isInstallingCLI = false - @State private var cliStatus: String? - @State private var cliInstalled = false +struct GeneralSettings: View { + @ObservedObject var state: AppState + @ObservedObject private var healthStore = HealthStore.shared + @ObservedObject private var gatewayManager = GatewayProcessManager.shared + // swiftlint:disable:next inclusive_language + @StateObject private var masterDiscovery = MasterDiscoveryModel() + @State private var isInstallingCLI = false + @State private var cliStatus: String? + @State private var cliInstalled = false @State private var cliInstallLocation: String? @State private var gatewayStatus: GatewayEnvironmentStatus = .checking @State private var gatewayInstallMessage: String? @@ -577,12 +577,12 @@ extension GeneralSettings { alert.runModal() } - // swiftlint:disable:next inclusive_language - private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) { - let host = master.tailnetDns ?? master.lanHost - guard let host else { return } - let user = NSUserName() - var target = "\(user)@\(host)" + // swiftlint:disable:next inclusive_language + private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) { + let host = master.tailnetDns ?? master.lanHost + guard let host else { return } + let user = NSUserName() + var target = "\(user)@\(host)" if master.sshPort != 22 { target += ":\(master.sshPort)" } diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index fd2314811..7f2bc3603 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -45,16 +45,16 @@ struct OnboardingView: View { @State private var cliStatus: String? @State private var copied = false @State private var monitoringPermissions = false - @State private var monitoringDiscovery = false - @State private var cliInstalled = false - @State private var cliInstallLocation: String? - @State private var gatewayStatus: GatewayEnvironmentStatus = .checking - @State private var gatewayInstalling = false - @State private var gatewayInstallMessage: String? - // swiftlint:disable:next inclusive_language - @StateObject private var masterDiscovery = MasterDiscoveryModel() - @ObservedObject private var state = AppStateStore.shared - @ObservedObject private var permissionMonitor = PermissionMonitor.shared + @State private var monitoringDiscovery = false + @State private var cliInstalled = false + @State private var cliInstallLocation: String? + @State private var gatewayStatus: GatewayEnvironmentStatus = .checking + @State private var gatewayInstalling = false + @State private var gatewayInstallMessage: String? + // swiftlint:disable:next inclusive_language + @StateObject private var masterDiscovery = MasterDiscoveryModel() + @ObservedObject private var state = AppStateStore.shared + @ObservedObject private var permissionMonitor = PermissionMonitor.shared private let pageWidth: CGFloat = 680 private let contentHeight: CGFloat = 520 @@ -116,17 +116,16 @@ struct OnboardingView: View { } private func welcomePage() -> some View { - self.onboardingPage { - Text("Welcome to Clawdis") - .font(.largeTitle.weight(.semibold)) - Text( - "Your macOS menu bar companion for notifications, screenshots, and agent automation — " + - "setup takes just a few minutes." - ) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .lineLimit(2) + self.onboardingPage { + Text("Welcome to Clawdis") + .font(.largeTitle.weight(.semibold)) + Text( + "Your macOS menu bar companion for notifications, screenshots, and agent automation — " + + "setup takes just a few minutes.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) .frame(maxWidth: 560) .fixedSize(horizontal: false, vertical: true) @@ -141,16 +140,16 @@ struct OnboardingView: View { VStack(alignment: .leading, spacing: 6) { Text("Security notice") .font(.headline) - Text( - """ - The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, - including running - commands, reading/writing files, and capturing screenshots — depending on the - permissions you grant. + Text( + """ + The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, + including running + commands, reading/writing files, and capturing screenshots — depending on the + permissions you grant. - Only enable Clawdis if you understand the risks and trust the prompts - and integrations you use. - """) + Only enable Clawdis if you understand the risks and trust the prompts + and integrations you use. + """) .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -162,17 +161,16 @@ struct OnboardingView: View { } private func connectionPage() -> some View { - self.onboardingPage { - Text("Where Clawdis runs") - .font(.largeTitle.weight(.semibold)) - Text( - "Clawdis has one primary Gateway (“master”) that runs continuously. " + - "Connect locally or over SSH/Tailscale so the agent can work on any Mac." - ) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .lineLimit(2) + self.onboardingPage { + Text("Where Clawdis runs") + .font(.largeTitle.weight(.semibold)) + Text( + "Clawdis has one primary Gateway (“master”) that runs continuously. " + + "Connect locally or over SSH/Tailscale so the agent can work on any Mac.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) .frame(maxWidth: 520) .fixedSize(horizontal: false, vertical: true) @@ -300,26 +298,25 @@ struct OnboardingView: View { .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) - } else { - Text( - "Uses \"npm install -g clawdis@\" on your PATH. " + - "We keep the gateway on port 18789." - ) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) + } else { + Text( + "Uses \"npm install -g clawdis@\" on your PATH. " + + "We keep the gateway on port 18789.") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) } } } } } - // swiftlint:disable:next inclusive_language - private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) { - let host = master.tailnetDns ?? master.lanHost - guard let host else { return } - let user = NSUserName() - var target = "\(user)@\(host)" + // swiftlint:disable:next inclusive_language + private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) { + let host = master.tailnetDns ?? master.lanHost + guard let host else { return } + let user = NSUserName() + var target = "\(user)@\(host)" if master.sshPort != 22 { target += ":\(master.sshPort)" } @@ -460,13 +457,13 @@ struct OnboardingView: View { Text("Telegram") .font(.headline) - self.featureRow( - title: "Set `TELEGRAM_BOT_TOKEN`", - subtitle: """ - Create a bot with @BotFather and set the token as an env var - (or `telegram.botToken` in `~/.clawdis/clawdis.json`). - """, - systemImage: "key") + self.featureRow( + title: "Set `TELEGRAM_BOT_TOKEN`", + subtitle: """ + Create a bot with @BotFather and set the token as an env var + (or `telegram.botToken` in `~/.clawdis/clawdis.json`). + """, + systemImage: "key") self.featureRow( title: "Verify with `clawdis status --deep`", subtitle: "This probes both WhatsApp and the Telegram API and prints what’s configured.", @@ -491,11 +488,11 @@ struct OnboardingView: View { title: "Try Voice Wake", subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", systemImage: "waveform.circle") - self.featureRow( - title: "Use the panel + Canvas", - subtitle: "Open the menu bar panel for quick chat; the agent can show previews " + - "and richer visuals in Canvas.", - systemImage: "rectangle.inset.filled.and.person.filled") + self.featureRow( + title: "Use the panel + Canvas", + subtitle: "Open the menu bar panel for quick chat; the agent can show previews " + + "and richer visuals in Canvas.", + systemImage: "rectangle.inset.filled.and.person.filled") self.featureRow( title: "Test a notification", subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.", diff --git a/apps/macos/Sources/Clawdis/PermissionManager.swift b/apps/macos/Sources/Clawdis/PermissionManager.swift index 657a68d4b..023f10bd4 100644 --- a/apps/macos/Sources/Clawdis/PermissionManager.swift +++ b/apps/macos/Sources/Clawdis/PermissionManager.swift @@ -9,109 +9,110 @@ import Speech import UserNotifications enum PermissionManager { - static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { - var results: [Capability: Bool] = [:] - for cap in caps { - results[cap] = await self.ensureCapability(cap, interactive: interactive) - } - return results - } + static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { + var results: [Capability: Bool] = [:] + for cap in caps { + results[cap] = await self.ensureCapability(cap, interactive: interactive) + } + return results + } - private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool { - switch cap { - case .notifications: - return await self.ensureNotifications(interactive: interactive) - case .appleScript: - return await self.ensureAppleScript(interactive: interactive) - case .accessibility: - return await self.ensureAccessibility(interactive: interactive) - case .screenRecording: - return await self.ensureScreenRecording(interactive: interactive) - case .microphone: - return await self.ensureMicrophone(interactive: interactive) - case .speechRecognition: - return await self.ensureSpeechRecognition(interactive: interactive) - } - } + private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool { + switch cap { + case .notifications: + await self.ensureNotifications(interactive: interactive) + case .appleScript: + await self.ensureAppleScript(interactive: interactive) + case .accessibility: + await self.ensureAccessibility(interactive: interactive) + case .screenRecording: + await self.ensureScreenRecording(interactive: interactive) + case .microphone: + await self.ensureMicrophone(interactive: interactive) + case .speechRecognition: + await self.ensureSpeechRecognition(interactive: interactive) + } + } - private static func ensureNotifications(interactive: Bool) async -> Bool { - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() + private static func ensureNotifications(interactive: Bool) async -> Bool { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - return true - case .notDetermined: - guard interactive else { return false } - let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false - let updated = await center.notificationSettings() - return granted && (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional) - case .denied: - if interactive { - NotificationPermissionHelper.openSettings() - } - return false - @unknown default: - return false - } - } + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined: + guard interactive else { return false } + let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + let updated = await center.notificationSettings() + return granted && + (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional) + case .denied: + if interactive { + NotificationPermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } - private static func ensureAppleScript(interactive: Bool) async -> Bool { - let granted = await MainActor.run { AppleScriptPermission.isAuthorized() } - if interactive, !granted { - await AppleScriptPermission.requestAuthorization() - } - return await MainActor.run { AppleScriptPermission.isAuthorized() } - } + private static func ensureAppleScript(interactive: Bool) async -> Bool { + let granted = await MainActor.run { AppleScriptPermission.isAuthorized() } + if interactive, !granted { + await AppleScriptPermission.requestAuthorization() + } + return await MainActor.run { AppleScriptPermission.isAuthorized() } + } - private static func ensureAccessibility(interactive: Bool) async -> Bool { - let trusted = await MainActor.run { AXIsProcessTrusted() } - if interactive, !trusted { - await MainActor.run { - let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] - _ = AXIsProcessTrustedWithOptions(opts) - } - } - return await MainActor.run { AXIsProcessTrusted() } - } + private static func ensureAccessibility(interactive: Bool) async -> Bool { + let trusted = await MainActor.run { AXIsProcessTrusted() } + if interactive, !trusted { + await MainActor.run { + let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] + _ = AXIsProcessTrustedWithOptions(opts) + } + } + return await MainActor.run { AXIsProcessTrusted() } + } - private static func ensureScreenRecording(interactive: Bool) async -> Bool { - let granted = ScreenRecordingProbe.isAuthorized() - if interactive, !granted { - await ScreenRecordingProbe.requestAuthorization() - } - return ScreenRecordingProbe.isAuthorized() - } + private static func ensureScreenRecording(interactive: Bool) async -> Bool { + let granted = ScreenRecordingProbe.isAuthorized() + if interactive, !granted { + await ScreenRecordingProbe.requestAuthorization() + } + return ScreenRecordingProbe.isAuthorized() + } - private static func ensureMicrophone(interactive: Bool) async -> Bool { - let status = AVCaptureDevice.authorizationStatus(for: .audio) - switch status { - case .authorized: - return true - case .notDetermined: - guard interactive else { return false } - return await AVCaptureDevice.requestAccess(for: .audio) - case .denied, .restricted: - if interactive { - MicrophonePermissionHelper.openSettings() - } - return false - @unknown default: - return false - } - } + private static func ensureMicrophone(interactive: Bool) async -> Bool { + let status = AVCaptureDevice.authorizationStatus(for: .audio) + switch status { + case .authorized: + return true + case .notDetermined: + guard interactive else { return false } + return await AVCaptureDevice.requestAccess(for: .audio) + case .denied, .restricted: + if interactive { + MicrophonePermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } - private static func ensureSpeechRecognition(interactive: Bool) async -> Bool { - let status = SFSpeechRecognizer.authorizationStatus() - if status == .notDetermined, interactive { - await withUnsafeContinuation { (cont: UnsafeContinuation) in - SFSpeechRecognizer.requestAuthorization { _ in - DispatchQueue.main.async { cont.resume() } - } - } - } - return SFSpeechRecognizer.authorizationStatus() == .authorized - } + private static func ensureSpeechRecognition(interactive: Bool) async -> Bool { + let status = SFSpeechRecognizer.authorizationStatus() + if status == .notDetermined, interactive { + await withUnsafeContinuation { (cont: UnsafeContinuation) in + SFSpeechRecognizer.requestAuthorization { _ in + DispatchQueue.main.async { cont.resume() } + } + } + } + return SFSpeechRecognizer.authorizationStatus() == .authorized + } static func voiceWakePermissionsGranted() -> Bool { let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index 81aaaa635..fedad318c 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -451,8 +451,7 @@ struct WebChatView: View { Text( self.viewModel.healthOK ? "This is the native SwiftUI debug chat." - : "Connecting to the gateway…" - ) + : "Connecting to the gateway…") .font(.subheadline) .foregroundStyle(.secondary) } diff --git a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift index eadb69f62..58aee1a70 100644 --- a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift @@ -105,8 +105,8 @@ enum BrowserCLI { sub: String, options: RunOptions, baseURL: URL, - jsonOutput: Bool - ) async throws -> Int32 { + jsonOutput: Bool) async throws -> Int32 + { switch sub { case "status": return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput) @@ -172,8 +172,7 @@ enum BrowserCLI { method: "POST", url: url, body: ["url": urlString], - timeoutInterval: 15.0 - ) + timeoutInterval: 15.0) self.printResult(jsonOutput: jsonOutput, res: res) return 0 } @@ -188,8 +187,7 @@ enum BrowserCLI { method: "POST", url: url, body: ["targetId": id], - timeoutInterval: 5.0 - ) + timeoutInterval: 5.0) self.printResult(jsonOutput: jsonOutput, res: res) return 0 } @@ -250,8 +248,7 @@ enum BrowserCLI { "targetId": options.targetId ?? "", "await": options.awaitPromise, ], - timeoutInterval: 15.0 - ) + timeoutInterval: 15.0) if jsonOutput { self.printJSON(ok: true, result: res) diff --git a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift index 94eee3f48..f4cb0c52b 100644 --- a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift @@ -58,276 +58,269 @@ struct ClawdisCLI { enum Kind { case generic } - } + } - private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest { - var args = args - guard !args.isEmpty else { throw CLIError.help } - let command = args.removeFirst() + private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest { + var args = args + guard !args.isEmpty else { throw CLIError.help } + let command = args.removeFirst() - switch command { - case "--help", "-h", "help": - throw CLIError.help + switch command { + case "--help", "-h", "help": + throw CLIError.help - case "--version", "-V", "version": - throw CLIError.version + case "--version", "-V", "version": + throw CLIError.version - case "notify": - return try self.parseNotify(args: &args) + case "notify": + return try self.parseNotify(args: &args) - case "ensure-permissions": - return self.parseEnsurePermissions(args: &args) + case "ensure-permissions": + return self.parseEnsurePermissions(args: &args) - case "run": - return self.parseRunShell(args: &args) + case "run": + return self.parseRunShell(args: &args) - case "status": - return ParsedCLIRequest(request: .status, kind: .generic) + case "status": + return ParsedCLIRequest(request: .status, kind: .generic) - case "rpc-status": - return ParsedCLIRequest(request: .rpcStatus, kind: .generic) + case "rpc-status": + return ParsedCLIRequest(request: .rpcStatus, kind: .generic) - case "agent": - return try self.parseAgent(args: &args) + case "agent": + return try self.parseAgent(args: &args) - case "node": - return try self.parseNode(args: &args) + case "node": + return try self.parseNode(args: &args) - case "canvas": - return try self.parseCanvas(args: &args) + case "canvas": + return try self.parseCanvas(args: &args) - default: - throw CLIError.help - } - } + default: + throw CLIError.help + } + } - private static func parseNotify(args: inout [String]) throws -> ParsedCLIRequest { - var title: String? - var body: String? - var sound: String? - var priority: NotificationPriority? - var delivery: NotificationDelivery? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--title": title = args.popFirst() - case "--body": body = args.popFirst() - case "--sound": sound = args.popFirst() - case "--priority": - if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p } - case "--delivery": - if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d } - default: break - } - } - guard let t = title, let b = body else { throw CLIError.help } - return ParsedCLIRequest( - request: .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery), - kind: .generic - ) - } + private static func parseNotify(args: inout [String]) throws -> ParsedCLIRequest { + var title: String? + var body: String? + var sound: String? + var priority: NotificationPriority? + var delivery: NotificationDelivery? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--title": title = args.popFirst() + case "--body": body = args.popFirst() + case "--sound": sound = args.popFirst() + case "--priority": + if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p } + case "--delivery": + if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d } + default: break + } + } + guard let t = title, let b = body else { throw CLIError.help } + return ParsedCLIRequest( + request: .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery), + kind: .generic) + } - private static func parseEnsurePermissions(args: inout [String]) -> ParsedCLIRequest { - var caps: [Capability] = [] - var interactive = false - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--cap": - if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) } - case "--interactive": - interactive = true - default: - break - } - } - if caps.isEmpty { caps = Capability.allCases } - return ParsedCLIRequest(request: .ensurePermissions(caps, interactive: interactive), kind: .generic) - } + private static func parseEnsurePermissions(args: inout [String]) -> ParsedCLIRequest { + var caps: [Capability] = [] + var interactive = false + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--cap": + if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) } + case "--interactive": + interactive = true + default: + break + } + } + if caps.isEmpty { caps = Capability.allCases } + return ParsedCLIRequest(request: .ensurePermissions(caps, interactive: interactive), kind: .generic) + } - private static func parseRunShell(args: inout [String]) -> ParsedCLIRequest { - var cwd: String? - var env: [String: String] = [:] - var timeout: Double? - var needsSR = false - var cmd: [String] = [] - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--cwd": - cwd = args.popFirst() - case "--env": - if let pair = args.popFirst() { - self.parseEnvPair(pair, into: &env) - } - case "--timeout": - if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl } - case "--needs-screen-recording": - needsSR = true - default: - cmd.append(arg) - } - } - return ParsedCLIRequest( - request: .runShell( - command: cmd, - cwd: cwd, - env: env.isEmpty ? nil : env, - timeoutSec: timeout, - needsScreenRecording: needsSR - ), - kind: .generic - ) - } + private static func parseRunShell(args: inout [String]) -> ParsedCLIRequest { + var cwd: String? + var env: [String: String] = [:] + var timeout: Double? + var needsSR = false + var cmd: [String] = [] + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--cwd": + cwd = args.popFirst() + case "--env": + if let pair = args.popFirst() { + self.parseEnvPair(pair, into: &env) + } + case "--timeout": + if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl } + case "--needs-screen-recording": + needsSR = true + default: + cmd.append(arg) + } + } + return ParsedCLIRequest( + request: .runShell( + command: cmd, + cwd: cwd, + env: env.isEmpty ? nil : env, + timeoutSec: timeout, + needsScreenRecording: needsSR), + kind: .generic) + } - private static func parseEnvPair(_ pair: String, into env: inout [String: String]) { - guard let eq = pair.firstIndex(of: "=") else { return } - let key = String(pair[.. ParsedCLIRequest { - guard let sub = args.popFirst() else { throw CLIError.help } - switch sub { - case "list": - return ParsedCLIRequest(request: .nodeList, kind: .generic) - case "invoke": - var nodeId: String? - var command: String? - var paramsJSON: String? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--node": nodeId = args.popFirst() - case "--command": command = args.popFirst() - case "--params-json": paramsJSON = args.popFirst() - default: break - } - } - guard let nodeId, let command else { throw CLIError.help } - return ParsedCLIRequest( - request: .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON), - kind: .generic - ) - default: - throw CLIError.help - } - } + private static func parseNode(args: inout [String]) throws -> ParsedCLIRequest { + guard let sub = args.popFirst() else { throw CLIError.help } + switch sub { + case "list": + return ParsedCLIRequest(request: .nodeList, kind: .generic) + case "invoke": + var nodeId: String? + var command: String? + var paramsJSON: String? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--node": nodeId = args.popFirst() + case "--command": command = args.popFirst() + case "--params-json": paramsJSON = args.popFirst() + default: break + } + } + guard let nodeId, let command else { throw CLIError.help } + return ParsedCLIRequest( + request: .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON), + kind: .generic) + default: + throw CLIError.help + } + } - private static func parseCanvas(args: inout [String]) throws -> ParsedCLIRequest { - guard let sub = args.popFirst() else { throw CLIError.help } - switch sub { - case "show": - var session = "main" - var path: String? - let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path) - return ParsedCLIRequest( - request: .canvasShow(session: session, path: path, placement: placement), - kind: .generic - ) - case "hide": - var session = "main" - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--session": session = args.popFirst() ?? session - default: break - } - } - return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic) - case "goto": - var session = "main" - var path: String? - let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path) - guard let path else { throw CLIError.help } - return ParsedCLIRequest( - request: .canvasGoto(session: session, path: path, placement: placement), - kind: .generic - ) - case "eval": - var session = "main" - var js: String? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--session": session = args.popFirst() ?? session - case "--js": js = args.popFirst() - default: break - } - } - guard let js else { throw CLIError.help } - return ParsedCLIRequest(request: .canvasEval(session: session, javaScript: js), kind: .generic) - case "snapshot": - var session = "main" - var outPath: String? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--session": session = args.popFirst() ?? session - case "--out": outPath = args.popFirst() - default: break - } - } - return ParsedCLIRequest(request: .canvasSnapshot(session: session, outPath: outPath), kind: .generic) - default: - throw CLIError.help - } - } + private static func parseCanvas(args: inout [String]) throws -> ParsedCLIRequest { + guard let sub = args.popFirst() else { throw CLIError.help } + switch sub { + case "show": + var session = "main" + var path: String? + let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path) + return ParsedCLIRequest( + request: .canvasShow(session: session, path: path, placement: placement), + kind: .generic) + case "hide": + var session = "main" + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--session": session = args.popFirst() ?? session + default: break + } + } + return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic) + case "goto": + var session = "main" + var path: String? + let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path) + guard let path else { throw CLIError.help } + return ParsedCLIRequest( + request: .canvasGoto(session: session, path: path, placement: placement), + kind: .generic) + case "eval": + var session = "main" + var js: String? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--session": session = args.popFirst() ?? session + case "--js": js = args.popFirst() + default: break + } + } + guard let js else { throw CLIError.help } + return ParsedCLIRequest(request: .canvasEval(session: session, javaScript: js), kind: .generic) + case "snapshot": + var session = "main" + var outPath: String? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--session": session = args.popFirst() ?? session + case "--out": outPath = args.popFirst() + default: break + } + } + return ParsedCLIRequest(request: .canvasSnapshot(session: session, outPath: outPath), kind: .generic) + default: + throw CLIError.help + } + } - private static func parseCanvasPlacement( - args: inout [String], - session: inout String, - path: inout String? - ) -> CanvasPlacement? { - var x: Double? - var y: Double? - var width: Double? - var height: Double? - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--session": session = args.popFirst() ?? session - case "--path": path = args.popFirst() - case "--x": x = args.popFirst().flatMap(Double.init) - case "--y": y = args.popFirst().flatMap(Double.init) - case "--width": width = args.popFirst().flatMap(Double.init) - case "--height": height = args.popFirst().flatMap(Double.init) - default: break - } - } - if x == nil, y == nil, width == nil, height == nil { return nil } - return CanvasPlacement(x: x, y: y, width: width, height: height) - } + private static func parseCanvasPlacement( + args: inout [String], + session: inout String, + path: inout String?) -> CanvasPlacement? + { + var x: Double? + var y: Double? + var width: Double? + var height: Double? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--session": session = args.popFirst() ?? session + case "--path": path = args.popFirst() + case "--x": x = args.popFirst().flatMap(Double.init) + case "--y": y = args.popFirst().flatMap(Double.init) + case "--width": width = args.popFirst().flatMap(Double.init) + case "--height": height = args.popFirst().flatMap(Double.init) + default: break + } + } + if x == nil, y == nil, width == nil, height == nil { return nil } + return CanvasPlacement(x: x, y: y, width: width, height: height) + } private static func printText(parsed: ParsedCLIRequest, response: Response) throws { guard response.ok else { @@ -506,13 +499,13 @@ struct ClawdisCLI { _NSGetExecutablePath(ptr.baseAddress, &size) } guard result2 == 0 else { return nil } - } + } - let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count - let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) } - guard let path = String(bytes: bytes, encoding: .utf8) else { return nil } - return URL(fileURLWithPath: path).resolvingSymlinksInPath() - } + let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count + let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) } + guard let path = String(bytes: bytes, encoding: .utf8) else { return nil } + return URL(fileURLWithPath: path).resolvingSymlinksInPath() + } private static func loadPackageJSONVersion() -> String? { guard let exeURL = self.resolveExecutableURL() else { return nil } diff --git a/apps/macos/Sources/ClawdisCLI/UICLI.swift b/apps/macos/Sources/ClawdisCLI/UICLI.swift index 3953607e9..44970426c 100644 --- a/apps/macos/Sources/ClawdisCLI/UICLI.swift +++ b/apps/macos/Sources/ClawdisCLI/UICLI.swift @@ -323,17 +323,17 @@ enum UICLI { "screenshotPath": screenshotPath, "result": self.toJSONObject(detection), ]) - } else { - FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8)) - for el in detection.elements.all { - let b = el.bounds - let label = (el.label ?? el.value ?? "").replacingOccurrences(of: "\n", with: " ") - let coords = "\(Int(b.origin.x)),\(Int(b.origin.y))" - let size = "\(Int(b.size.width))x\(Int(b.size.height))" - let line = "\(el.id)\t\(el.type)\t\(coords) \(size)\t\(label)\n" - FileHandle.standardOutput.write(Data(line.utf8)) - } - } + } else { + FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8)) + for el in detection.elements.all { + let b = el.bounds + let label = (el.label ?? el.value ?? "").replacingOccurrences(of: "\n", with: " ") + let coords = "\(Int(b.origin.x)),\(Int(b.origin.y))" + let size = "\(Int(b.size.width))x\(Int(b.size.height))" + let line = "\(el.id)\t\(el.type)\t\(coords) \(size)\t\(label)\n" + FileHandle.standardOutput.write(Data(line.utf8)) + } + } return 0 } @@ -522,16 +522,16 @@ enum UICLI { ]) } - do { - return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle) - } catch { - let command = "clawdis-mac ui see --bundle-id \(resolvedBundle)" - let help = "No recent snapshot for \(resolvedBundle). Run `\(command)` first." - throw NSError(domain: "clawdis.ui", code: 6, userInfo: [ - NSLocalizedDescriptionKey: help, - ]) - } - } + do { + return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle) + } catch { + let command = "clawdis-mac ui see --bundle-id \(resolvedBundle)" + let help = "No recent snapshot for \(resolvedBundle). Run `\(command)` first." + throw NSError(domain: "clawdis.ui", code: 6, userInfo: [ + NSLocalizedDescriptionKey: help, + ]) + } + } // MARK: - IO helpers