feat(bridge): add Bonjour node bridge

This commit is contained in:
Peter Steinberger
2025-12-12 21:18:46 +00:00
parent b9007dc721
commit 0b532579d8
17 changed files with 1002 additions and 13 deletions

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ apps/macos/.build/
bin/clawdis-mac
apps/macos/.build-local/
apps/macos/.swiftpm/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/

View File

@@ -17,6 +17,7 @@ let package = Package(
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
.package(path: "../shared/ClawdisNodeKit"),
],
targets: [
.target(
@@ -37,6 +38,7 @@ let package = Package(
dependencies: [
"ClawdisIPC",
"ClawdisProtocol",
.product(name: "ClawdisNodeKit", package: "ClawdisNodeKit"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Sparkle", package: "Sparkle"),

View File

@@ -0,0 +1,273 @@
import ClawdisNodeKit
import Foundation
import Network
import OSLog
actor BridgeConnectionHandler {
private let connection: NWConnection
private let logger: Logger
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let queue = DispatchQueue(label: "com.steipete.clawdis.bridge.connection")
private var buffer = Data()
private var isAuthenticated = false
private var nodeId: String?
private var pendingInvokes: [String: CheckedContinuation<BridgeInvokeResponse, Error>] = [:]
private var isClosed = false
init(connection: NWConnection, logger: Logger) {
self.connection = connection
self.logger = logger
}
enum AuthResult: Sendable {
case ok
case notPaired
case unauthorized
case error(code: String, message: String)
}
enum PairResult: Sendable {
case ok(token: String)
case rejected
case error(code: String, message: String)
}
func run(
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
onAuthenticated: (@Sendable (String) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil) async
{
self.connection.stateUpdateHandler = { [logger] state in
switch state {
case .ready:
logger.debug("bridge conn ready")
case let .failed(err):
logger.error("bridge conn failed: \(err.localizedDescription, privacy: .public)")
default:
break
}
}
self.connection.start(queue: self.queue)
while true {
do {
guard let line = try await self.receiveLine() else { break }
guard let data = line.data(using: .utf8) else { continue }
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
switch base.type {
case "hello":
let hello = try self.decoder.decode(BridgeHello.self, from: data)
self.nodeId = hello.nodeId
let result = await resolveAuth(hello)
await self.handleAuthResult(
result,
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
if case .ok = result, let nodeId = self.nodeId {
await onAuthenticated?(nodeId)
}
case "pair-request":
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
self.nodeId = req.nodeId
let result = await handlePair(req)
await self.handlePairResult(
result,
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
if case .ok = result, let nodeId = self.nodeId {
await onAuthenticated?(nodeId)
}
case "event":
guard self.isAuthenticated, let nodeId = self.nodeId else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
await onEvent?(nodeId, evt)
case "ping":
if !self.isAuthenticated {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let ping = try self.decoder.decode(BridgePing.self, from: data)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "invoke-res":
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
default:
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
await self.close(with: onDisconnected)
}
private func handlePairResult(_ result: PairResult, serverName: String) async {
switch result {
case let .ok(token):
do {
try await self.send(BridgePairOk(type: "pair-ok", token: token))
self.isAuthenticated = true
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
} catch {
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
}
case .rejected:
await self.sendError(code: "UNAUTHORIZED", message: "pairing rejected")
case let .error(code, message):
await self.sendError(code: code, message: message)
}
}
private func handleAuthResult(_ result: AuthResult, serverName: String) async {
switch result {
case .ok:
self.isAuthenticated = true
do {
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
} catch {
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
}
case .notPaired:
await self.sendError(code: "NOT_PAIRED", message: "pairing required")
case .unauthorized:
await self.sendError(code: "UNAUTHORIZED", message: "invalid token")
case let .error(code, message):
await self.sendError(code: code, message: message)
}
}
private func sendError(code: String, message: String) async {
do {
try await self.send(BridgeErrorFrame(type: "error", code: code, message: message))
} catch {
self.logger.error("bridge send error failed: \(error.localizedDescription, privacy: .public)")
}
}
func invoke(command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
guard self.isAuthenticated else {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "UNAUTHORIZED: not authenticated",
])
}
let id = UUID().uuidString
let req = BridgeInvokeRequest(type: "invoke", id: id, command: command, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: 15 * 1_000_000_000)
await self.timeoutInvoke(id: id)
}
defer { timeoutTask.cancel() }
return try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginInvoke(id: id, request: req, continuation: cont)
}
}
}
private func beginInvoke(
id: String,
request: BridgeInvokeRequest,
continuation: CheckedContinuation<BridgeInvokeResponse, Error>) async
{
self.pendingInvokes[id] = continuation
do {
try await self.send(request)
} catch {
await self.failInvoke(id: id, error: error)
}
}
private func timeoutInvoke(id: String) async {
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: invoke timeout",
]))
}
private func failInvoke(id: String, error: Error) async {
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
cont.resume(throwing: error)
}
private func send(_ obj: some Encodable) async throws {
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A) // \n
let _: Void = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
self.connection.send(content: line, completion: .contentProcessed { err in
if let err {
cont.resume(throwing: err)
} else {
cont.resume(returning: ())
}
})
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let lineData = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return String(data: lineData, encoding: .utf8)
}
let chunk = try await self.receiveChunk()
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
}
private func receiveChunk() async throws -> Data {
try await withCheckedThrowingContinuation { cont in
self.connection
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func close(with onDisconnected: (@Sendable (String) async -> Void)? = nil) async {
if self.isClosed { return }
self.isClosed = true
let nodeId = self.nodeId
let pending = self.pendingInvokes.values
self.pendingInvokes.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 4, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
self.connection.cancel()
if let nodeId {
await onDisconnected?(nodeId)
}
}
}

View File

@@ -0,0 +1,259 @@
import AppKit
import ClawdisNodeKit
import Foundation
import Network
import OSLog
actor BridgeServer {
static let shared = BridgeServer()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "bridge")
private var listener: NWListener?
private var isRunning = false
private var store: PairedNodesStore?
private var connections: [String: BridgeConnectionHandler] = [:]
func start() async {
if self.isRunning { return }
self.isRunning = true
do {
let storeURL = try Self.defaultStoreURL()
let store = PairedNodesStore(fileURL: storeURL)
await store.load()
self.store = store
let params = NWParameters.tcp
let listener = try NWListener(using: params, on: .any)
let name = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
listener.service = NWListener.Service(
name: "\(name) (Clawdis)",
type: ClawdisBonjour.bridgeServiceType,
domain: ClawdisBonjour.bridgeServiceDomain,
txtRecord: nil)
listener.newConnectionHandler = { [weak self] connection in
guard let self else { return }
Task { await self.handle(connection: connection) }
}
listener.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleListenerState(state) }
}
listener.start(queue: DispatchQueue(label: "com.steipete.clawdis.bridge"))
self.listener = listener
} catch {
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")
self.isRunning = false
}
}
func stop() async {
self.isRunning = false
self.listener?.cancel()
self.listener = nil
}
private func handleListenerState(_ state: NWListener.State) {
switch state {
case .ready:
self.logger.info("bridge listening")
case let .failed(err):
self.logger.error("bridge listener failed: \(err.localizedDescription, privacy: .public)")
case .cancelled:
self.logger.info("bridge listener cancelled")
case .waiting:
self.logger.info("bridge listener waiting")
case .setup:
break
@unknown default:
break
}
}
private func handle(connection: NWConnection) async {
let handler = BridgeConnectionHandler(connection: connection, logger: self.logger)
await handler.run(
resolveAuth: { [weak self] hello in
await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
},
handlePair: { [weak self] request in
await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
},
onAuthenticated: { [weak self] nodeId in
await self?.registerConnection(handler: handler, nodeId: nodeId)
},
onDisconnected: { [weak self] nodeId in
await self?.unregisterConnection(nodeId: nodeId)
},
onEvent: { [weak self] nodeId, evt in
await self?.handleEvent(nodeId: nodeId, evt: evt)
})
}
func invoke(nodeId: String, command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
guard let handler = self.connections[nodeId] else {
throw NSError(domain: "Bridge", code: 10, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: node not connected",
])
}
return try await handler.invoke(command: command, paramsJSON: paramsJSON)
}
func connectedNodeIds() -> [String] {
Array(self.connections.keys).sorted()
}
private func registerConnection(handler: BridgeConnectionHandler, nodeId: String) async {
self.connections[nodeId] = handler
await self.beacon(text: "Node connected", nodeId: nodeId, tags: ["node", "ios"])
}
private func unregisterConnection(nodeId: String) async {
self.connections.removeValue(forKey: nodeId)
await self.beacon(text: "Node disconnected", nodeId: nodeId, tags: ["node", "ios"])
}
private struct VoiceTranscriptPayload: Codable, Sendable {
var text: String
var sessionKey: String?
}
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
switch evt.event {
case "voice.transcript":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
}
guard let payload = try? JSONDecoder().decode(VoiceTranscriptPayload.self, from: data) else {
return
}
let text = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "node-\(nodeId)"
_ = await AgentRPC.shared.send(
text: text,
thinking: "low",
sessionKey: sessionKey,
deliver: false,
to: nil,
channel: "last")
default:
break
}
}
private func beacon(text: String, nodeId: String, tags: [String]) async {
do {
let params: [String: Any] = [
"text": "\(text): \(nodeId)",
"instanceId": nodeId,
"mode": "node",
"tags": tags,
]
_ = try await AgentRPC.shared.controlRequest(
method: "system-event",
params: ControlRequestParams(raw: params))
} catch {
// Best-effort only.
}
}
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
if nodeId.isEmpty {
return .error(code: "INVALID_REQUEST", message: "nodeId required")
}
guard let store = self.store else {
return .error(code: "UNAVAILABLE", message: "store unavailable")
}
guard let paired = await store.find(nodeId: nodeId) else {
return .notPaired
}
guard let token = hello.token, token == paired.token else {
return .unauthorized
}
do { try await store.touchSeen(nodeId: nodeId) } catch { /* ignore */ }
return .ok
}
private func pair(request: BridgePairRequest) async -> BridgeConnectionHandler.PairResult {
let nodeId = request.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
if nodeId.isEmpty {
return .error(code: "INVALID_REQUEST", message: "nodeId required")
}
guard let store = self.store else {
return .error(code: "UNAVAILABLE", message: "store unavailable")
}
let existing = await store.find(nodeId: nodeId)
let approved = await BridgePairingApprover.approve(request: request, isRepair: existing != nil)
if !approved {
return .rejected
}
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
let node = PairedNode(
nodeId: nodeId,
displayName: request.displayName,
platform: request.platform,
version: request.version,
token: token,
createdAtMs: nowMs,
lastSeenAtMs: nowMs)
do {
try await store.upsert(node)
return .ok(token: token)
} catch {
return .error(code: "UNAVAILABLE", message: "failed to persist pairing")
}
}
private static func defaultStoreURL() throws -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(
domain: "Bridge",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Application Support unavailable"])
}
return base
.appendingPathComponent("Clawdis", isDirectory: true)
.appendingPathComponent("bridge", isDirectory: true)
.appendingPathComponent("paired-nodes.json", isDirectory: false)
}
}
@MainActor
enum BridgePairingApprover {
static func approve(request: BridgePairRequest, isRepair: Bool) async -> Bool {
await withCheckedContinuation { cont in
let name = request.displayName ?? request.nodeId
let alert = NSAlert()
alert.messageText = isRepair ? "Re-pair Clawdis Node?" : "Pair Clawdis Node?"
alert.informativeText = """
Node: \(name)
Platform: \(request.platform ?? "unknown")
Version: \(request.version ?? "unknown")
"""
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
let resp = alert.runModal()
cont.resume(returning: resp == .alertFirstButtonReturn)
}
}
}
extension String {
fileprivate var nonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
struct PairedNode: Codable, Equatable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var token: String
var createdAtMs: Int
var lastSeenAtMs: Int?
}
actor PairedNodesStore {
private let fileURL: URL
private var nodes: [String: PairedNode] = [:]
init(fileURL: URL) {
self.fileURL = fileURL
}
func load() {
do {
let data = try Data(contentsOf: self.fileURL)
let decoded = try JSONDecoder().decode([String: PairedNode].self, from: data)
self.nodes = decoded
} catch {
self.nodes = [:]
}
}
func all() -> [PairedNode] {
self.nodes.values.sorted { a, b in (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) }
}
func find(nodeId: String) -> PairedNode? {
self.nodes[nodeId]
}
func upsert(_ node: PairedNode) async throws {
self.nodes[node.nodeId] = node
try await self.persist()
}
func touchSeen(nodeId: String) async throws {
guard var node = self.nodes[nodeId] else { return }
node.lastSeenAtMs = Int(Date().timeIntervalSince1970 * 1000)
self.nodes[nodeId] = node
try await self.persist()
}
private func persist() async throws {
let dir = self.fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(self.nodes)
try data.write(to: self.fileURL, options: [.atomic])
}
}

View File

@@ -90,7 +90,10 @@ enum ControlRequestHandler {
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)
@@ -105,7 +108,10 @@ enum ControlRequestHandler {
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)
@@ -132,6 +138,28 @@ enum ControlRequestHandler {
} catch {
return Response(ok: false, message: error.localizedDescription)
}
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))
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)
}
}
}
}

View File

@@ -62,7 +62,8 @@ struct DebugSettings: View {
VStack(alignment: .leading, spacing: 6) {
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
.toggleStyle(.switch)
.help("Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. Enable only while actively debugging.")
.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())
@@ -90,7 +91,8 @@ struct DebugSettings: View {
}
Toggle("Only attach to existing gateway (dont spawn locally)", isOn: self.$attachExistingGatewayOnly)
.toggleStyle(.switch)
.help("When enabled in local mode, the mac app will only connect to an already-running gateway and will not start one itself.")
.help(
"When enabled in local mode, the mac app will only connect to an already-running gateway and will not start one itself.")
VStack(alignment: .leading, spacing: 4) {
Text("Gateway stdout/stderr")
.font(.caption.weight(.semibold))
@@ -295,7 +297,8 @@ struct DebugSettings: View {
.font(.caption.weight(.semibold))
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.switch)
.help("When off, agent Canvas requests return “Canvas disabled by user”. Manual debug actions still work.")
.help(
"When off, agent Canvas requests return “Canvas disabled by user”. Manual debug actions still work.")
HStack(spacing: 8) {
TextField("Session", text: self.$canvasSessionKey)
.textFieldStyle(.roundedBorder)
@@ -353,7 +356,8 @@ struct DebugSettings: View {
.truncationMode(.middle)
.textSelection(.enabled)
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
NSWorkspace.shared
.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
}
.buttonStyle(.bordered)
}
@@ -379,7 +383,8 @@ struct DebugSettings: View {
}
Toggle("Use SwiftUI web chat (glass, gateway WS)", isOn: self.$webChatSwiftUIEnabled)
.toggleStyle(.switch)
.help("When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the bundled WKWebView.")
.help(
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the bundled WKWebView.")
Spacer(minLength: 8)
}
.frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -28,7 +28,7 @@ actor DiagnosticsFileLog {
}
nonisolated static func logFileURL() -> URL {
Self.logDirectoryURL().appendingPathComponent("diagnostics.jsonl", isDirectory: false)
self.logDirectoryURL().appendingPathComponent("diagnostics.jsonl", isDirectory: false)
}
nonisolated func log(category: String, event: String, fields: [String: String]? = nil) {
@@ -131,4 +131,3 @@ actor DiagnosticsFileLog {
Self.logDirectoryURL().appendingPathComponent("\(self.fileName).\(index)", isDirectory: false)
}
}

View File

@@ -172,6 +172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { await HealthStore.shared.refresh(onDemand: true) }
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
Task { await self.socketServer.start() }
Task { await BridgeServer.shared.start() }
self.scheduleFirstRunOnboardingIfNeeded()
// Developer/testing helper: auto-open WebChat when launched with --webchat
@@ -189,6 +190,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { await RemoteTunnelManager.shared.stopAll() }
Task { await AgentRPC.shared.shutdown() }
Task { await self.socketServer.stop() }
Task { await BridgeServer.shared.stop() }
}
@MainActor

View File

@@ -1,6 +1,6 @@
import ClawdisIPC
import Foundation
import Darwin
import Foundation
@main
struct ClawdisCLI {
@@ -163,6 +163,34 @@ struct ClawdisCLI {
guard let message else { throw CLIError.help }
return .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to)
case "node":
guard let sub = args.first else { throw CLIError.help }
args = Array(args.dropFirst())
switch sub {
case "list":
return .nodeList
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 .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
default:
throw CLIError.help
}
case "canvas":
guard let sub = args.first else { throw CLIError.help }
args = Array(args.dropFirst())
@@ -281,6 +309,8 @@ struct ClawdisCLI {
clawdis-mac rpc-status
clawdis-mac agent --message <text> [--thinking <low|default|high>]
[--session <key>] [--deliver] [--to <E.164>]
clawdis-mac node list
clawdis-mac node invoke --node <id> --command <name> [--params-json <json>]
clawdis-mac canvas show [--session <key>] [--path </...>]
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
clawdis-mac canvas hide [--session <key>]
@@ -336,7 +366,7 @@ struct ClawdisCLI {
}
private static func resolveExecutableURL() -> URL? {
var size: UInt32 = UInt32(PATH_MAX)
var size = UInt32(PATH_MAX)
var buffer = [CChar](repeating: 0, count: Int(size))
let result = buffer.withUnsafeMutableBufferPointer { ptr in

View File

@@ -16,8 +16,8 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
/// Notification interruption level (maps to UNNotificationInterruptionLevel)
public enum NotificationPriority: String, Codable, Sendable {
case passive // silent, no wake
case active // default
case passive // silent, no wake
case active // default
case timeSensitive // breaks through Focus modes
}
@@ -72,6 +72,8 @@ public enum Request: Sendable {
case canvasGoto(session: String, path: String, placement: CanvasPlacement?)
case canvasEval(session: String, javaScript: String)
case canvasSnapshot(session: String, outPath: String?)
case nodeList
case nodeInvoke(nodeId: String, command: String, paramsJSON: String?)
}
// MARK: - Responses
@@ -104,6 +106,9 @@ extension Request: Codable {
case javaScript
case outPath
case placement
case nodeId
case nodeCommand
case paramsJSON
}
private enum Kind: String, Codable {
@@ -119,6 +124,8 @@ extension Request: Codable {
case canvasGoto
case canvasEval
case canvasSnapshot
case nodeList
case nodeInvoke
}
public func encode(to encoder: Encoder) throws {
@@ -190,6 +197,15 @@ extension Request: Codable {
try container.encode(Kind.canvasSnapshot, forKey: .type)
try container.encode(session, forKey: .session)
try container.encodeIfPresent(outPath, forKey: .outPath)
case .nodeList:
try container.encode(Kind.nodeList, forKey: .type)
case let .nodeInvoke(nodeId, command, paramsJSON):
try container.encode(Kind.nodeInvoke, forKey: .type)
try container.encode(nodeId, forKey: .nodeId)
try container.encode(command, forKey: .nodeCommand)
try container.encodeIfPresent(paramsJSON, forKey: .paramsJSON)
}
}
@@ -263,6 +279,15 @@ extension Request: Codable {
let session = try container.decode(String.self, forKey: .session)
let outPath = try container.decodeIfPresent(String.self, forKey: .outPath)
self = .canvasSnapshot(session: session, outPath: outPath)
case .nodeList:
self = .nodeList
case .nodeInvoke:
let nodeId = try container.decode(String.self, forKey: .nodeId)
let command = try container.decode(String.self, forKey: .nodeCommand)
let paramsJSON = try container.decodeIfPresent(String.self, forKey: .paramsJSON)
self = .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
}
}
}

View File

@@ -0,0 +1,22 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "ClawdisNodeKit",
platforms: [
.iOS(.v17),
.macOS(.v15),
],
products: [
.library(name: "ClawdisNodeKit", targets: ["ClawdisNodeKit"]),
],
targets: [
.target(
name: "ClawdisNodeKit",
dependencies: [],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
])

View File

@@ -0,0 +1,7 @@
import Foundation
public enum ClawdisBonjour {
// v0: internal-only, subject to rename.
public static let bridgeServiceType = "_clawdis-bridge._tcp"
public static let bridgeServiceDomain = "local."
}

View File

@@ -0,0 +1,156 @@
import Foundation
public struct BridgeBaseFrame: Codable, Sendable {
public let type: String
public init(type: String) {
self.type = type
}
}
public struct BridgeInvokeRequest: Codable, Sendable {
public let type: String
public let id: String
public let command: String
public let paramsJSON: String?
public init(type: String = "invoke", id: String, command: String, paramsJSON: String? = nil) {
self.type = type
self.id = id
self.command = command
self.paramsJSON = paramsJSON
}
}
public struct BridgeInvokeResponse: Codable, Sendable {
public let type: String
public let id: String
public let ok: Bool
public let payloadJSON: String?
public let error: ClawdisNodeError?
public init(
type: String = "invoke-res",
id: String,
ok: Bool,
payloadJSON: String? = nil,
error: ClawdisNodeError? = nil)
{
self.type = type
self.id = id
self.ok = ok
self.payloadJSON = payloadJSON
self.error = error
}
}
public struct BridgeEventFrame: Codable, Sendable {
public let type: String
public let event: String
public let payloadJSON: String?
public init(type: String = "event", event: String, payloadJSON: String? = nil) {
self.type = type
self.event = event
self.payloadJSON = payloadJSON
}
}
public struct BridgeHello: Codable, Sendable {
public let type: String
public let nodeId: String
public let displayName: String?
public let token: String?
public let platform: String?
public let version: String?
public init(
type: String = "hello",
nodeId: String,
displayName: String?,
token: String?,
platform: String?,
version: String?)
{
self.type = type
self.nodeId = nodeId
self.displayName = displayName
self.token = token
self.platform = platform
self.version = version
}
}
public struct BridgeHelloOk: Codable, Sendable {
public let type: String
public let serverName: String
public init(type: String = "hello-ok", serverName: String) {
self.type = type
self.serverName = serverName
}
}
public struct BridgePairRequest: Codable, Sendable {
public let type: String
public let nodeId: String
public let displayName: String?
public let platform: String?
public let version: String?
public init(
type: String = "pair-request",
nodeId: String,
displayName: String?,
platform: String?,
version: String?)
{
self.type = type
self.nodeId = nodeId
self.displayName = displayName
self.platform = platform
self.version = version
}
}
public struct BridgePairOk: Codable, Sendable {
public let type: String
public let token: String
public init(type: String = "pair-ok", token: String) {
self.type = type
self.token = token
}
}
public struct BridgePing: Codable, Sendable {
public let type: String
public let id: String
public init(type: String = "ping", id: String) {
self.type = type
self.id = id
}
}
public struct BridgePong: Codable, Sendable {
public let type: String
public let id: String
public init(type: String = "pong", id: String) {
self.type = type
self.id = id
}
}
public struct BridgeErrorFrame: Codable, Sendable {
public let type: String
public let code: String
public let message: String
public init(type: String = "error", code: String, message: String) {
self.type = type
self.code = code
self.message = message
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
public enum ClawdisNodeErrorCode: String, Codable, Sendable {
case notPaired = "NOT_PAIRED"
case unauthorized = "UNAUTHORIZED"
case backgroundUnavailable = "NODE_BACKGROUND_UNAVAILABLE"
case invalidRequest = "INVALID_REQUEST"
case unavailable = "UNAVAILABLE"
}
public struct ClawdisNodeError: Error, Codable, Sendable, Equatable {
public var code: ClawdisNodeErrorCode
public var message: String
public var retryable: Bool?
public var retryAfterMs: Int?
public init(
code: ClawdisNodeErrorCode,
message: String,
retryable: Bool? = nil,
retryAfterMs: Int? = nil)
{
self.code = code
self.message = message
self.retryable = retryable
self.retryAfterMs = retryAfterMs
}
}

View File

@@ -0,0 +1,56 @@
import Foundation
public enum ClawdisScreenMode: String, Codable, Sendable {
case canvas
case web
}
public enum ClawdisScreenCommand: String, Codable, Sendable {
case show = "screen.show"
case hide = "screen.hide"
case setMode = "screen.setMode"
case navigate = "screen.navigate"
case evalJS = "screen.eval"
case snapshot = "screen.snapshot"
}
public struct ClawdisScreenNavigateParams: Codable, Sendable, Equatable {
public var url: String
public init(url: String) {
self.url = url
}
}
public struct ClawdisScreenSetModeParams: Codable, Sendable, Equatable {
public var mode: ClawdisScreenMode
public init(mode: ClawdisScreenMode) {
self.mode = mode
}
}
public struct ClawdisScreenEvalParams: Codable, Sendable, Equatable {
public var javaScript: String
public init(javaScript: String) {
self.javaScript = javaScript
}
}
public enum ClawdisSnapshotFormat: String, Codable, Sendable {
case png
case jpeg
}
public struct ClawdisScreenSnapshotParams: Codable, Sendable, Equatable {
public var maxWidth: Int?
public var quality: Double?
public var format: ClawdisSnapshotFormat?
public init(maxWidth: Int? = nil, quality: Double? = nil, format: ClawdisSnapshotFormat? = nil) {
self.maxWidth = maxWidth
self.quality = quality
self.format = format
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
public enum ClawdisNodeStorage {
public static func appSupportDir() throws -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(domain: "ClawdisNodeStorage", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Application Support directory unavailable",
])
}
return base.appendingPathComponent("Clawdis", isDirectory: true)
}
public static func canvasRoot(sessionKey: String) throws -> URL {
let root = try appSupportDir().appendingPathComponent("canvas", isDirectory: true)
let safe = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let session = safe.isEmpty ? "main" : safe
return root.appendingPathComponent(session, isDirectory: true)
}
public static func cachesDir() throws -> URL {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(domain: "ClawdisNodeStorage", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Caches directory unavailable",
])
}
return base.appendingPathComponent("Clawdis", isDirectory: true)
}
public static func canvasSnapshotsRoot(sessionKey: String) throws -> URL {
let root = try cachesDir().appendingPathComponent("canvas-snapshots", isDirectory: true)
let safe = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let session = safe.isEmpty ? "main" : safe
return root.appendingPathComponent(session, isDirectory: true)
}
}