From e2a93e17f90910b87e855ef93fca9ccae8d201e1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 19:30:46 +0000 Subject: [PATCH] refactor: apply stashed bridge + CLI changes --- apps/ios/Sources/Bridge/BridgeClient.swift | 30 +- apps/ios/Sources/Model/NodeAppModel.swift | 106 +-- .../ios/Sources/Screen/ScreenController.swift | 24 +- apps/ios/Sources/Settings/SettingsTab.swift | 70 +- .../Sources/Clawdis/CanvasSchemeHandler.swift | 29 +- .../Sources/Clawdis/ConfigSettings.swift | 6 +- .../Sources/Clawdis/ControlChannel.swift | 15 +- .../Clawdis/ControlRequestHandler.swift | 372 +++++++---- .../Sources/Clawdis/ControlSocketServer.swift | 12 +- apps/macos/Sources/Clawdis/CronSettings.swift | 45 +- .../macos/Sources/Clawdis/DebugSettings.swift | 136 ++-- .../Clawdis/GatewayEndpointStore.swift | 25 +- .../Sources/Clawdis/GeneralSettings.swift | 28 +- .../Sources/Clawdis/MasterDiscoveryMenu.swift | 3 + .../Clawdis/MasterDiscoveryModel.swift | 4 + .../Sources/Clawdis/MenuContentView.swift | 10 +- apps/macos/Sources/Clawdis/Onboarding.swift | 120 ++-- .../Sources/Clawdis/PermissionManager.swift | 182 +++--- .../macos/Sources/Clawdis/WebChatServer.swift | 62 +- .../Sources/Clawdis/WebChatSwiftUI.swift | 17 +- .../macos/Sources/ClawdisCLI/BrowserCLI.swift | 606 ++++++++++-------- .../macos/Sources/ClawdisCLI/ClawdisCLI.swift | 493 +++++++------- apps/macos/Sources/ClawdisCLI/UICLI.swift | 39 +- 23 files changed, 1337 insertions(+), 1097 deletions(-) diff --git a/apps/ios/Sources/Bridge/BridgeClient.swift b/apps/ios/Sources/Bridge/BridgeClient.swift index 5f14a673f..b6988fdee 100644 --- a/apps/ios/Sources/Bridge/BridgeClient.swift +++ b/apps/ios/Sources/Bridge/BridgeClient.swift @@ -9,11 +9,7 @@ actor BridgeClient { func pairAndHello( endpoint: NWEndpoint, - nodeId: String, - displayName: String?, - platform: String, - version: String, - existingToken: String?, + hello: BridgeHello, onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String { self.lineBuffer = Data() @@ -25,14 +21,7 @@ actor BridgeClient { } onStatus?("Authenticating…") - try await self.send( - BridgeHello( - nodeId: nodeId, - displayName: displayName, - token: existingToken, - platform: platform, - version: version), - over: connection) + try await self.send(hello, over: connection) let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in guard let frame = try await self.receiveFrame(over: connection) else { @@ -46,7 +35,7 @@ actor BridgeClient { switch first.base.type { case "hello-ok": // We only return a token if we have one; callers should treat empty as "no token yet". - return existingToken ?? "" + return hello.token ?? "" case "error": let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data) @@ -59,10 +48,11 @@ actor BridgeClient { onStatus?("Requesting approval…") try await self.send( BridgePairRequest( - nodeId: nodeId, - displayName: displayName, - platform: platform, - version: version), + nodeId: hello.nodeId, + displayName: hello.displayName, + platform: hello.platform, + version: hello.version + ), over: connection) onStatus?("Waiting for approval…") @@ -155,7 +145,9 @@ actor BridgeClient { var errorDescription: String? { if self.purpose == "pairing approval" { - return "Timed out waiting for approval (\(self.seconds)s). Approve the node on your gateway and try again." + return + "Timed out waiting for approval (\(self.seconds)s). " + + "Approve the node on your gateway and try again." } return "Timed out during \(self.purpose) (\(self.seconds)s)." } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index e86f1c677..5e27828ca 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -42,45 +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, - token: String, - nodeId: String, - displayName: String?, - platform: String, - version: String) - { - 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: BridgeHello( - nodeId: nodeId, - displayName: displayName, - token: token, - platform: platform, - version: version), - 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 { @@ -119,16 +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) - let json = String(decoding: data, as: UTF8.self) - 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 } @@ -168,12 +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) - let json = String(decoding: data, as: UTF8.self) - 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 } @@ -244,8 +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) - return String(decoding: data, as: UTF8.self) - } + 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 955805d02..778f3e0bf 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -105,17 +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 00d4d3503..dd3062093 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -77,17 +77,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, - token: existing, - nodeId: self.instanceId, - displayName: self.displayName, - 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 = nil + } .onChange(of: self.appModel.bridgeServerName) { _, _ in self.connectStatus = nil } @@ -170,18 +173,22 @@ struct SettingsTab: View { existing : nil - let token = try await BridgeClient().pairAndHello( - endpoint: bridge.endpoint, - nodeId: self.instanceId, - displayName: self.displayName, - platform: self.platformString(), - version: self.appVersion(), - existingToken: existingToken, - 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 + Task { @MainActor in + self.connectStatus = status + } + } + ) if !token.isEmpty, token != existingToken { _ = KeychainStore.saveString( @@ -190,13 +197,16 @@ struct SettingsTab: View { account: self.keychainAccount()) } - self.appModel.connectToBridge( - endpoint: bridge.endpoint, - token: token, - nodeId: self.instanceId, - displayName: self.displayName, - 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)" diff --git a/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift b/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift index 5cb0696b5..a387f4cff 100644 --- a/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift +++ b/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift @@ -87,19 +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) - canvasLogger.debug( - "served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)") - return CanvasResponse(mime: mime, data: data) - } catch { - canvasLogger - .error( - "failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, 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 5c8730f77..318003882 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -204,13 +204,15 @@ struct ConfigSettings: View { .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.") + "When enabled, the browser server will only connect if the clawd browser is already running." + ) } 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.") + "Clawd uses a separate Chrome profile and ports (default 18791/18792) so it won’t interfere with your daily browser." + ) .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 2a52cd873..42fe9bb09 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -144,12 +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/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index fc2a49e1c..b26e838cf 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -10,156 +10,254 @@ enum ControlRequestHandler { { // Keep `status` responsive even if the main actor is busy. let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) - if paused { - switch request { - case .status: - break - default: - return Response(ok: false, message: "clawdis paused") - } + if paused, request != .status { + return Response(ok: false, message: "clawdis paused") } - switch request { - case let .notify(title, body, sound, priority, delivery): - let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines) - let chosenDelivery = delivery ?? .system + switch request { + case let .notify(title, body, sound, priority, delivery): + let notify = NotifyRequest( + title: title, + body: body, + sound: sound, + priority: priority, + delivery: delivery + ) + return await self.handleNotify(notify, notifier: notifier) - switch chosenDelivery { - case .system: - let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority) - return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized") + case let .ensurePermissions(caps, interactive): + return await self.handleEnsurePermissions(caps: caps, interactive: interactive) - case .overlay: - await MainActor.run { - NotifyOverlayController.shared.present(title: title, body: body) - } - return Response(ok: true) + case .status: + return paused + ? Response(ok: false, message: "clawdis paused") + : Response(ok: true, message: "ready") - case .auto: - let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority) - if ok { return Response(ok: true) } - await MainActor.run { - NotifyOverlayController.shared.present(title: title, body: body) - } - return Response(ok: true, message: "notification not authorized; used overlay") + case .rpcStatus: + return await self.handleRPCStatus() + + case let .runShell(command, cwd, env, timeoutSec, needsSR): + return await self.handleRunShell( + command: command, + cwd: cwd, + env: env, + timeoutSec: timeoutSec, + needsSR: needsSR + ) + + case let .agent(message, thinking, session, deliver, to): + return await self.handleAgent( + message: message, + thinking: thinking, + session: session, + deliver: deliver, + to: to + ) + + case let .canvasShow(session, path, placement): + return await self.handleCanvasShow(session: session, path: path, placement: placement) + + case let .canvasHide(session): + return await self.handleCanvasHide(session: session) + + case let .canvasGoto(session, path, placement): + return await self.handleCanvasGoto(session: session, path: path, placement: placement) + + case let .canvasEval(session, javaScript): + return await self.handleCanvasEval(session: session, javaScript: javaScript) + + case let .canvasSnapshot(session, outPath): + return await self.handleCanvasSnapshot(session: session, outPath: outPath) + + case .nodeList: + return await self.handleNodeList() + + case let .nodeInvoke(nodeId, command, paramsJSON): + return await self.handleNodeInvoke( + nodeId: nodeId, + command: command, + paramsJSON: paramsJSON, + logger: logger + ) + } + } + + private struct NotifyRequest { + var title: String + var body: String + var sound: String? + var priority: NotificationPriority? + var delivery: NotificationDelivery? + } + + private static func handleNotify(_ request: NotifyRequest, notifier: NotificationManager) async -> Response { + let chosenSound = request.sound?.trimmingCharacters(in: .whitespacesAndNewlines) + let chosenDelivery = request.delivery ?? .system + + switch chosenDelivery { + case .system: + let ok = await notifier.send( + title: request.title, + body: request.body, + sound: chosenSound, + priority: request.priority + ) + return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized") + case .overlay: + await MainActor.run { + NotifyOverlayController.shared.present(title: request.title, body: request.body) } - - case let .ensurePermissions(caps, interactive): - let statuses = await PermissionManager.ensure(caps, interactive: interactive) - let missing = statuses.filter { !$0.value }.map(\.key.rawValue) - let ok = missing.isEmpty - let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))" - return Response(ok: ok, message: msg) - - case .status: - return paused ? Response(ok: false, message: "clawdis paused") : Response(ok: true, message: "ready") - - case .rpcStatus: - let result = await AgentRPC.shared.status() - return Response(ok: result.ok, message: result.error) - - case let .runShell(command, cwd, env, timeoutSec, needsSR): - if needsSR { - let authorized = await PermissionManager - .ensure([.screenRecording], interactive: false)[.screenRecording] ?? false - guard authorized else { return Response(ok: false, message: "screen recording permission missing") } - } - return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec) - - case let .agent(message, thinking, session, deliver, to): - let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") } - let sessionKey = session ?? "main" - let rpcResult = await AgentRPC.shared.send( - text: trimmed, - thinking: thinking, - sessionKey: sessionKey, - deliver: deliver, - to: to, - channel: nil) - return rpcResult.ok - ? Response(ok: true, message: rpcResult.text ?? "sent") - : Response(ok: false, message: rpcResult.error ?? "failed to send") - - case let .canvasShow(session, path, placement): - let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - guard canvasEnabled else { - return Response(ok: false, message: "Canvas disabled by user") - } - do { - let dir = try await MainActor.run { try CanvasManager.shared.show( - sessionKey: session, - path: path, - placement: placement) } - return Response(ok: true, message: dir) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - - case let .canvasHide(session): - await MainActor.run { CanvasManager.shared.hide(sessionKey: session) } return Response(ok: true) + case .auto: + let ok = await notifier.send( + title: request.title, + body: request.body, + sound: chosenSound, + priority: request.priority + ) + if ok { return Response(ok: true) } + await MainActor.run { + NotifyOverlayController.shared.present(title: request.title, body: request.body) + } + return Response(ok: true, message: "notification not authorized; used overlay") + } + } - case let .canvasGoto(session, path, placement): - let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - guard canvasEnabled else { - return Response(ok: false, message: "Canvas disabled by user") - } - do { - try await MainActor.run { try CanvasManager.shared.goto( - sessionKey: session, - path: path, - placement: placement) } - return Response(ok: true) - } catch { - return Response(ok: false, message: error.localizedDescription) - } + private static func handleEnsurePermissions(caps: [Capability], interactive: Bool) async -> Response { + let statuses = await PermissionManager.ensure(caps, interactive: interactive) + let missing = statuses.filter { !$0.value }.map(\.key.rawValue) + let ok = missing.isEmpty + let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))" + return Response(ok: ok, message: msg) + } - case let .canvasEval(session, javaScript): - let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - guard canvasEnabled else { - return Response(ok: false, message: "Canvas disabled by user") - } - do { - let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript) - return Response(ok: true, payload: Data(result.utf8)) - } catch { - return Response(ok: false, message: error.localizedDescription) - } + private static func handleRPCStatus() async -> Response { + let result = await AgentRPC.shared.status() + return Response(ok: result.ok, message: result.error) + } - case let .canvasSnapshot(session, outPath): - let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - guard canvasEnabled else { - return Response(ok: false, message: "Canvas disabled by user") - } - do { - let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath) - return Response(ok: true, message: path) - } catch { - return Response(ok: false, message: error.localizedDescription) - } + private static func handleRunShell( + command: [String], + cwd: String?, + env: [String: String]?, + timeoutSec: Double?, + needsSR: Bool + ) async -> Response { + if needsSR { + let authorized = await PermissionManager + .ensure([.screenRecording], interactive: false)[.screenRecording] ?? false + guard authorized else { return Response(ok: false, message: "screen recording permission missing") } + } + return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec) + } - case .nodeList: - let ids = await BridgeServer.shared.connectedNodeIds() - let payload = (try? JSONSerialization.data( - withJSONObject: ["connectedNodeIds": ids], - options: [.prettyPrinted])) - .flatMap { String(data: $0, encoding: .utf8) } - ?? "{}" - return Response(ok: true, payload: Data(payload.utf8)) + private static func handleAgent( + message: String, + thinking: String?, + session: String?, + deliver: Bool, + to: String? + ) async -> Response { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") } + let sessionKey = session ?? "main" + let rpcResult = await AgentRPC.shared.send( + text: trimmed, + thinking: thinking, + sessionKey: sessionKey, + deliver: deliver, + to: to, + channel: nil + ) + return rpcResult.ok + ? Response(ok: true, message: rpcResult.text ?? "sent") + : Response(ok: false, message: rpcResult.error ?? "failed to send") + } - case let .nodeInvoke(nodeId, command, paramsJSON): - do { - let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON) - if res.ok { - let payload = res.payloadJSON ?? "" - return Response(ok: true, payload: Data(payload.utf8)) - } - let errText = res.error?.message ?? "node invoke failed" - return Response(ok: false, message: errText) - } catch { - return Response(ok: false, message: error.localizedDescription) + private static func canvasEnabled() -> Bool { + UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true + } + + private static func handleCanvasShow( + session: String, + path: String?, + placement: CanvasPlacement? + ) async -> Response { + guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } + do { + let dir = try await MainActor.run { + try CanvasManager.shared.show(sessionKey: session, path: path, placement: placement) } + return Response(ok: true, message: dir) + } catch { + return Response(ok: false, message: error.localizedDescription) + } + } + + private static func handleCanvasHide(session: String) async -> Response { + await MainActor.run { CanvasManager.shared.hide(sessionKey: session) } + return Response(ok: true) + } + + private static func handleCanvasGoto(session: String, path: String, placement: CanvasPlacement?) async -> Response { + guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } + do { + try await MainActor.run { + try CanvasManager.shared.goto(sessionKey: session, path: path, placement: placement) + } + return Response(ok: true) + } catch { + return Response(ok: false, message: error.localizedDescription) + } + } + + private static func handleCanvasEval(session: String, javaScript: String) async -> Response { + guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } + do { + let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript) + return Response(ok: true, payload: Data(result.utf8)) + } catch { + return Response(ok: false, message: error.localizedDescription) + } + } + + private static func handleCanvasSnapshot(session: String, outPath: String?) async -> Response { + guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } + do { + let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath) + return Response(ok: true, message: path) + } catch { + return Response(ok: false, message: error.localizedDescription) + } + } + + private static func handleNodeList() async -> Response { + let ids = await BridgeServer.shared.connectedNodeIds() + let payload = (try? JSONSerialization.data( + withJSONObject: ["connectedNodeIds": ids], + options: [.prettyPrinted] + )) + .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + return Response(ok: true, payload: Data(payload.utf8)) + } + + private static func handleNodeInvoke( + nodeId: String, + command: String, + paramsJSON: String?, + logger: Logger + ) async -> Response { + do { + let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON) + if res.ok { + let payload = res.payloadJSON ?? "" + return Response(ok: true, payload: Data(payload.utf8)) + } + let errText = res.error?.message ?? "node invoke failed" + return Response(ok: false, message: errText) + } catch { + logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)") + return Response(ok: false, message: error.localizedDescription) } } } diff --git a/apps/macos/Sources/Clawdis/ControlSocketServer.swift b/apps/macos/Sources/Clawdis/ControlSocketServer.swift index e72d539af..c13a4c742 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, privacy: .public) due to 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 3384eda64..ce0dfe315 100644 --- a/apps/macos/Sources/Clawdis/CronSettings.swift +++ b/apps/macos/Sources/Clawdis/CronSettings.swift @@ -69,11 +69,13 @@ 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()) @@ -526,7 +528,8 @@ private struct CronJobEditor: View { 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.") + "Create a schedule that wakes clawd via the Gateway. Use an isolated session for agent turns so your main chat stays clean." + ) .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -572,7 +575,8 @@ 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).") + "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)." + ) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -597,7 +601,8 @@ 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.") + "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." + ) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -642,7 +647,8 @@ private struct CronJobEditor: View { 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.") + "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) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -663,7 +669,8 @@ 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.") + "System events are injected into the current main session. Agent turns require an isolated session target." + ) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -696,7 +703,8 @@ 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.") + "Controls the label used when posting the completion summary back to the main session." + ) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -906,13 +914,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 f198565ca..133754a4d 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -141,14 +141,17 @@ 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) @@ -229,15 +232,17 @@ 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() } @@ -480,11 +485,13 @@ 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) @@ -580,28 +587,18 @@ 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.") - } - } - } - } - - 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) - } - } + 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 { @@ -755,12 +752,14 @@ 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 @MainActor @@ -796,12 +795,17 @@ struct DebugSettings: View { body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; } .wrap { padding:16px; } .row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; } - .pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.12); } - button { background:#22c55e; color:#04110a; border:0; border-radius:10px; padding:8px 10px; font-weight:700; cursor:pointer; } + .pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08); + border:1px solid rgba(255,255,255,.12); } + button { background:#22c55e; color:#04110a; border:0; border-radius:10px; + padding:8px 10px; font-weight:700; cursor:pointer; } button:active { transform: translateY(1px); } - .panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.1); } + .panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06); + border:1px solid rgba(255,255,255,.1); } .grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; margin-top:12px; } - .box { grid-column: span 4; height:80px; border-radius:14px; background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25)); border:1px solid rgba(255,255,255,.12); } + .box { grid-column: span 4; height:80px; border-radius:14px; + background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25)); + border:1px solid rgba(255,255,255,.12); } .muted { color: rgba(229,231,235,.7); } @@ -850,7 +854,8 @@ struct DebugSettings: View { 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 @@ -865,7 +870,8 @@ struct DebugSettings: View { 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 @@ -873,10 +879,22 @@ struct DebugSettings: View { } } -#if DEBUG -struct DebugSettings_Previews: PreviewProvider { - static var previews: some View { - 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) + } + } + + #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 63137df82..e56ff9f40 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -125,15 +125,18 @@ actor GatewayEndpointStore { for (_, continuation) in self.subscribers { continuation.yield(next) } - switch next { - case let .ready(mode, url, _): - self.logger - .debug( - "resolved endpoint mode=\(String(describing: mode), privacy: .public) url=\(url.absoluteString, privacy: .public)") - case let .unavailable(mode, reason): - self.logger - .debug( - "endpoint unavailable mode=\(String(describing: mode), 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 5125570f5..07b404b16 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -1,14 +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 - @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? @@ -576,11 +577,12 @@ extension GeneralSettings { alert.runModal() } - 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/MasterDiscoveryMenu.swift b/apps/macos/Sources/Clawdis/MasterDiscoveryMenu.swift index d8de55ea7..887713652 100644 --- a/apps/macos/Sources/Clawdis/MasterDiscoveryMenu.swift +++ b/apps/macos/Sources/Clawdis/MasterDiscoveryMenu.swift @@ -1,5 +1,7 @@ import SwiftUI +// “master” is part of the discovery protocol naming; keep UI components consistent. +// swiftlint:disable:next inclusive_language struct MasterDiscoveryInlineList: View { @ObservedObject var discovery: MasterDiscoveryModel var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void @@ -50,6 +52,7 @@ struct MasterDiscoveryInlineList: View { } } +// swiftlint:disable:next inclusive_language struct MasterDiscoveryMenu: View { @ObservedObject var discovery: MasterDiscoveryModel var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void diff --git a/apps/macos/Sources/Clawdis/MasterDiscoveryModel.swift b/apps/macos/Sources/Clawdis/MasterDiscoveryModel.swift index 4960fe7e2..561157f7e 100644 --- a/apps/macos/Sources/Clawdis/MasterDiscoveryModel.swift +++ b/apps/macos/Sources/Clawdis/MasterDiscoveryModel.swift @@ -1,8 +1,11 @@ import Foundation import Network +// We use “master” as the on-the-wire service name; keep the model aligned with the protocol/docs. @MainActor +// swiftlint:disable:next inclusive_language final class MasterDiscoveryModel: ObservableObject { + // swiftlint:disable:next inclusive_language struct DiscoveredMaster: Identifiable, Equatable { var id: String { self.debugID } var displayName: String @@ -12,6 +15,7 @@ final class MasterDiscoveryModel: ObservableObject { var debugID: String } + // swiftlint:disable:next inclusive_language @Published var masters: [DiscoveredMaster] = [] @Published var statusText: String = "Idle" diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 3d894491f..e693c858f 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -110,9 +110,8 @@ struct MenuContent: View { await self.reloadSessionMenu() } } label: { - Label( - level.capitalized, - systemImage: row.thinkingLevel == normalized ? "checkmark" : "") + let checkmark = row.thinkingLevel == normalized ? "checkmark" : "" + Label(level.capitalized, systemImage: checkmark) } } } @@ -128,9 +127,8 @@ struct MenuContent: View { await self.reloadSessionMenu() } } label: { - Label( - level.capitalized, - systemImage: row.verboseLevel == normalized ? "checkmark" : "") + let checkmark = row.verboseLevel == normalized ? "checkmark" : "" + Label(level.capitalized, systemImage: checkmark) } } } diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 3b0f3bbc6..fd2314811 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -45,15 +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? - @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 @@ -115,15 +116,17 @@ 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) @@ -138,12 +141,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) @@ -155,15 +162,17 @@ 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) @@ -291,23 +300,26 @@ 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) } } } } } - 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)" } @@ -448,12 +460,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.", @@ -478,10 +491,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 517825aeb..657a68d4b 100644 --- a/apps/macos/Sources/Clawdis/PermissionManager.swift +++ b/apps/macos/Sources/Clawdis/PermissionManager.swift @@ -9,101 +9,109 @@ import Speech import UserNotifications enum PermissionManager { - static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { - var results: [Capability: Bool] = [:] - for cap in caps { - switch cap { - case .notifications: - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() + 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 + } - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - results[cap] = true + 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) + } + } - case .notDetermined: - if interactive { - let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? - false - let updated = await center.notificationSettings() - results[cap] = granted && (updated.authorizationStatus == .authorized || updated - .authorizationStatus == .provisional) - } else { - results[cap] = false - } + private static func ensureNotifications(interactive: Bool) async -> Bool { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() - case .denied: - results[cap] = false - if interactive { - NotificationPermissionHelper.openSettings() - } + 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 + } + } - @unknown default: - results[cap] = 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() } + } - case .appleScript: - let granted = await MainActor.run { AppleScriptPermission.isAuthorized() } - if interactive, !granted { - await AppleScriptPermission.requestAuthorization() - } - results[cap] = 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() } + } - case .accessibility: - let trusted = await MainActor.run { AXIsProcessTrusted() } - results[cap] = trusted - if interactive, !trusted { - await MainActor.run { - let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] - _ = AXIsProcessTrustedWithOptions(opts) - } - } + private static func ensureScreenRecording(interactive: Bool) async -> Bool { + let granted = ScreenRecordingProbe.isAuthorized() + if interactive, !granted { + await ScreenRecordingProbe.requestAuthorization() + } + return ScreenRecordingProbe.isAuthorized() + } - case .screenRecording: - let granted = ScreenRecordingProbe.isAuthorized() - if interactive, !granted { - await ScreenRecordingProbe.requestAuthorization() - } - results[cap] = 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 + } + } - case .microphone: - let status = AVCaptureDevice.authorizationStatus(for: .audio) - switch status { - case .authorized: - results[cap] = true - - case .notDetermined: - if interactive { - let ok = await AVCaptureDevice.requestAccess(for: .audio) - results[cap] = ok - } else { - results[cap] = false - } - - case .denied, .restricted: - results[cap] = false - if interactive { - MicrophonePermissionHelper.openSettings() - } - - @unknown default: - results[cap] = false - } - - case .speechRecognition: - let status = SFSpeechRecognizer.authorizationStatus() - if status == .notDetermined, interactive { - await withUnsafeContinuation { (cont: UnsafeContinuation) in - SFSpeechRecognizer.requestAuthorization { _ in - DispatchQueue.main.async { cont.resume() } - } - } - } - results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized - } - } - return results - } + 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/WebChatServer.swift b/apps/macos/Sources/Clawdis/WebChatServer.swift index be22dca26..280062f65 100644 --- a/apps/macos/Sources/Clawdis/WebChatServer.swift +++ b/apps/macos/Sources/Clawdis/WebChatServer.swift @@ -186,39 +186,37 @@ final class WebChatServer: @unchecked Sendable { over: connection) return } - guard let data = try? Data(contentsOf: fileURL) else { - webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)") - self.send( - status: 404, - mime: "text/plain", - body: Data("Not Found".utf8), - contentLength: "Not Found".utf8.count, - includeBody: includeBody, - over: connection) - return - } - let mime = self.mimeType(forExtension: fileURL.pathExtension) - self.send( - status: 200, - mime: mime, - body: data, - contentLength: data.count, - includeBody: includeBody, - over: connection) - } + guard let data = try? Data(contentsOf: fileURL) else { + webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)") + self.send( + status: 404, + mime: "text/plain", + body: Data("Not Found".utf8), + includeBody: includeBody, + over: connection) + return + } + let mime = self.mimeType(forExtension: fileURL.pathExtension) + self.send( + status: 200, + mime: mime, + body: data, + includeBody: includeBody, + over: connection) + } - private func send( - status: Int, - mime: String, - body: Data, - contentLength: Int, - includeBody: Bool, - over connection: NWConnection) - { - let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" + - "Content-Length: \(contentLength)\r\n" + - "Content-Type: \(mime)\r\n" + - "Connection: close\r\n\r\n" + private func send( + status: Int, + mime: String, + body: Data, + includeBody: Bool, + over connection: NWConnection) + { + let contentLength = body.count + let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" + + "Content-Length: \(contentLength)\r\n" + + "Content-Type: \(mime)\r\n" + + "Connection: close\r\n\r\n" var response = Data(headers.utf8) if includeBody { response.append(body) diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index 27cc465df..81aaaa635 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -163,8 +163,9 @@ final class WebChatViewModel: ObservableObject { do { let data = try await Task.detached { try Data(contentsOf: url) }.value guard data.count <= 5_000_000 else { - await MainActor - .run { self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit" } + await MainActor.run { + self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit" + } continue } let uti = UTType(filenameExtension: url.pathExtension) ?? .data @@ -447,8 +448,11 @@ struct WebChatView: View { .foregroundStyle(Color.accentColor.opacity(0.9)) Text("Say hi to Clawd") .font(.headline) - Text(self.viewModel - .healthOK ? "This is the native SwiftUI debug chat." : "Connecting to the gateway…") + Text( + self.viewModel.healthOK + ? "This is the native SwiftUI debug chat." + : "Connecting to the gateway…" + ) .font(.subheadline) .foregroundStyle(.secondary) } @@ -460,10 +464,9 @@ struct WebChatView: View { .padding(.vertical, 34) } else { ForEach(self.viewModel.messages) { msg in + let alignment: Alignment = msg.role.lowercased() == "user" ? .trailing : .leading MessageBubble(message: msg) - .frame( - maxWidth: .infinity, - alignment: msg.role.lowercased() == "user" ? .trailing : .leading) + .frame(maxWidth: .infinity, alignment: alignment) } } diff --git a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift index 38e292027..eadb69f62 100644 --- a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift @@ -6,62 +6,17 @@ enum BrowserCLI { static func run(args: [String], jsonOutput: Bool) async throws -> Int32 { var args = args - guard let sub = args.first else { + guard let sub = args.popFirst() else { self.printHelp() return 0 } - args = Array(args.dropFirst()) if sub == "--help" || sub == "-h" || sub == "help" { self.printHelp() return 0 } - var overrideURL: String? - var fullPage = false - var targetId: String? - var awaitPromise = false - var js: String? - var jsFile: String? - var jsStdin = false - var selector: String? - var format: String? - var limit: Int? - var maxChars: Int? - var outPath: String? - var rest: [String] = [] - - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--url": - overrideURL = args.popFirst() - case "--full-page": - fullPage = true - case "--target-id": - targetId = args.popFirst() - case "--await": - awaitPromise = true - case "--js": - js = args.popFirst() - case "--js-file": - jsFile = args.popFirst() - case "--js-stdin": - jsStdin = true - case "--selector": - selector = args.popFirst() - case "--format": - format = args.popFirst() - case "--limit": - limit = args.popFirst().flatMap(Int.init) - case "--max-chars": - maxChars = args.popFirst().flatMap(Int.init) - case "--out": - outPath = args.popFirst() - default: - rest.append(arg) - } - } + let options = self.parseOptions(args: args) let cfg = self.loadBrowserConfig() guard cfg.enabled else { @@ -73,7 +28,7 @@ enum BrowserCLI { return 1 } - let base = (overrideURL ?? cfg.controlUrl).trimmingCharacters(in: .whitespacesAndNewlines) + let base = (options.overrideURL ?? cfg.controlUrl).trimmingCharacters(in: .whitespacesAndNewlines) guard let baseURL = URL(string: base) else { throw NSError(domain: "BrowserCLI", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Invalid browser control URL: \(base)", @@ -81,237 +36,7 @@ enum BrowserCLI { } do { - switch sub { - case "status": - try await self.printResult( - jsonOutput: jsonOutput, - res: self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/"))) - return 0 - - case "start": - try await self.printResult( - jsonOutput: jsonOutput, - res: self.httpJSON( - method: "POST", - url: baseURL.appendingPathComponent("/start"), - timeoutInterval: 15.0)) - return 0 - - case "stop": - try await self.printResult( - jsonOutput: jsonOutput, - res: self.httpJSON( - method: "POST", - url: baseURL.appendingPathComponent("/stop"), - timeoutInterval: 15.0)) - return 0 - - case "tabs": - let res = try await self.httpJSON( - method: "GET", - url: baseURL.appendingPathComponent("/tabs"), - timeoutInterval: 3.0) - if jsonOutput { - self.printJSON(ok: true, result: res) - } else { - self.printTabs(res: res) - } - return 0 - - case "open": - guard let url = rest.first, !url.isEmpty else { - self.printHelp() - return 2 - } - try await self.printResult( - jsonOutput: jsonOutput, - res: self.httpJSON( - method: "POST", - url: baseURL.appendingPathComponent("/tabs/open"), - body: ["url": url], - timeoutInterval: 15.0)) - return 0 - - case "focus": - guard let id = rest.first, !id.isEmpty else { - self.printHelp() - return 2 - } - try await self.printResult( - jsonOutput: jsonOutput, - res: self.httpJSON( - method: "POST", - url: baseURL.appendingPathComponent("/tabs/focus"), - body: ["targetId": id], - timeoutInterval: 5.0)) - return 0 - - case "close": - guard let id = rest.first, !id.isEmpty else { - self.printHelp() - return 2 - } - try await self.printResult( - jsonOutput: jsonOutput, - res: self.httpJSON( - method: "DELETE", - url: baseURL.appendingPathComponent("/tabs/\(id)"), - timeoutInterval: 5.0)) - return 0 - - case "screenshot": - var url = baseURL.appendingPathComponent("/screenshot") - var items: [URLQueryItem] = [] - if let targetId, !targetId.isEmpty { - items.append(URLQueryItem(name: "targetId", value: targetId)) - } - if fullPage { - items.append(URLQueryItem(name: "fullPage", value: "1")) - } - if !items.isEmpty { - url = self.withQuery(url, items: items) - } - let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) - if jsonOutput { - self.printJSON(ok: true, result: res) - } else if let path = res["path"] as? String, !path.isEmpty { - print("MEDIA:\(path)") - } else { - self.printResult(jsonOutput: false, res: res) - } - return 0 - - case "eval": - if jsStdin, jsFile != nil { - self.printHelp() - return 2 - } - - let code: String = try { - if let jsFile, !jsFile.isEmpty { - return try String(contentsOfFile: jsFile, encoding: .utf8) - } - if jsStdin { - let data = FileHandle.standardInput.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - if let js, !js.isEmpty { return js } - if !rest.isEmpty { return rest.joined(separator: " ") } - return "" - }() - - if code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - self.printHelp() - return 2 - } - - let res = try await self.httpJSON( - method: "POST", - url: baseURL.appendingPathComponent("/eval"), - body: [ - "js": code, - "targetId": targetId ?? "", - "await": awaitPromise, - ], - timeoutInterval: 15.0) - - if jsonOutput { - self.printJSON(ok: true, result: res) - } else { - self.printEval(res: res) - } - return 0 - - case "query": - let sel = (selector ?? rest.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if sel.isEmpty { - self.printHelp() - return 2 - } - var url = baseURL.appendingPathComponent("/query") - var items: [URLQueryItem] = [URLQueryItem(name: "selector", value: sel)] - if let targetId, !targetId.isEmpty { - items.append(URLQueryItem(name: "targetId", value: targetId)) - } - if let limit, limit > 0 { - items.append(URLQueryItem(name: "limit", value: String(limit))) - } - url = self.withQuery(url, items: items) - let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 15.0) - if jsonOutput || format == "json" { - self.printJSON(ok: true, result: res) - } else { - self.printQuery(res: res) - } - return 0 - - case "dom": - let fmt = (format == "text") ? "text" : "html" - var url = baseURL.appendingPathComponent("/dom") - var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)] - if let targetId, !targetId.isEmpty { - items.append(URLQueryItem(name: "targetId", value: targetId)) - } - if let selector = selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty { - items.append(URLQueryItem(name: "selector", value: selector)) - } - if let maxChars, maxChars > 0 { - items.append(URLQueryItem(name: "maxChars", value: String(maxChars))) - } - url = self.withQuery(url, items: items) - let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) - let text = (res["text"] as? String) ?? "" - if let out = outPath, !out.isEmpty { - try Data(text.utf8).write(to: URL(fileURLWithPath: out)) - if jsonOutput { - self.printJSON(ok: true, result: ["ok": true, "out": out]) - } else { - print(out) - } - return 0 - } - if jsonOutput { - self.printJSON(ok: true, result: res) - } else { - print(text) - } - return 0 - - case "snapshot": - let fmt = (format == "domSnapshot") ? "domSnapshot" : "aria" - var url = baseURL.appendingPathComponent("/snapshot") - var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)] - if let targetId, !targetId.isEmpty { - items.append(URLQueryItem(name: "targetId", value: targetId)) - } - if let limit, limit > 0 { - items.append(URLQueryItem(name: "limit", value: String(limit))) - } - url = self.withQuery(url, items: items) - let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) - - if let out = outPath, !out.isEmpty { - let data = try JSONSerialization.data(withJSONObject: res, options: [.prettyPrinted]) - try data.write(to: URL(fileURLWithPath: out)) - if jsonOutput { - self.printJSON(ok: true, result: ["ok": true, "out": out]) - } else { - print(out) - } - return 0 - } - - if jsonOutput || fmt == "domSnapshot" { - self.printJSON(ok: true, result: res) - } else { - self.printSnapshotAria(res: res) - } - return 0 - - default: - self.printHelp() - return 2 - } + return try await self.runCommand(sub: sub, options: options, baseURL: baseURL, jsonOutput: jsonOutput) } catch { let msg = self.describeError(error, baseURL: baseURL) if jsonOutput { @@ -323,6 +48,329 @@ enum BrowserCLI { } } + private struct RunOptions { + var overrideURL: String? + var fullPage: Bool = false + var targetId: String? + var awaitPromise: Bool = false + var js: String? + var jsFile: String? + var jsStdin: Bool = false + var selector: String? + var format: String? + var limit: Int? + var maxChars: Int? + var outPath: String? + var rest: [String] = [] + } + + private static func parseOptions(args: [String]) -> RunOptions { + var args = args + var options = RunOptions() + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--url": + options.overrideURL = args.popFirst() + case "--full-page": + options.fullPage = true + case "--target-id": + options.targetId = args.popFirst() + case "--await": + options.awaitPromise = true + case "--js": + options.js = args.popFirst() + case "--js-file": + options.jsFile = args.popFirst() + case "--js-stdin": + options.jsStdin = true + case "--selector": + options.selector = args.popFirst() + case "--format": + options.format = args.popFirst() + case "--limit": + options.limit = args.popFirst().flatMap(Int.init) + case "--max-chars": + options.maxChars = args.popFirst().flatMap(Int.init) + case "--out": + options.outPath = args.popFirst() + default: + options.rest.append(arg) + } + } + return options + } + + private static func runCommand( + sub: String, + options: RunOptions, + baseURL: URL, + jsonOutput: Bool + ) async throws -> Int32 { + switch sub { + case "status": + return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput) + case "start": + return try await self.handleStartStop(action: "start", baseURL: baseURL, jsonOutput: jsonOutput) + case "stop": + return try await self.handleStartStop(action: "stop", baseURL: baseURL, jsonOutput: jsonOutput) + case "tabs": + return try await self.handleTabs(baseURL: baseURL, jsonOutput: jsonOutput) + case "open": + return try await self.handleOpen(baseURL: baseURL, jsonOutput: jsonOutput, options: options) + case "focus": + return try await self.handleFocus(baseURL: baseURL, jsonOutput: jsonOutput, options: options) + case "close": + return try await self.handleClose(baseURL: baseURL, jsonOutput: jsonOutput, options: options) + case "screenshot": + return try await self.handleScreenshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options) + case "eval": + return try await self.handleEval(baseURL: baseURL, jsonOutput: jsonOutput, options: options) + case "query": + return try await self.handleQuery(baseURL: baseURL, jsonOutput: jsonOutput, options: options) + case "dom": + return try await self.handleDOM(baseURL: baseURL, jsonOutput: jsonOutput, options: options) + case "snapshot": + return try await self.handleSnapshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options) + default: + self.printHelp() + return 2 + } + } + + private static func handleStatus(baseURL: URL, jsonOutput: Bool) async throws -> Int32 { + let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/")) + self.printResult(jsonOutput: jsonOutput, res: res) + return 0 + } + + private static func handleStartStop(action: String, baseURL: URL, jsonOutput: Bool) async throws -> Int32 { + let url = baseURL.appendingPathComponent("/\(action)") + let res = try await self.httpJSON(method: "POST", url: url, timeoutInterval: 15.0) + self.printResult(jsonOutput: jsonOutput, res: res) + return 0 + } + + private static func handleTabs(baseURL: URL, jsonOutput: Bool) async throws -> Int32 { + let url = baseURL.appendingPathComponent("/tabs") + let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 3.0) + if jsonOutput { + self.printJSON(ok: true, result: res) + } else { + self.printTabs(res: res) + } + return 0 + } + + private static func handleOpen(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { + guard let urlString = options.rest.first, !urlString.isEmpty else { + self.printHelp() + return 2 + } + let url = baseURL.appendingPathComponent("/tabs/open") + let res = try await self.httpJSON( + method: "POST", + url: url, + body: ["url": urlString], + timeoutInterval: 15.0 + ) + self.printResult(jsonOutput: jsonOutput, res: res) + return 0 + } + + private static func handleFocus(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { + guard let id = options.rest.first, !id.isEmpty else { + self.printHelp() + return 2 + } + let url = baseURL.appendingPathComponent("/tabs/focus") + let res = try await self.httpJSON( + method: "POST", + url: url, + body: ["targetId": id], + timeoutInterval: 5.0 + ) + self.printResult(jsonOutput: jsonOutput, res: res) + return 0 + } + + private static func handleClose(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { + guard let id = options.rest.first, !id.isEmpty else { + self.printHelp() + return 2 + } + let url = baseURL.appendingPathComponent("/tabs/\(id)") + let res = try await self.httpJSON(method: "DELETE", url: url, timeoutInterval: 5.0) + self.printResult(jsonOutput: jsonOutput, res: res) + return 0 + } + + private static func handleScreenshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { + var url = baseURL.appendingPathComponent("/screenshot") + var items: [URLQueryItem] = [] + if let targetId = options.targetId, !targetId.isEmpty { + items.append(URLQueryItem(name: "targetId", value: targetId)) + } + if options.fullPage { + items.append(URLQueryItem(name: "fullPage", value: "1")) + } + if !items.isEmpty { + url = self.withQuery(url, items: items) + } + + let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) + if jsonOutput { + self.printJSON(ok: true, result: res) + } else if let path = res["path"] as? String, !path.isEmpty { + print("MEDIA:\(path)") + } else { + self.printResult(jsonOutput: false, res: res) + } + return 0 + } + + private static func handleEval(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { + if options.jsStdin, options.jsFile != nil { + self.printHelp() + return 2 + } + + let code = try self.resolveEvalCode(options: options) + if code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.printHelp() + return 2 + } + + let url = baseURL.appendingPathComponent("/eval") + let res = try await self.httpJSON( + method: "POST", + url: url, + body: [ + "js": code, + "targetId": options.targetId ?? "", + "await": options.awaitPromise, + ], + timeoutInterval: 15.0 + ) + + if jsonOutput { + self.printJSON(ok: true, result: res) + } else { + self.printEval(res: res) + } + return 0 + } + + private static func resolveEvalCode(options: RunOptions) throws -> String { + if let jsFile = options.jsFile, !jsFile.isEmpty { + return try String(contentsOfFile: jsFile, encoding: .utf8) + } + if options.jsStdin { + let data = FileHandle.standardInput.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } + if let js = options.js, !js.isEmpty { + return js + } + if !options.rest.isEmpty { + return options.rest.joined(separator: " ") + } + return "" + } + + private static func handleQuery(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { + let sel = (options.selector ?? options.rest.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if sel.isEmpty { + self.printHelp() + return 2 + } + + var url = baseURL.appendingPathComponent("/query") + var items: [URLQueryItem] = [URLQueryItem(name: "selector", value: sel)] + if let targetId = options.targetId, !targetId.isEmpty { + items.append(URLQueryItem(name: "targetId", value: targetId)) + } + if let limit = options.limit, limit > 0 { + items.append(URLQueryItem(name: "limit", value: String(limit))) + } + url = self.withQuery(url, items: items) + + let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 15.0) + if jsonOutput || options.format == "json" { + self.printJSON(ok: true, result: res) + } else { + self.printQuery(res: res) + } + return 0 + } + + private static func handleDOM(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { + let fmt = (options.format == "text") ? "text" : "html" + var url = baseURL.appendingPathComponent("/dom") + var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)] + if let targetId = options.targetId, !targetId.isEmpty { + items.append(URLQueryItem(name: "targetId", value: targetId)) + } + if let selector = options.selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty { + items.append(URLQueryItem(name: "selector", value: selector)) + } + if let maxChars = options.maxChars, maxChars > 0 { + items.append(URLQueryItem(name: "maxChars", value: String(maxChars))) + } + url = self.withQuery(url, items: items) + + let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) + let text = (res["text"] as? String) ?? "" + if let out = options.outPath, !out.isEmpty { + try Data(text.utf8).write(to: URL(fileURLWithPath: out)) + if jsonOutput { + self.printJSON(ok: true, result: ["ok": true, "out": out]) + } else { + print(out) + } + return 0 + } + + if jsonOutput { + self.printJSON(ok: true, result: res) + } else { + print(text) + } + return 0 + } + + private static func handleSnapshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { + let fmt = (options.format == "domSnapshot") ? "domSnapshot" : "aria" + var url = baseURL.appendingPathComponent("/snapshot") + var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)] + if let targetId = options.targetId, !targetId.isEmpty { + items.append(URLQueryItem(name: "targetId", value: targetId)) + } + if let limit = options.limit, limit > 0 { + items.append(URLQueryItem(name: "limit", value: String(limit))) + } + url = self.withQuery(url, items: items) + + let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) + if let out = options.outPath, !out.isEmpty { + let data = try JSONSerialization.data(withJSONObject: res, options: [.prettyPrinted]) + try data.write(to: URL(fileURLWithPath: out)) + if jsonOutput { + self.printJSON(ok: true, result: ["ok": true, "out": out]) + } else { + print(out) + } + return 0 + } + + if jsonOutput || fmt == "domSnapshot" { + self.printJSON(ok: true, result: res) + } else { + self.printSnapshotAria(res: res) + } + return 0 + } + private struct BrowserConfig { let enabled: Bool let controlUrl: String diff --git a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift index 3a2e82640..94eee3f48 100644 --- a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift @@ -58,261 +58,276 @@ struct ClawdisCLI { enum Kind { case generic } - } + } - // swiftlint:disable cyclomatic_complexity - private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest { - var args = args - guard let command = args.first else { throw CLIError.help } - args = Array(args.dropFirst()) + 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": - 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) + case "notify": + return try self.parseNotify(args: &args) - case "ensure-permissions": - 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) + case "ensure-permissions": + return self.parseEnsurePermissions(args: &args) - case "run": - 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 "run": + return self.parseRunShell(args: &args) - case "--env": - if let pair = args.popFirst(), let eq = pair.firstIndex(of: "=") { - let k = String(pair[.. 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 + ) + } - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--message": message = args.popFirst() - case "--thinking": thinking = args.popFirst() - case "--session": session = args.popFirst() - case "--deliver": deliver = true - case "--to": to = args.popFirst() - default: - // Support bare message as last argument - if message == nil { - message = arg - } - } - } + 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) + } - guard let message else { throw CLIError.help } - return ParsedCLIRequest( - request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to), - 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 + ) + } - case "node": - guard let sub = args.first else { throw CLIError.help } - args = Array(args.dropFirst()) + private static func parseEnvPair(_ pair: String, into env: inout [String: String]) { + guard let eq = pair.firstIndex(of: "=") else { return } + let key = String(pair[.. ParsedCLIRequest { + var message: String? + var thinking: String? + var session: String? + var deliver = false + var to: String? - 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) + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--message": message = args.popFirst() + case "--thinking": thinking = args.popFirst() + case "--session": session = args.popFirst() + case "--deliver": deliver = true + case "--to": to = args.popFirst() + default: + if message == nil { + message = arg + } + } + } - default: - throw CLIError.help - } + guard let message else { throw CLIError.help } + return ParsedCLIRequest( + request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to), + kind: .generic + ) + } - case "canvas": - guard let sub = args.first else { throw CLIError.help } - args = Array(args.dropFirst()) + 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 + } + } - switch sub { - case "show": - var session = "main" - var path: String? - 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 - } - } - let placement = (x != nil || y != nil || width != nil || height != nil) - ? CanvasPlacement(x: x, y: y, width: width, height: height) - : nil - return ParsedCLIRequest( - request: .canvasShow(session: session, path: path, placement: placement), - kind: .generic) + 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 + } + } - 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? - 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 - } - } - guard let path else { throw CLIError.help } - let placement = (x != nil || y != nil || width != nil || height != nil) - ? CanvasPlacement(x: x, y: y, width: width, height: height) - : nil - 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 - } - - default: - throw CLIError.help - } - } - - // swiftlint:enable cyclomatic_complexity + 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 { @@ -491,13 +506,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) } - let path = String(decoding: bytes, as: UTF8.self) - 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 0e135ae95..3953607e9 100644 --- a/apps/macos/Sources/ClawdisCLI/UICLI.swift +++ b/apps/macos/Sources/ClawdisCLI/UICLI.swift @@ -323,16 +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 line = - "\(el.id)\t\(el.type)\t\(Int(b.origin.x)),\(Int(b.origin.y)) \(Int(b.size.width))x\(Int(b.size.height))\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 } @@ -521,14 +522,16 @@ enum UICLI { ]) } - do { - return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle) - } catch { - throw NSError(domain: "clawdis.ui", code: 6, userInfo: [ - NSLocalizedDescriptionKey: "No recent snapshot for \(resolvedBundle). Run `clawdis-mac ui see --bundle-id \(resolvedBundle)` first.", - ]) - } - } + 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