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

View File

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

View File

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

View File

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

View File

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

View File

@@ -204,13 +204,15 @@ struct ConfigSettings: View {
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(
"When enabled, the browser server will only connect if the clawd browser is already running.")
"When enabled, the browser server will only connect if the clawd browser is already running."
)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
"Clawd uses a separate Chrome profile and ports (default 18791/18792) so it 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)
.foregroundStyle(.secondary)
.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 let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
return "Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment or clear it on the gateway. Reason: \(reason)"
}
if let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
return
"Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment " +
"or clear it on the gateway. " +
"Reason: \(reason)"
}
// Common misfire: we connected to localhost:18789 but the port is occupied
// by some other process (e.g. a local dev gateway or a stuck SSH forward).

View File

@@ -10,156 +10,254 @@ enum ControlRequestHandler {
{
// Keep `status` responsive even if the main actor is busy.
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
if paused {
switch request {
case .status:
break
default:
return Response(ok: false, message: "clawdis paused")
}
if paused, request != .status {
return Response(ok: false, message: "clawdis paused")
}
switch request {
case let .notify(title, body, sound, priority, delivery):
let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines)
let chosenDelivery = delivery ?? .system
switch request {
case let .notify(title, body, sound, priority, delivery):
let notify = NotifyRequest(
title: title,
body: body,
sound: sound,
priority: priority,
delivery: delivery
)
return await self.handleNotify(notify, notifier: notifier)
switch chosenDelivery {
case .system:
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
case let .ensurePermissions(caps, interactive):
return await self.handleEnsurePermissions(caps: caps, interactive: interactive)
case .overlay:
await MainActor.run {
NotifyOverlayController.shared.present(title: title, body: body)
}
return Response(ok: true)
case .status:
return paused
? Response(ok: false, message: "clawdis paused")
: Response(ok: true, message: "ready")
case .auto:
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
if ok { return Response(ok: true) }
await MainActor.run {
NotifyOverlayController.shared.present(title: title, body: body)
}
return Response(ok: true, message: "notification not authorized; used overlay")
case .rpcStatus:
return await self.handleRPCStatus()
case let .runShell(command, cwd, env, timeoutSec, needsSR):
return await self.handleRunShell(
command: command,
cwd: cwd,
env: env,
timeoutSec: timeoutSec,
needsSR: needsSR
)
case let .agent(message, thinking, session, deliver, to):
return await self.handleAgent(
message: message,
thinking: thinking,
session: session,
deliver: deliver,
to: to
)
case let .canvasShow(session, path, placement):
return await self.handleCanvasShow(session: session, path: path, placement: placement)
case let .canvasHide(session):
return await self.handleCanvasHide(session: session)
case let .canvasGoto(session, path, placement):
return await self.handleCanvasGoto(session: session, path: path, placement: placement)
case let .canvasEval(session, javaScript):
return await self.handleCanvasEval(session: session, javaScript: javaScript)
case let .canvasSnapshot(session, outPath):
return await self.handleCanvasSnapshot(session: session, outPath: outPath)
case .nodeList:
return await self.handleNodeList()
case let .nodeInvoke(nodeId, command, paramsJSON):
return await self.handleNodeInvoke(
nodeId: nodeId,
command: command,
paramsJSON: paramsJSON,
logger: logger
)
}
}
private struct NotifyRequest {
var title: String
var body: String
var sound: String?
var priority: NotificationPriority?
var delivery: NotificationDelivery?
}
private static func handleNotify(_ request: NotifyRequest, notifier: NotificationManager) async -> Response {
let chosenSound = request.sound?.trimmingCharacters(in: .whitespacesAndNewlines)
let chosenDelivery = request.delivery ?? .system
switch chosenDelivery {
case .system:
let ok = await notifier.send(
title: request.title,
body: request.body,
sound: chosenSound,
priority: request.priority
)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
case .overlay:
await MainActor.run {
NotifyOverlayController.shared.present(title: request.title, body: request.body)
}
case let .ensurePermissions(caps, interactive):
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
let ok = missing.isEmpty
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
return Response(ok: ok, message: msg)
case .status:
return paused ? Response(ok: false, message: "clawdis paused") : Response(ok: true, message: "ready")
case .rpcStatus:
let result = await AgentRPC.shared.status()
return Response(ok: result.ok, message: result.error)
case let .runShell(command, cwd, env, timeoutSec, needsSR):
if needsSR {
let authorized = await PermissionManager
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
}
return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
case let .agent(message, thinking, session, deliver, to):
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
let sessionKey = session ?? "main"
let rpcResult = await AgentRPC.shared.send(
text: trimmed,
thinking: thinking,
sessionKey: sessionKey,
deliver: deliver,
to: to,
channel: nil)
return rpcResult.ok
? Response(ok: true, message: rpcResult.text ?? "sent")
: Response(ok: false, message: rpcResult.error ?? "failed to send")
case let .canvasShow(session, path, placement):
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
let dir = try await MainActor.run { try CanvasManager.shared.show(
sessionKey: session,
path: path,
placement: placement) }
return Response(ok: true, message: dir)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
case let .canvasHide(session):
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
return Response(ok: true)
case .auto:
let ok = await notifier.send(
title: request.title,
body: request.body,
sound: chosenSound,
priority: request.priority
)
if ok { return Response(ok: true) }
await MainActor.run {
NotifyOverlayController.shared.present(title: request.title, body: request.body)
}
return Response(ok: true, message: "notification not authorized; used overlay")
}
}
case let .canvasGoto(session, path, placement):
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
try await MainActor.run { try CanvasManager.shared.goto(
sessionKey: session,
path: path,
placement: placement) }
return Response(ok: true)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
private static func handleEnsurePermissions(caps: [Capability], interactive: Bool) async -> Response {
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
let ok = missing.isEmpty
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
return Response(ok: ok, message: msg)
}
case let .canvasEval(session, javaScript):
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
return Response(ok: true, payload: Data(result.utf8))
} catch {
return Response(ok: false, message: error.localizedDescription)
}
private static func handleRPCStatus() async -> Response {
let result = await AgentRPC.shared.status()
return Response(ok: result.ok, message: result.error)
}
case let .canvasSnapshot(session, outPath):
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath)
return Response(ok: true, message: path)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
private static func handleRunShell(
command: [String],
cwd: String?,
env: [String: String]?,
timeoutSec: Double?,
needsSR: Bool
) async -> Response {
if needsSR {
let authorized = await PermissionManager
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
}
return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
}
case .nodeList:
let ids = await BridgeServer.shared.connectedNodeIds()
let payload = (try? JSONSerialization.data(
withJSONObject: ["connectedNodeIds": ids],
options: [.prettyPrinted]))
.flatMap { String(data: $0, encoding: .utf8) }
?? "{}"
return Response(ok: true, payload: Data(payload.utf8))
private static func handleAgent(
message: String,
thinking: String?,
session: String?,
deliver: Bool,
to: String?
) async -> Response {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
let sessionKey = session ?? "main"
let rpcResult = await AgentRPC.shared.send(
text: trimmed,
thinking: thinking,
sessionKey: sessionKey,
deliver: deliver,
to: to,
channel: nil
)
return rpcResult.ok
? Response(ok: true, message: rpcResult.text ?? "sent")
: Response(ok: false, message: rpcResult.error ?? "failed to send")
}
case let .nodeInvoke(nodeId, command, paramsJSON):
do {
let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
if res.ok {
let payload = res.payloadJSON ?? ""
return Response(ok: true, payload: Data(payload.utf8))
}
let errText = res.error?.message ?? "node invoke failed"
return Response(ok: false, message: errText)
} catch {
return Response(ok: false, message: error.localizedDescription)
private static func canvasEnabled() -> Bool {
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
}
private static func handleCanvasShow(
session: String,
path: String?,
placement: CanvasPlacement?
) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
do {
let dir = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: session, path: path, placement: placement)
}
return Response(ok: true, message: dir)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasHide(session: String) async -> Response {
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
return Response(ok: true)
}
private static func handleCanvasGoto(session: String, path: String, placement: CanvasPlacement?) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
do {
try await MainActor.run {
try CanvasManager.shared.goto(sessionKey: session, path: path, placement: placement)
}
return Response(ok: true)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
do {
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
return Response(ok: true, payload: Data(result.utf8))
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasSnapshot(session: String, outPath: String?) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
do {
let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath)
return Response(ok: true, message: path)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleNodeList() async -> Response {
let ids = await BridgeServer.shared.connectedNodeIds()
let payload = (try? JSONSerialization.data(
withJSONObject: ["connectedNodeIds": ids],
options: [.prettyPrinted]
))
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
return Response(ok: true, payload: Data(payload.utf8))
}
private static func handleNodeInvoke(
nodeId: String,
command: String,
paramsJSON: String?,
logger: Logger
) async -> Response {
do {
let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
if res.ok {
let payload = res.payloadJSON ?? ""
return Response(ok: true, payload: Data(payload.utf8))
}
let errText = res.error?.message ?? "node invoke failed"
return Response(ok: false, message: errText)
} catch {
logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)")
return Response(ok: false, message: error.localizedDescription)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,62 +6,17 @@ enum BrowserCLI {
static func run(args: [String], jsonOutput: Bool) async throws -> Int32 {
var args = args
guard let sub = args.first else {
guard let sub = args.popFirst() else {
self.printHelp()
return 0
}
args = Array(args.dropFirst())
if sub == "--help" || sub == "-h" || sub == "help" {
self.printHelp()
return 0
}
var overrideURL: String?
var fullPage = false
var targetId: String?
var awaitPromise = false
var js: String?
var jsFile: String?
var jsStdin = false
var selector: String?
var format: String?
var limit: Int?
var maxChars: Int?
var outPath: String?
var rest: [String] = []
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--url":
overrideURL = args.popFirst()
case "--full-page":
fullPage = true
case "--target-id":
targetId = args.popFirst()
case "--await":
awaitPromise = true
case "--js":
js = args.popFirst()
case "--js-file":
jsFile = args.popFirst()
case "--js-stdin":
jsStdin = true
case "--selector":
selector = args.popFirst()
case "--format":
format = args.popFirst()
case "--limit":
limit = args.popFirst().flatMap(Int.init)
case "--max-chars":
maxChars = args.popFirst().flatMap(Int.init)
case "--out":
outPath = args.popFirst()
default:
rest.append(arg)
}
}
let options = self.parseOptions(args: args)
let cfg = self.loadBrowserConfig()
guard cfg.enabled else {
@@ -73,7 +28,7 @@ enum BrowserCLI {
return 1
}
let base = (overrideURL ?? cfg.controlUrl).trimmingCharacters(in: .whitespacesAndNewlines)
let base = (options.overrideURL ?? cfg.controlUrl).trimmingCharacters(in: .whitespacesAndNewlines)
guard let baseURL = URL(string: base) else {
throw NSError(domain: "BrowserCLI", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid browser control URL: \(base)",
@@ -81,237 +36,7 @@ enum BrowserCLI {
}
do {
switch sub {
case "status":
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/")))
return 0
case "start":
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/start"),
timeoutInterval: 15.0))
return 0
case "stop":
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/stop"),
timeoutInterval: 15.0))
return 0
case "tabs":
let res = try await self.httpJSON(
method: "GET",
url: baseURL.appendingPathComponent("/tabs"),
timeoutInterval: 3.0)
if jsonOutput {
self.printJSON(ok: true, result: res)
} else {
self.printTabs(res: res)
}
return 0
case "open":
guard let url = rest.first, !url.isEmpty else {
self.printHelp()
return 2
}
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/tabs/open"),
body: ["url": url],
timeoutInterval: 15.0))
return 0
case "focus":
guard let id = rest.first, !id.isEmpty else {
self.printHelp()
return 2
}
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/tabs/focus"),
body: ["targetId": id],
timeoutInterval: 5.0))
return 0
case "close":
guard let id = rest.first, !id.isEmpty else {
self.printHelp()
return 2
}
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(
method: "DELETE",
url: baseURL.appendingPathComponent("/tabs/\(id)"),
timeoutInterval: 5.0))
return 0
case "screenshot":
var url = baseURL.appendingPathComponent("/screenshot")
var items: [URLQueryItem] = []
if let targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if fullPage {
items.append(URLQueryItem(name: "fullPage", value: "1"))
}
if !items.isEmpty {
url = self.withQuery(url, items: items)
}
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
if jsonOutput {
self.printJSON(ok: true, result: res)
} else if let path = res["path"] as? String, !path.isEmpty {
print("MEDIA:\(path)")
} else {
self.printResult(jsonOutput: false, res: res)
}
return 0
case "eval":
if jsStdin, jsFile != nil {
self.printHelp()
return 2
}
let code: String = try {
if let jsFile, !jsFile.isEmpty {
return try String(contentsOfFile: jsFile, encoding: .utf8)
}
if jsStdin {
let data = FileHandle.standardInput.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
}
if let js, !js.isEmpty { return js }
if !rest.isEmpty { return rest.joined(separator: " ") }
return ""
}()
if code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.printHelp()
return 2
}
let res = try await self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/eval"),
body: [
"js": code,
"targetId": targetId ?? "",
"await": awaitPromise,
],
timeoutInterval: 15.0)
if jsonOutput {
self.printJSON(ok: true, result: res)
} else {
self.printEval(res: res)
}
return 0
case "query":
let sel = (selector ?? rest.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if sel.isEmpty {
self.printHelp()
return 2
}
var url = baseURL.appendingPathComponent("/query")
var items: [URLQueryItem] = [URLQueryItem(name: "selector", value: sel)]
if let targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let limit, limit > 0 {
items.append(URLQueryItem(name: "limit", value: String(limit)))
}
url = self.withQuery(url, items: items)
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 15.0)
if jsonOutput || format == "json" {
self.printJSON(ok: true, result: res)
} else {
self.printQuery(res: res)
}
return 0
case "dom":
let fmt = (format == "text") ? "text" : "html"
var url = baseURL.appendingPathComponent("/dom")
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
if let targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let selector = selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty {
items.append(URLQueryItem(name: "selector", value: selector))
}
if let maxChars, maxChars > 0 {
items.append(URLQueryItem(name: "maxChars", value: String(maxChars)))
}
url = self.withQuery(url, items: items)
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
let text = (res["text"] as? String) ?? ""
if let out = outPath, !out.isEmpty {
try Data(text.utf8).write(to: URL(fileURLWithPath: out))
if jsonOutput {
self.printJSON(ok: true, result: ["ok": true, "out": out])
} else {
print(out)
}
return 0
}
if jsonOutput {
self.printJSON(ok: true, result: res)
} else {
print(text)
}
return 0
case "snapshot":
let fmt = (format == "domSnapshot") ? "domSnapshot" : "aria"
var url = baseURL.appendingPathComponent("/snapshot")
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
if let targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let limit, limit > 0 {
items.append(URLQueryItem(name: "limit", value: String(limit)))
}
url = self.withQuery(url, items: items)
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
if let out = outPath, !out.isEmpty {
let data = try JSONSerialization.data(withJSONObject: res, options: [.prettyPrinted])
try data.write(to: URL(fileURLWithPath: out))
if jsonOutput {
self.printJSON(ok: true, result: ["ok": true, "out": out])
} else {
print(out)
}
return 0
}
if jsonOutput || fmt == "domSnapshot" {
self.printJSON(ok: true, result: res)
} else {
self.printSnapshotAria(res: res)
}
return 0
default:
self.printHelp()
return 2
}
return try await self.runCommand(sub: sub, options: options, baseURL: baseURL, jsonOutput: jsonOutput)
} catch {
let msg = self.describeError(error, baseURL: baseURL)
if jsonOutput {
@@ -323,6 +48,329 @@ enum BrowserCLI {
}
}
private struct RunOptions {
var overrideURL: String?
var fullPage: Bool = false
var targetId: String?
var awaitPromise: Bool = false
var js: String?
var jsFile: String?
var jsStdin: Bool = false
var selector: String?
var format: String?
var limit: Int?
var maxChars: Int?
var outPath: String?
var rest: [String] = []
}
private static func parseOptions(args: [String]) -> RunOptions {
var args = args
var options = RunOptions()
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--url":
options.overrideURL = args.popFirst()
case "--full-page":
options.fullPage = true
case "--target-id":
options.targetId = args.popFirst()
case "--await":
options.awaitPromise = true
case "--js":
options.js = args.popFirst()
case "--js-file":
options.jsFile = args.popFirst()
case "--js-stdin":
options.jsStdin = true
case "--selector":
options.selector = args.popFirst()
case "--format":
options.format = args.popFirst()
case "--limit":
options.limit = args.popFirst().flatMap(Int.init)
case "--max-chars":
options.maxChars = args.popFirst().flatMap(Int.init)
case "--out":
options.outPath = args.popFirst()
default:
options.rest.append(arg)
}
}
return options
}
private static func runCommand(
sub: String,
options: RunOptions,
baseURL: URL,
jsonOutput: Bool
) async throws -> Int32 {
switch sub {
case "status":
return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput)
case "start":
return try await self.handleStartStop(action: "start", baseURL: baseURL, jsonOutput: jsonOutput)
case "stop":
return try await self.handleStartStop(action: "stop", baseURL: baseURL, jsonOutput: jsonOutput)
case "tabs":
return try await self.handleTabs(baseURL: baseURL, jsonOutput: jsonOutput)
case "open":
return try await self.handleOpen(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "focus":
return try await self.handleFocus(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "close":
return try await self.handleClose(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "screenshot":
return try await self.handleScreenshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "eval":
return try await self.handleEval(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "query":
return try await self.handleQuery(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "dom":
return try await self.handleDOM(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "snapshot":
return try await self.handleSnapshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
default:
self.printHelp()
return 2
}
}
private static func handleStatus(baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/"))
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
private static func handleStartStop(action: String, baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
let url = baseURL.appendingPathComponent("/\(action)")
let res = try await self.httpJSON(method: "POST", url: url, timeoutInterval: 15.0)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
private static func handleTabs(baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
let url = baseURL.appendingPathComponent("/tabs")
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 3.0)
if jsonOutput {
self.printJSON(ok: true, result: res)
} else {
self.printTabs(res: res)
}
return 0
}
private static func handleOpen(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
guard let urlString = options.rest.first, !urlString.isEmpty else {
self.printHelp()
return 2
}
let url = baseURL.appendingPathComponent("/tabs/open")
let res = try await self.httpJSON(
method: "POST",
url: url,
body: ["url": urlString],
timeoutInterval: 15.0
)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
private static func handleFocus(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
guard let id = options.rest.first, !id.isEmpty else {
self.printHelp()
return 2
}
let url = baseURL.appendingPathComponent("/tabs/focus")
let res = try await self.httpJSON(
method: "POST",
url: url,
body: ["targetId": id],
timeoutInterval: 5.0
)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
private static func handleClose(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
guard let id = options.rest.first, !id.isEmpty else {
self.printHelp()
return 2
}
let url = baseURL.appendingPathComponent("/tabs/\(id)")
let res = try await self.httpJSON(method: "DELETE", url: url, timeoutInterval: 5.0)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
private static func handleScreenshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
var url = baseURL.appendingPathComponent("/screenshot")
var items: [URLQueryItem] = []
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if options.fullPage {
items.append(URLQueryItem(name: "fullPage", value: "1"))
}
if !items.isEmpty {
url = self.withQuery(url, items: items)
}
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
if jsonOutput {
self.printJSON(ok: true, result: res)
} else if let path = res["path"] as? String, !path.isEmpty {
print("MEDIA:\(path)")
} else {
self.printResult(jsonOutput: false, res: res)
}
return 0
}
private static func handleEval(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
if options.jsStdin, options.jsFile != nil {
self.printHelp()
return 2
}
let code = try self.resolveEvalCode(options: options)
if code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.printHelp()
return 2
}
let url = baseURL.appendingPathComponent("/eval")
let res = try await self.httpJSON(
method: "POST",
url: url,
body: [
"js": code,
"targetId": options.targetId ?? "",
"await": options.awaitPromise,
],
timeoutInterval: 15.0
)
if jsonOutput {
self.printJSON(ok: true, result: res)
} else {
self.printEval(res: res)
}
return 0
}
private static func resolveEvalCode(options: RunOptions) throws -> String {
if let jsFile = options.jsFile, !jsFile.isEmpty {
return try String(contentsOfFile: jsFile, encoding: .utf8)
}
if options.jsStdin {
let data = FileHandle.standardInput.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
}
if let js = options.js, !js.isEmpty {
return js
}
if !options.rest.isEmpty {
return options.rest.joined(separator: " ")
}
return ""
}
private static func handleQuery(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
let sel = (options.selector ?? options.rest.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if sel.isEmpty {
self.printHelp()
return 2
}
var url = baseURL.appendingPathComponent("/query")
var items: [URLQueryItem] = [URLQueryItem(name: "selector", value: sel)]
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let limit = options.limit, limit > 0 {
items.append(URLQueryItem(name: "limit", value: String(limit)))
}
url = self.withQuery(url, items: items)
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 15.0)
if jsonOutput || options.format == "json" {
self.printJSON(ok: true, result: res)
} else {
self.printQuery(res: res)
}
return 0
}
private static func handleDOM(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
let fmt = (options.format == "text") ? "text" : "html"
var url = baseURL.appendingPathComponent("/dom")
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let selector = options.selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty {
items.append(URLQueryItem(name: "selector", value: selector))
}
if let maxChars = options.maxChars, maxChars > 0 {
items.append(URLQueryItem(name: "maxChars", value: String(maxChars)))
}
url = self.withQuery(url, items: items)
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
let text = (res["text"] as? String) ?? ""
if let out = options.outPath, !out.isEmpty {
try Data(text.utf8).write(to: URL(fileURLWithPath: out))
if jsonOutput {
self.printJSON(ok: true, result: ["ok": true, "out": out])
} else {
print(out)
}
return 0
}
if jsonOutput {
self.printJSON(ok: true, result: res)
} else {
print(text)
}
return 0
}
private static func handleSnapshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
let fmt = (options.format == "domSnapshot") ? "domSnapshot" : "aria"
var url = baseURL.appendingPathComponent("/snapshot")
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let limit = options.limit, limit > 0 {
items.append(URLQueryItem(name: "limit", value: String(limit)))
}
url = self.withQuery(url, items: items)
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
if let out = options.outPath, !out.isEmpty {
let data = try JSONSerialization.data(withJSONObject: res, options: [.prettyPrinted])
try data.write(to: URL(fileURLWithPath: out))
if jsonOutput {
self.printJSON(ok: true, result: ["ok": true, "out": out])
} else {
print(out)
}
return 0
}
if jsonOutput || fmt == "domSnapshot" {
self.printJSON(ok: true, result: res)
} else {
self.printSnapshotAria(res: res)
}
return 0
}
private struct BrowserConfig {
let enabled: Bool
let controlUrl: String

View File

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

View File

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