From 0b532579d8c84769643ccb3558a81ac0347bc662 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Dec 2025 21:18:46 +0000 Subject: [PATCH] feat(bridge): add Bonjour node bridge --- .gitignore | 3 + apps/macos/Package.swift | 2 + .../Bridge/BridgeConnectionHandler.swift | 273 ++++++++++++++++++ .../Sources/Clawdis/Bridge/BridgeServer.swift | 259 +++++++++++++++++ .../Clawdis/Bridge/PairedNodesStore.swift | 57 ++++ .../Clawdis/ControlRequestHandler.swift | 32 +- .../macos/Sources/Clawdis/DebugSettings.swift | 15 +- .../Sources/Clawdis/DiagnosticsFileLog.swift | 3 +- apps/macos/Sources/Clawdis/MenuBar.swift | 2 + apps/macos/Sources/ClawdisCLI/main.swift | 34 ++- apps/macos/Sources/ClawdisIPC/IPC.swift | 29 +- apps/shared/ClawdisNodeKit/Package.swift | 22 ++ .../Sources/ClawdisNodeKit/BonjourTypes.swift | 7 + .../Sources/ClawdisNodeKit/BridgeFrames.swift | 156 ++++++++++ .../Sources/ClawdisNodeKit/NodeError.swift | 28 ++ .../ClawdisNodeKit/ScreenCommands.swift | 56 ++++ .../Sources/ClawdisNodeKit/StoragePaths.swift | 37 +++ 17 files changed, 1002 insertions(+), 13 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift create mode 100644 apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift create mode 100644 apps/macos/Sources/Clawdis/Bridge/PairedNodesStore.swift create mode 100644 apps/shared/ClawdisNodeKit/Package.swift create mode 100644 apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/BonjourTypes.swift create mode 100644 apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/BridgeFrames.swift create mode 100644 apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/NodeError.swift create mode 100644 apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/ScreenCommands.swift create mode 100644 apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/StoragePaths.swift diff --git a/.gitignore b/.gitignore index 2d4fdb478..1405d1165 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 9f1d4e788..9a83fa0d8 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -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"), diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift new file mode 100644 index 000000000..97218fb27 --- /dev/null +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift @@ -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] = [:] + 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) 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) 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) + } + } +} diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift new file mode 100644 index 000000000..e179e41f1 --- /dev/null +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift @@ -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 + } +} diff --git a/apps/macos/Sources/Clawdis/Bridge/PairedNodesStore.swift b/apps/macos/Sources/Clawdis/Bridge/PairedNodesStore.swift new file mode 100644 index 000000000..90eaad7c5 --- /dev/null +++ b/apps/macos/Sources/Clawdis/Bridge/PairedNodesStore.swift @@ -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]) + } +} diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index 58598ad99..f733ba369 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -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) + } } } } diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index a26ee0bf0..72d89ca2e 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -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 (don’t 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) diff --git a/apps/macos/Sources/Clawdis/DiagnosticsFileLog.swift b/apps/macos/Sources/Clawdis/DiagnosticsFileLog.swift index 8d2773538..db5b69614 100644 --- a/apps/macos/Sources/Clawdis/DiagnosticsFileLog.swift +++ b/apps/macos/Sources/Clawdis/DiagnosticsFileLog.swift @@ -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) } } - diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 801535414..32329e7eb 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -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 diff --git a/apps/macos/Sources/ClawdisCLI/main.swift b/apps/macos/Sources/ClawdisCLI/main.swift index d89daf2be..3bbe15564 100644 --- a/apps/macos/Sources/ClawdisCLI/main.swift +++ b/apps/macos/Sources/ClawdisCLI/main.swift @@ -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 [--thinking ] [--session ] [--deliver] [--to ] + clawdis-mac node list + clawdis-mac node invoke --node --command [--params-json ] clawdis-mac canvas show [--session ] [--path ] [--x --y ] [--width --height ] clawdis-mac canvas hide [--session ] @@ -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 diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index db9134b0b..c32d5e661 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -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) } } } diff --git a/apps/shared/ClawdisNodeKit/Package.swift b/apps/shared/ClawdisNodeKit/Package.swift new file mode 100644 index 000000000..398838062 --- /dev/null +++ b/apps/shared/ClawdisNodeKit/Package.swift @@ -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"), + ]), + ]) + diff --git a/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/BonjourTypes.swift b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/BonjourTypes.swift new file mode 100644 index 000000000..83825bc2d --- /dev/null +++ b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/BonjourTypes.swift @@ -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." +} diff --git a/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/BridgeFrames.swift b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/BridgeFrames.swift new file mode 100644 index 000000000..0e76504ee --- /dev/null +++ b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/BridgeFrames.swift @@ -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 + } +} diff --git a/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/NodeError.swift b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/NodeError.swift new file mode 100644 index 000000000..a5d1c988c --- /dev/null +++ b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/NodeError.swift @@ -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 + } +} diff --git a/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/ScreenCommands.swift b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/ScreenCommands.swift new file mode 100644 index 000000000..665562e5e --- /dev/null +++ b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/ScreenCommands.swift @@ -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 + } +} diff --git a/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/StoragePaths.swift b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/StoragePaths.swift new file mode 100644 index 000000000..ff3cca8a9 --- /dev/null +++ b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/StoragePaths.swift @@ -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) + } +}