refactor: apply stashed bridge + CLI changes

This commit is contained in:
Peter Steinberger
2025-12-13 19:30:46 +00:00
parent 0b990443de
commit e2a93e17f9
23 changed files with 1337 additions and 1097 deletions

View File

@@ -9,11 +9,7 @@ actor BridgeClient {
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
nodeId: String, hello: BridgeHello,
displayName: String?,
platform: String,
version: String,
existingToken: String?,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{ {
self.lineBuffer = Data() self.lineBuffer = Data()
@@ -25,14 +21,7 @@ actor BridgeClient {
} }
onStatus?("Authenticating…") onStatus?("Authenticating…")
try await self.send( try await self.send(hello, over: connection)
BridgeHello(
nodeId: nodeId,
displayName: displayName,
token: existingToken,
platform: platform,
version: version),
over: connection)
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
guard let frame = try await self.receiveFrame(over: connection) else { guard let frame = try await self.receiveFrame(over: connection) else {
@@ -46,7 +35,7 @@ actor BridgeClient {
switch first.base.type { switch first.base.type {
case "hello-ok": case "hello-ok":
// We only return a token if we have one; callers should treat empty as "no token yet". // We only return a token if we have one; callers should treat empty as "no token yet".
return existingToken ?? "" return hello.token ?? ""
case "error": case "error":
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data) let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
@@ -59,10 +48,11 @@ actor BridgeClient {
onStatus?("Requesting approval…") onStatus?("Requesting approval…")
try await self.send( try await self.send(
BridgePairRequest( BridgePairRequest(
nodeId: nodeId, nodeId: hello.nodeId,
displayName: displayName, displayName: hello.displayName,
platform: platform, platform: hello.platform,
version: version), version: hello.version
),
over: connection) over: connection)
onStatus?("Waiting for approval…") onStatus?("Waiting for approval…")
@@ -155,7 +145,9 @@ actor BridgeClient {
var errorDescription: String? { var errorDescription: String? {
if self.purpose == "pairing approval" { 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)." return "Timed out during \(self.purpose) (\(self.seconds)s)."
} }

View File

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

View File

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

View File

@@ -77,17 +77,20 @@ struct SettingsTab: View {
guard let existing, !existing.isEmpty else { return } guard let existing, !existing.isEmpty else { return }
guard let target = self.pickAutoConnectBridge(from: newValue) else { return } guard let target = self.pickAutoConnectBridge(from: newValue) else { return }
self.didAutoConnect = true self.didAutoConnect = true
self.preferredBridgeStableID = target.stableID self.preferredBridgeStableID = target.stableID
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: target.endpoint, endpoint: target.endpoint,
token: existing, hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
platform: self.platformString(), token: existing,
version: self.appVersion()) platform: self.platformString(),
self.connectStatus = nil version: self.appVersion()
} )
)
self.connectStatus = nil
}
.onChange(of: self.appModel.bridgeServerName) { _, _ in .onChange(of: self.appModel.bridgeServerName) { _, _ in
self.connectStatus = nil self.connectStatus = nil
} }
@@ -170,18 +173,22 @@ struct SettingsTab: View {
existing : existing :
nil nil
let token = try await BridgeClient().pairAndHello( let hello = BridgeHello(
endpoint: bridge.endpoint, nodeId: self.instanceId,
nodeId: self.instanceId, displayName: self.displayName,
displayName: self.displayName, token: existingToken,
platform: self.platformString(), platform: self.platformString(),
version: self.appVersion(), version: self.appVersion()
existingToken: existingToken, )
onStatus: { status in let token = try await BridgeClient().pairAndHello(
Task { @MainActor in endpoint: bridge.endpoint,
self.connectStatus = status hello: hello,
} onStatus: { status in
}) Task { @MainActor in
self.connectStatus = status
}
}
)
if !token.isEmpty, token != existingToken { if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString( _ = KeychainStore.saveString(
@@ -190,13 +197,16 @@ struct SettingsTab: View {
account: self.keychainAccount()) account: self.keychainAccount())
} }
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: bridge.endpoint, endpoint: bridge.endpoint,
token: token, hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
platform: self.platformString(), token: token,
version: self.appVersion()) platform: self.platformString(),
version: self.appVersion()
)
)
} catch { } catch {
self.connectStatus = "Failed: \(error.localizedDescription)" self.connectStatus = "Failed: \(error.localizedDescription)"

View File

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

View File

@@ -204,13 +204,15 @@ struct ConfigSettings: View {
.disabled(!self.browserEnabled) .disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() } .onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help( .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 { GridRow {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(
"Clawd uses a separate Chrome profile and ports (default 18791/18792) so it wont interfere with your daily browser.") "Clawd uses a separate Chrome profile and ports (default 18791/18792) so it wont interfere with your daily browser."
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

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

View File

@@ -10,156 +10,254 @@ enum ControlRequestHandler {
{ {
// Keep `status` responsive even if the main actor is busy. // Keep `status` responsive even if the main actor is busy.
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
if paused { if paused, request != .status {
switch request { return Response(ok: false, message: "clawdis paused")
case .status:
break
default:
return Response(ok: false, message: "clawdis paused")
}
} }
switch request { switch request {
case let .notify(title, body, sound, priority, delivery): case let .notify(title, body, sound, priority, delivery):
let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines) let notify = NotifyRequest(
let chosenDelivery = delivery ?? .system title: title,
body: body,
sound: sound,
priority: priority,
delivery: delivery
)
return await self.handleNotify(notify, notifier: notifier)
switch chosenDelivery { case let .ensurePermissions(caps, interactive):
case .system: return await self.handleEnsurePermissions(caps: caps, interactive: interactive)
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 .overlay: case .status:
await MainActor.run { return paused
NotifyOverlayController.shared.present(title: title, body: body) ? Response(ok: false, message: "clawdis paused")
} : Response(ok: true, message: "ready")
return Response(ok: true)
case .auto: case .rpcStatus:
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority) return await self.handleRPCStatus()
if ok { return Response(ok: true) }
await MainActor.run { case let .runShell(command, cwd, env, timeoutSec, needsSR):
NotifyOverlayController.shared.present(title: title, body: body) return await self.handleRunShell(
} command: command,
return Response(ok: true, message: "notification not authorized; used overlay") 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) 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): private static func handleEnsurePermissions(caps: [Capability], interactive: Bool) async -> Response {
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true let statuses = await PermissionManager.ensure(caps, interactive: interactive)
guard canvasEnabled else { let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
return Response(ok: false, message: "Canvas disabled by user") let ok = missing.isEmpty
} let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
do { return Response(ok: ok, message: msg)
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)
}
case let .canvasEval(session, javaScript): private static func handleRPCStatus() async -> Response {
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true let result = await AgentRPC.shared.status()
guard canvasEnabled else { return Response(ok: result.ok, message: result.error)
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)
}
case let .canvasSnapshot(session, outPath): private static func handleRunShell(
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true command: [String],
guard canvasEnabled else { cwd: String?,
return Response(ok: false, message: "Canvas disabled by user") env: [String: String]?,
} timeoutSec: Double?,
do { needsSR: Bool
let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath) ) async -> Response {
return Response(ok: true, message: path) if needsSR {
} catch { let authorized = await PermissionManager
return Response(ok: false, message: error.localizedDescription) .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: private static func handleAgent(
let ids = await BridgeServer.shared.connectedNodeIds() message: String,
let payload = (try? JSONSerialization.data( thinking: String?,
withJSONObject: ["connectedNodeIds": ids], session: String?,
options: [.prettyPrinted])) deliver: Bool,
.flatMap { String(data: $0, encoding: .utf8) } to: String?
?? "{}" ) async -> Response {
return Response(ok: true, payload: Data(payload.utf8)) 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): private static func canvasEnabled() -> Bool {
do { UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON) }
if res.ok {
let payload = res.payloadJSON ?? "" private static func handleCanvasShow(
return Response(ok: true, payload: Data(payload.utf8)) session: String,
} path: String?,
let errText = res.error?.message ?? "node invoke failed" placement: CanvasPlacement?
return Response(ok: false, message: errText) ) async -> Response {
} catch { guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
return Response(ok: false, message: error.localizedDescription) 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)
} }
} }
} }

View File

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

View File

@@ -69,11 +69,13 @@ struct CronSettings: View {
.font(.headline) .font(.headline)
Spacer() Spacer()
} }
Text( Text(
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` and the Gateway restarts.") "Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
.font(.footnote) "and the Gateway restarts."
.foregroundStyle(.secondary) )
.fixedSize(horizontal: false, vertical: true) .font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty { if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
Text(storePath) Text(storePath)
.font(.caption.monospaced()) .font(.caption.monospaced())
@@ -526,7 +528,8 @@ private struct CronJobEditor: View {
Text(self.job == nil ? "New cron job" : "Edit cron job") Text(self.job == nil ? "New cron job" : "Edit cron job")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text( Text(
"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) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -572,7 +575,8 @@ private struct CronJobEditor: View {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(
"Main jobs post a system event into the current main session. Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/etc).") "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) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -597,7 +601,8 @@ private struct CronJobEditor: View {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression.") "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -642,7 +647,8 @@ private struct CronJobEditor: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
if self.sessionTarget == .isolated { if self.sessionTarget == .isolated {
Text( 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) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -663,7 +669,8 @@ private struct CronJobEditor: View {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(
"System events are injected into the current main session. Agent turns require an isolated session target.") "System events are injected into the current main session. Agent turns require an isolated session target."
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -696,7 +703,8 @@ private struct CronJobEditor: View {
Color.clear Color.clear
.frame(width: self.labelColumnWidth, height: 1) .frame(width: self.labelColumnWidth, height: 1)
Text( Text(
"Controls the label used when posting the completion summary back to the main session.") "Controls the label used when posting the completion summary back to the main session."
)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -906,13 +914,14 @@ private struct CronJobEditor: View {
}() }()
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
throw NSError( throw NSError(
domain: "Cron", domain: "Cron",
code: 0, code: 0,
userInfo: [ userInfo: [
NSLocalizedDescriptionKey: "Main session jobs require systemEvent payloads (switch Session target to isolated).", NSLocalizedDescriptionKey:
]) "Main session jobs require systemEvent payloads (switch Session target to isolated).",
} ])
}
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
throw NSError( throw NSError(

View File

@@ -141,14 +141,17 @@ struct DebugSettings: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
GridRow { GridRow {
self.gridLabel("Attach only") self.gridLabel("Attach only")
Toggle("", isOn: self.$attachExistingGatewayOnly) Toggle("", isOn: self.$attachExistingGatewayOnly)
.labelsHidden() .labelsHidden()
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.help( .help(
"When enabled in local mode, the mac app will only connect to an already-running gateway and will not start one itself.") "When enabled in local mode, the mac app will only connect " +
} "to an already-running gateway " +
"and will not start one itself."
)
}
GridRow { GridRow {
self.gridLabel("Deep links") self.gridLabel("Deep links")
Toggle("", isOn: self.$deepLinkAgentEnabled) Toggle("", isOn: self.$deepLinkAgentEnabled)
@@ -229,15 +232,17 @@ struct DebugSettings: View {
GridRow { GridRow {
self.gridLabel("Diagnostics") self.gridLabel("Diagnostics")
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled) Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.help( .help(
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. Enable only while actively debugging.") "Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " +
HStack(spacing: 8) { "Enable only while actively debugging."
Button("Open folder") { )
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL()) HStack(spacing: 8) {
} Button("Open folder") {
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
}
.buttonStyle(.bordered) .buttonStyle(.bordered)
Button("Clear") { Button("Clear") {
Task { try? await DiagnosticsFileLog.shared.clear() } Task { try? await DiagnosticsFileLog.shared.clear() }
@@ -480,11 +485,13 @@ struct DebugSettings: View {
private var canvasSection: some View { private var canvasSection: some View {
GroupBox("Canvas") { GroupBox("Canvas") {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled) Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.help( .help(
"When off, agent Canvas requests return “Canvas disabled by user”. Manual debug actions still work.") "When off, agent Canvas requests return “Canvas disabled by user”. " +
"Manual debug actions still work."
)
HStack(spacing: 8) { HStack(spacing: 8) {
TextField("Session", text: self.$canvasSessionKey) TextField("Session", text: self.$canvasSessionKey)
@@ -580,28 +587,18 @@ struct DebugSettings: View {
.labelsHidden() .labelsHidden()
.frame(maxWidth: 280, alignment: .leading) .frame(maxWidth: 280, alignment: .leading)
} }
GridRow { GridRow {
self.gridLabel("Web chat") self.gridLabel("Web chat")
Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled) Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled)
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.help( .help(
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the bundled WKWebView.") "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)
}
}
@MainActor @MainActor
private func runPortCheck() async { private func runPortCheck() async {
@@ -755,12 +752,14 @@ struct DebugSettings: View {
} }
} }
private func configURL() -> URL { private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis") .appendingPathComponent(".clawdis")
.appendingPathComponent("clawdis.json") .appendingPathComponent("clawdis.json")
} }
}
extension DebugSettings {
// MARK: - Canvas debug actions // MARK: - Canvas debug actions
@MainActor @MainActor
@@ -796,12 +795,17 @@ struct DebugSettings: View {
body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; } body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
.wrap { padding:16px; } .wrap { padding:16px; }
.row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; } .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); } .pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08);
button { background:#22c55e; color:#04110a; border:0; border-radius:10px; padding:8px 10px; font-weight:700; cursor:pointer; } 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); } 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; } .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); } .muted { color: rgba(229,231,235,.7); }
</style> </style>
</head> </head>
@@ -850,7 +854,8 @@ struct DebugSettings: View {
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let result = try await CanvasManager.shared.eval( let result = try await CanvasManager.shared.eval(
sessionKey: session.isEmpty ? "main" : session, sessionKey: session.isEmpty ? "main" : session,
javaScript: self.canvasEvalJS) javaScript: self.canvasEvalJS
)
self.canvasEvalResult = result self.canvasEvalResult = result
} catch { } catch {
self.canvasError = error.localizedDescription self.canvasError = error.localizedDescription
@@ -865,7 +870,8 @@ struct DebugSettings: View {
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let path = try await CanvasManager.shared.snapshot( let path = try await CanvasManager.shared.snapshot(
sessionKey: session.isEmpty ? "main" : session, sessionKey: session.isEmpty ? "main" : session,
outPath: nil) outPath: nil
)
self.canvasSnapshotPath = path self.canvasSnapshotPath = path
} catch { } catch {
self.canvasError = error.localizedDescription self.canvasError = error.localizedDescription
@@ -873,10 +879,22 @@ struct DebugSettings: View {
} }
} }
#if DEBUG private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
struct DebugSettings_Previews: PreviewProvider { func makeBody(configuration: Configuration) -> some View {
static var previews: some View { VStack(alignment: .leading, spacing: 10) {
DebugSettings() 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) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
} }
} }

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import SwiftUI import SwiftUI
// master is part of the discovery protocol naming; keep UI components consistent.
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryInlineList: View { struct MasterDiscoveryInlineList: View {
@ObservedObject var discovery: MasterDiscoveryModel @ObservedObject var discovery: MasterDiscoveryModel
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
@@ -50,6 +52,7 @@ struct MasterDiscoveryInlineList: View {
} }
} }
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryMenu: View { struct MasterDiscoveryMenu: View {
@ObservedObject var discovery: MasterDiscoveryModel @ObservedObject var discovery: MasterDiscoveryModel
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void

View File

@@ -1,8 +1,11 @@
import Foundation import Foundation
import Network import Network
// We use master as the on-the-wire service name; keep the model aligned with the protocol/docs.
@MainActor @MainActor
// swiftlint:disable:next inclusive_language
final class MasterDiscoveryModel: ObservableObject { final class MasterDiscoveryModel: ObservableObject {
// swiftlint:disable:next inclusive_language
struct DiscoveredMaster: Identifiable, Equatable { struct DiscoveredMaster: Identifiable, Equatable {
var id: String { self.debugID } var id: String { self.debugID }
var displayName: String var displayName: String
@@ -12,6 +15,7 @@ final class MasterDiscoveryModel: ObservableObject {
var debugID: String var debugID: String
} }
// swiftlint:disable:next inclusive_language
@Published var masters: [DiscoveredMaster] = [] @Published var masters: [DiscoveredMaster] = []
@Published var statusText: String = "Idle" @Published var statusText: String = "Idle"

View File

@@ -110,9 +110,8 @@ struct MenuContent: View {
await self.reloadSessionMenu() await self.reloadSessionMenu()
} }
} label: { } label: {
Label( let checkmark = row.thinkingLevel == normalized ? "checkmark" : ""
level.capitalized, Label(level.capitalized, systemImage: checkmark)
systemImage: row.thinkingLevel == normalized ? "checkmark" : "")
} }
} }
} }
@@ -128,9 +127,8 @@ struct MenuContent: View {
await self.reloadSessionMenu() await self.reloadSessionMenu()
} }
} label: { } label: {
Label( let checkmark = row.verboseLevel == normalized ? "checkmark" : ""
level.capitalized, Label(level.capitalized, systemImage: checkmark)
systemImage: row.verboseLevel == normalized ? "checkmark" : "")
} }
} }
} }

View File

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

View File

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

View File

@@ -186,39 +186,37 @@ final class WebChatServer: @unchecked Sendable {
over: connection) over: connection)
return return
} }
guard let data = try? Data(contentsOf: fileURL) else { guard let data = try? Data(contentsOf: fileURL) else {
webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)") webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)")
self.send( self.send(
status: 404, status: 404,
mime: "text/plain", mime: "text/plain",
body: Data("Not Found".utf8), body: Data("Not Found".utf8),
contentLength: "Not Found".utf8.count, includeBody: includeBody,
includeBody: includeBody, over: connection)
over: connection) return
return }
} let mime = self.mimeType(forExtension: fileURL.pathExtension)
let mime = self.mimeType(forExtension: fileURL.pathExtension) self.send(
self.send( status: 200,
status: 200, mime: mime,
mime: mime, body: data,
body: data, includeBody: includeBody,
contentLength: data.count, over: connection)
includeBody: includeBody, }
over: connection)
}
private func send( private func send(
status: Int, status: Int,
mime: String, mime: String,
body: Data, body: Data,
contentLength: Int, includeBody: Bool,
includeBody: Bool, over connection: NWConnection)
over connection: NWConnection) {
{ let contentLength = body.count
let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" + let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" +
"Content-Length: \(contentLength)\r\n" + "Content-Length: \(contentLength)\r\n" +
"Content-Type: \(mime)\r\n" + "Content-Type: \(mime)\r\n" +
"Connection: close\r\n\r\n" "Connection: close\r\n\r\n"
var response = Data(headers.utf8) var response = Data(headers.utf8)
if includeBody { if includeBody {
response.append(body) response.append(body)

View File

@@ -163,8 +163,9 @@ final class WebChatViewModel: ObservableObject {
do { do {
let data = try await Task.detached { try Data(contentsOf: url) }.value let data = try await Task.detached { try Data(contentsOf: url) }.value
guard data.count <= 5_000_000 else { guard data.count <= 5_000_000 else {
await MainActor await MainActor.run {
.run { self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit" } self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit"
}
continue continue
} }
let uti = UTType(filenameExtension: url.pathExtension) ?? .data let uti = UTType(filenameExtension: url.pathExtension) ?? .data
@@ -447,8 +448,11 @@ struct WebChatView: View {
.foregroundStyle(Color.accentColor.opacity(0.9)) .foregroundStyle(Color.accentColor.opacity(0.9))
Text("Say hi to Clawd") Text("Say hi to Clawd")
.font(.headline) .font(.headline)
Text(self.viewModel Text(
.healthOK ? "This is the native SwiftUI debug chat." : "Connecting to the gateway…") self.viewModel.healthOK
? "This is the native SwiftUI debug chat."
: "Connecting to the gateway…"
)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -460,10 +464,9 @@ struct WebChatView: View {
.padding(.vertical, 34) .padding(.vertical, 34)
} else { } else {
ForEach(self.viewModel.messages) { msg in ForEach(self.viewModel.messages) { msg in
let alignment: Alignment = msg.role.lowercased() == "user" ? .trailing : .leading
MessageBubble(message: msg) MessageBubble(message: msg)
.frame( .frame(maxWidth: .infinity, alignment: alignment)
maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
} }
} }

View File

@@ -6,62 +6,17 @@ enum BrowserCLI {
static func run(args: [String], jsonOutput: Bool) async throws -> Int32 { static func run(args: [String], jsonOutput: Bool) async throws -> Int32 {
var args = args var args = args
guard let sub = args.first else { guard let sub = args.popFirst() else {
self.printHelp() self.printHelp()
return 0 return 0
} }
args = Array(args.dropFirst())
if sub == "--help" || sub == "-h" || sub == "help" { if sub == "--help" || sub == "-h" || sub == "help" {
self.printHelp() self.printHelp()
return 0 return 0
} }
var overrideURL: String? let options = self.parseOptions(args: args)
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 cfg = self.loadBrowserConfig() let cfg = self.loadBrowserConfig()
guard cfg.enabled else { guard cfg.enabled else {
@@ -73,7 +28,7 @@ enum BrowserCLI {
return 1 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 { guard let baseURL = URL(string: base) else {
throw NSError(domain: "BrowserCLI", code: 1, userInfo: [ throw NSError(domain: "BrowserCLI", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid browser control URL: \(base)", NSLocalizedDescriptionKey: "Invalid browser control URL: \(base)",
@@ -81,237 +36,7 @@ enum BrowserCLI {
} }
do { do {
switch sub { return try await self.runCommand(sub: sub, options: options, baseURL: baseURL, jsonOutput: jsonOutput)
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
}
} catch { } catch {
let msg = self.describeError(error, baseURL: baseURL) let msg = self.describeError(error, baseURL: baseURL)
if jsonOutput { 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 { private struct BrowserConfig {
let enabled: Bool let enabled: Bool
let controlUrl: String let controlUrl: String

View File

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

View File

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