refactor: apply stashed bridge + CLI changes

main
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

@ -48,11 +48,7 @@ final class NodeAppModel: ObservableObject {
func connectToBridge(
endpoint: NWEndpoint,
token: String,
nodeId: String,
displayName: String?,
platform: String,
version: String)
hello: BridgeHello)
{
self.bridgeTask?.cancel()
self.bridgeStatusText = "Connecting…"
@ -64,12 +60,7 @@ final class NodeAppModel: ObservableObject {
do {
try await self.bridge.connect(
endpoint: endpoint,
hello: BridgeHello(
nodeId: nodeId,
displayName: displayName,
token: token,
platform: platform,
version: version),
hello: hello,
onConnected: { [weak self] serverName in
guard let self else { return }
await MainActor.run {
@ -126,7 +117,11 @@ final class NodeAppModel: ObservableObject {
}
let payload = Payload(text: text, sessionKey: sessionKey)
let data = try JSONEncoder().encode(payload)
let json = String(decoding: data, as: UTF8.self)
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)
}
@ -171,7 +166,11 @@ 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)
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)
}
@ -246,6 +245,11 @@ final class NodeAppModel: ObservableObject {
private static func encodePayload(_ obj: some Encodable) throws -> String {
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

@ -110,8 +110,10 @@ final class ScreenController: ObservableObject {
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);
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;

View File

@ -81,11 +81,14 @@ struct SettingsTab: View {
self.preferredBridgeStableID = target.stableID
self.appModel.connectToBridge(
endpoint: target.endpoint,
token: existing,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existing,
platform: self.platformString(),
version: self.appVersion())
version: self.appVersion()
)
)
self.connectStatus = nil
}
.onChange(of: self.appModel.bridgeServerName) { _, _ in
@ -170,18 +173,22 @@ struct SettingsTab: View {
existing :
nil
let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint,
let hello = BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existingToken,
platform: self.platformString(),
version: self.appVersion(),
existingToken: existingToken,
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(
@ -192,11 +199,14 @@ struct SettingsTab: View {
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
token: token,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: token,
platform: self.platformString(),
version: self.appVersion())
version: self.appVersion()
)
)
} catch {
self.connectStatus = "Failed: \(error.localizedDescription)"

View File

@ -90,13 +90,16 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
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) -> \(standardizedFile.path, privacy: .public)")
"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 \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
"failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)")
return self.html("Failed to read file.", title: "Canvas error")
}
}

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

@ -148,7 +148,10 @@ final class ControlChannel: ObservableObject {
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)"
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

View File

@ -10,63 +10,154 @@ 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:
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
let notify = NotifyRequest(
title: title,
body: body,
sound: sound,
priority: priority,
delivery: delivery
)
return await self.handleNotify(notify, notifier: notifier)
case let .ensurePermissions(caps, interactive):
return await self.handleEnsurePermissions(caps: caps, interactive: interactive)
case .status:
return paused
? Response(ok: false, message: "clawdis paused")
: Response(ok: true, message: "ready")
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: title, body: body, sound: chosenSound, priority: priority)
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: title, body: body)
NotifyOverlayController.shared.present(title: request.title, body: request.body)
}
return Response(ok: true)
case .auto:
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
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: title, body: body)
NotifyOverlayController.shared.present(title: request.title, body: request.body)
}
return Response(ok: true, message: "notification not authorized; used overlay")
}
}
case let .ensurePermissions(caps, interactive):
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 .status:
return paused ? Response(ok: false, message: "clawdis paused") : Response(ok: true, message: "ready")
case .rpcStatus:
private static func handleRPCStatus() async -> Response {
let result = await AgentRPC.shared.status()
return Response(ok: result.ok, message: result.error)
}
case let .runShell(command, cwd, env, timeoutSec, needsSR):
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 let .agent(message, thinking, session, deliver, to):
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"
@ -76,79 +167,86 @@ enum ControlRequestHandler {
sessionKey: sessionKey,
deliver: deliver,
to: to,
channel: nil)
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")
}
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) }
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):
private static func handleCanvasHide(session: String) async -> Response {
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
return Response(ok: true)
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")
}
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) }
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):
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
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)
}
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")
}
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)
}
}
case .nodeList:
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) }
?? "{}"
options: [.prettyPrinted]
))
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
return Response(ok: true, payload: Data(payload.utf8))
}
case let .nodeInvoke(nodeId, command, paramsJSON):
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 {
@ -158,8 +256,8 @@ enum ControlRequestHandler {
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

@ -237,7 +237,7 @@ final actor ControlSocketServer {
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")
"allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)")
return true
}
#endif

View File

@ -70,7 +70,9 @@ struct CronSettings: View {
Spacer()
}
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` " +
"and the Gateway restarts."
)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@ -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)
@ -910,7 +918,8 @@ private struct CronJobEditor: View {
domain: "Cron",
code: 0,
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).",
])
}

View File

@ -147,7 +147,10 @@ struct DebugSettings: View {
.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.")
"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")
@ -233,7 +236,9 @@ struct DebugSettings: View {
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.")
"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())
@ -484,7 +489,9 @@ struct DebugSettings: View {
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.")
"When off, agent Canvas requests return “Canvas disabled by user”. " +
"Manual debug actions still work."
)
HStack(spacing: 8) {
TextField("Session", text: self.$canvasSessionKey)
@ -585,24 +592,14 @@ struct DebugSettings: View {
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.")
"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
private func runPortCheck() async {
self.portCheckInFlight = true
@ -760,7 +757,9 @@ struct DebugSettings: View {
.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,8 +879,20 @@ struct DebugSettings: View {
}
}
#if DEBUG
struct DebugSettings_Previews: PreviewProvider {
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

@ -127,13 +127,16 @@ actor GatewayEndpointStore {
}
switch next {
case let .ready(mode, url, _):
let modeDesc = String(describing: mode)
let urlDesc = url.absoluteString
self.logger
.debug(
"resolved endpoint mode=\(String(describing: mode), privacy: .public) url=\(url.absoluteString, privacy: .public)")
"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=\(String(describing: mode), privacy: .public) reason=\(reason, privacy: .public)")
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
}
}
}

View File

@ -1,10 +1,11 @@
import AppKit
import SwiftUI
struct GeneralSettings: View {
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?
@ -576,6 +577,7 @@ extension GeneralSettings {
alert.runModal()
}
// swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return }

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

@ -51,6 +51,7 @@ struct OnboardingView: View {
@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
@ -119,7 +120,9 @@ struct OnboardingView: View {
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.")
"Your macOS menu bar companion for notifications, screenshots, and agent automation — " +
"setup takes just a few minutes."
)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@ -140,9 +143,13 @@ struct OnboardingView: View {
.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.
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)
@ -159,7 +166,9 @@ struct OnboardingView: View {
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.")
"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)
@ -293,7 +302,9 @@ struct OnboardingView: View {
.lineLimit(2)
} else {
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. " +
"We keep the gateway on port 18789."
)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@ -303,6 +314,7 @@ struct OnboardingView: View {
}
}
// swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return }
@ -451,7 +463,8 @@ struct OnboardingView: View {
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`).
Create a bot with @BotFather and set the token as an env var
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
""",
systemImage: "key")
self.featureRow(
@ -480,7 +493,8 @@ struct OnboardingView: View {
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.",
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",

View File

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

View File

@ -192,7 +192,6 @@ final class WebChatServer: @unchecked Sendable {
status: 404,
mime: "text/plain",
body: Data("Not Found".utf8),
contentLength: "Not Found".utf8.count,
includeBody: includeBody,
over: connection)
return
@ -202,7 +201,6 @@ final class WebChatServer: @unchecked Sendable {
status: 200,
mime: mime,
body: data,
contentLength: data.count,
includeBody: includeBody,
over: connection)
}
@ -211,10 +209,10 @@ final class WebChatServer: @unchecked Sendable {
status: Int,
mime: String,
body: Data,
contentLength: Int,
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" +

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,96 +36,188 @@ enum BrowserCLI {
}
do {
return try await self.runCommand(sub: sub, options: options, baseURL: baseURL, jsonOutput: jsonOutput)
} catch {
let msg = self.describeError(error, baseURL: baseURL)
if jsonOutput {
self.printJSON(ok: false, result: ["error": msg])
} else {
fputs("\(msg)\n", stderr)
}
return 1
}
}
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":
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/")))
return 0
return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput)
case "start":
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/start"),
timeoutInterval: 15.0))
return 0
return try await self.handleStartStop(action: "start", baseURL: baseURL, jsonOutput: jsonOutput)
case "stop":
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/stop"),
timeoutInterval: 15.0))
return 0
return try await self.handleStartStop(action: "stop", baseURL: baseURL, jsonOutput: jsonOutput)
case "tabs":
let res = try await self.httpJSON(
method: "GET",
url: baseURL.appendingPathComponent("/tabs"),
timeoutInterval: 3.0)
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
}
case "open":
guard let url = rest.first, !url.isEmpty else {
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
}
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(
let url = baseURL.appendingPathComponent("/tabs/open")
let res = try await self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/tabs/open"),
body: ["url": url],
timeoutInterval: 15.0))
url: url,
body: ["url": urlString],
timeoutInterval: 15.0
)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
case "focus":
guard let id = rest.first, !id.isEmpty else {
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
}
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(
let url = baseURL.appendingPathComponent("/tabs/focus")
let res = try await self.httpJSON(
method: "POST",
url: baseURL.appendingPathComponent("/tabs/focus"),
url: url,
body: ["targetId": id],
timeoutInterval: 5.0))
timeoutInterval: 5.0
)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
case "close":
guard let id = rest.first, !id.isEmpty else {
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
}
try await self.printResult(
jsonOutput: jsonOutput,
res: self.httpJSON(
method: "DELETE",
url: baseURL.appendingPathComponent("/tabs/\(id)"),
timeoutInterval: 5.0))
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
}
case "screenshot":
private static func handleScreenshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
var url = baseURL.appendingPathComponent("/screenshot")
var items: [URLQueryItem] = []
if let targetId, !targetId.isEmpty {
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if fullPage {
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)
@ -180,40 +227,31 @@ enum BrowserCLI {
self.printResult(jsonOutput: false, res: res)
}
return 0
}
case "eval":
if jsStdin, jsFile != nil {
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: 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 ""
}()
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: baseURL.appendingPathComponent("/eval"),
url: url,
body: [
"js": code,
"targetId": targetId ?? "",
"await": awaitPromise,
"targetId": options.targetId ?? "",
"await": options.awaitPromise,
],
timeoutInterval: 15.0)
timeoutInterval: 15.0
)
if jsonOutput {
self.printJSON(ok: true, result: res)
@ -221,47 +259,69 @@ enum BrowserCLI {
self.printEval(res: res)
}
return 0
}
case "query":
let sel = (selector ?? rest.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
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, !targetId.isEmpty {
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let limit, limit > 0 {
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 || format == "json" {
if jsonOutput || options.format == "json" {
self.printJSON(ok: true, result: res)
} else {
self.printQuery(res: res)
}
return 0
}
case "dom":
let fmt = (format == "text") ? "text" : "html"
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, !targetId.isEmpty {
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let selector = selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty {
if let selector = options.selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty {
items.append(URLQueryItem(name: "selector", value: selector))
}
if let maxChars, maxChars > 0 {
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 = outPath, !out.isEmpty {
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])
@ -270,27 +330,29 @@ enum BrowserCLI {
}
return 0
}
if jsonOutput {
self.printJSON(ok: true, result: res)
} else {
print(text)
}
return 0
}
case "snapshot":
let fmt = (format == "domSnapshot") ? "domSnapshot" : "aria"
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, !targetId.isEmpty {
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let limit, limit > 0 {
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 = outPath, !out.isEmpty {
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 {
@ -307,20 +369,6 @@ enum BrowserCLI {
self.printSnapshotAria(res: res)
}
return 0
default:
self.printHelp()
return 2
}
} catch {
let msg = self.describeError(error, baseURL: baseURL)
if jsonOutput {
self.printJSON(ok: false, result: ["error": msg])
} else {
fputs("\(msg)\n", stderr)
}
return 1
}
}
private struct BrowserConfig {

View File

@ -60,11 +60,10 @@ struct ClawdisCLI {
}
}
// 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())
guard !args.isEmpty else { throw CLIError.help }
let command = args.removeFirst()
switch command {
case "--help", "-h", "help":
@ -74,6 +73,35 @@ struct ClawdisCLI {
throw CLIError.version
case "notify":
return try self.parseNotify(args: &args)
case "ensure-permissions":
return self.parseEnsurePermissions(args: &args)
case "run":
return self.parseRunShell(args: &args)
case "status":
return ParsedCLIRequest(request: .status, kind: .generic)
case "rpc-status":
return ParsedCLIRequest(request: .rpcStatus, kind: .generic)
case "agent":
return try self.parseAgent(args: &args)
case "node":
return try self.parseNode(args: &args)
case "canvas":
return try self.parseCanvas(args: &args)
default:
throw CLIError.help
}
}
private static func parseNotify(args: inout [String]) throws -> ParsedCLIRequest {
var title: String?
var body: String?
var sound: String?
@ -95,9 +123,11 @@ struct ClawdisCLI {
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)
kind: .generic
)
}
case "ensure-permissions":
private static func parseEnsurePermissions(args: inout [String]) -> ParsedCLIRequest {
var caps: [Capability] = []
var interactive = false
while !args.isEmpty {
@ -105,14 +135,17 @@ struct ClawdisCLI {
switch arg {
case "--cap":
if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) }
case "--interactive": interactive = true
default: break
case "--interactive":
interactive = true
default:
break
}
}
if caps.isEmpty { caps = Capability.allCases }
return ParsedCLIRequest(request: .ensurePermissions(caps, interactive: interactive), kind: .generic)
}
case "run":
private static func parseRunShell(args: inout [String]) -> ParsedCLIRequest {
var cwd: String?
var env: [String: String] = [:]
var timeout: Double?
@ -121,35 +154,40 @@ struct ClawdisCLI {
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--cwd": cwd = args.popFirst()
case "--cwd":
cwd = args.popFirst()
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
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
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(
return ParsedCLIRequest(
request: .runShell(
command: cmd,
cwd: cwd,
env: env.isEmpty ? nil : env,
timeoutSec: timeout,
needsScreenRecording: needsSR), kind: .generic)
needsScreenRecording: needsSR
),
kind: .generic
)
}
case "status":
return ParsedCLIRequest(request: .status, kind: .generic)
private static func parseEnvPair(_ pair: String, into env: inout [String: String]) {
guard let eq = pair.firstIndex(of: "=") else { return }
let key = String(pair[..<eq])
let value = String(pair[pair.index(after: eq)...])
env[key] = value
}
case "rpc-status":
return ParsedCLIRequest(request: .rpcStatus, kind: .generic)
case "agent":
private static func parseAgent(args: inout [String]) throws -> ParsedCLIRequest {
var message: String?
var thinking: String?
var session: String?
@ -165,7 +203,6 @@ struct ClawdisCLI {
case "--deliver": deliver = true
case "--to": to = args.popFirst()
default:
// Support bare message as last argument
if message == nil {
message = arg
}
@ -175,16 +212,15 @@ struct ClawdisCLI {
guard let message else { throw CLIError.help }
return ParsedCLIRequest(
request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to),
kind: .generic)
case "node":
guard let sub = args.first else { throw CLIError.help }
args = Array(args.dropFirst())
kind: .generic
)
}
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?
@ -201,43 +237,24 @@ struct ClawdisCLI {
guard let nodeId, let command else { throw CLIError.help }
return ParsedCLIRequest(
request: .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON),
kind: .generic)
kind: .generic
)
default:
throw CLIError.help
}
}
case "canvas":
guard let sub = args.first else { throw CLIError.help }
args = Array(args.dropFirst())
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?
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
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
return ParsedCLIRequest(
request: .canvasShow(session: session, path: path, placement: placement),
kind: .generic)
kind: .generic
)
case "hide":
var session = "main"
while !args.isEmpty {
@ -248,34 +265,15 @@ struct ClawdisCLI {
}
}
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
}
}
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
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)
kind: .generic
)
case "eval":
var session = "main"
var js: String?
@ -289,7 +287,6 @@ struct ClawdisCLI {
}
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?
@ -302,17 +299,35 @@ struct ClawdisCLI {
}
}
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 {
@ -495,7 +510,7 @@ struct ClawdisCLI {
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)
guard let path = String(bytes: bytes, encoding: .utf8) else { return nil }
return URL(fileURLWithPath: path).resolvingSymlinksInPath()
}

View File

@ -328,8 +328,9 @@ enum UICLI {
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"
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))
}
}
@ -524,8 +525,10 @@ enum UICLI {
do {
return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle)
} catch {
let command = "clawdis-mac ui see --bundle-id \(resolvedBundle)"
let help = "No recent snapshot for \(resolvedBundle). Run `\(command)` first."
throw NSError(domain: "clawdis.ui", code: 6, userInfo: [
NSLocalizedDescriptionKey: "No recent snapshot for \(resolvedBundle). Run `clawdis-mac ui see --bundle-id \(resolvedBundle)` first.",
NSLocalizedDescriptionKey: help,
])
}
}