feat(bridge): add Bonjour node bridge
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ apps/macos/.build/
|
|||||||
bin/clawdis-mac
|
bin/clawdis-mac
|
||||||
apps/macos/.build-local/
|
apps/macos/.build-local/
|
||||||
apps/macos/.swiftpm/
|
apps/macos/.swiftpm/
|
||||||
|
apps/ios/*.xcodeproj/
|
||||||
|
apps/ios/*.xcworkspace/
|
||||||
|
apps/ios/.swiftpm/
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
.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/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
||||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||||
|
.package(path: "../shared/ClawdisNodeKit"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -37,6 +38,7 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
"ClawdisIPC",
|
"ClawdisIPC",
|
||||||
"ClawdisProtocol",
|
"ClawdisProtocol",
|
||||||
|
.product(name: "ClawdisNodeKit", package: "ClawdisNodeKit"),
|
||||||
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
||||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||||
.product(name: "Sparkle", package: "Sparkle"),
|
.product(name: "Sparkle", package: "Sparkle"),
|
||||||
|
|||||||
273
apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift
Normal file
273
apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
259
apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift
Normal file
259
apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/macos/Sources/Clawdis/Bridge/PairedNodesStore.swift
Normal file
57
apps/macos/Sources/Clawdis/Bridge/PairedNodesStore.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,7 +90,10 @@ enum ControlRequestHandler {
|
|||||||
return Response(ok: false, message: "Canvas disabled by user")
|
return Response(ok: false, message: "Canvas disabled by user")
|
||||||
}
|
}
|
||||||
do {
|
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)
|
return Response(ok: true, message: dir)
|
||||||
} catch {
|
} catch {
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
@@ -105,7 +108,10 @@ enum ControlRequestHandler {
|
|||||||
return Response(ok: false, message: "Canvas disabled by user")
|
return Response(ok: false, message: "Canvas disabled by user")
|
||||||
}
|
}
|
||||||
do {
|
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)
|
return Response(ok: true)
|
||||||
} catch {
|
} catch {
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
@@ -132,6 +138,28 @@ enum ControlRequestHandler {
|
|||||||
} catch {
|
} catch {
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ struct DebugSettings: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
|
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
|
||||||
.toggleStyle(.switch)
|
.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) {
|
HStack(spacing: 8) {
|
||||||
Button("Open folder") {
|
Button("Open folder") {
|
||||||
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
|
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)
|
Toggle("Only attach to existing gateway (don’t spawn locally)", isOn: self.$attachExistingGatewayOnly)
|
||||||
.toggleStyle(.switch)
|
.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) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Gateway stdout/stderr")
|
Text("Gateway stdout/stderr")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
@@ -295,7 +297,8 @@ struct DebugSettings: View {
|
|||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
|
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
|
||||||
.toggleStyle(.switch)
|
.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) {
|
HStack(spacing: 8) {
|
||||||
TextField("Session", text: self.$canvasSessionKey)
|
TextField("Session", text: self.$canvasSessionKey)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
@@ -353,7 +356,8 @@ struct DebugSettings: View {
|
|||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
Button("Reveal") {
|
Button("Reveal") {
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
|
NSWorkspace.shared
|
||||||
|
.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
@@ -379,7 +383,8 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
Toggle("Use SwiftUI web chat (glass, gateway WS)", isOn: self.$webChatSwiftUIEnabled)
|
Toggle("Use SwiftUI web chat (glass, gateway WS)", isOn: self.$webChatSwiftUIEnabled)
|
||||||
.toggleStyle(.switch)
|
.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)
|
Spacer(minLength: 8)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ actor DiagnosticsFileLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func logFileURL() -> URL {
|
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) {
|
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)
|
Self.logDirectoryURL().appendingPathComponent("\(self.fileName).\(index)", isDirectory: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||||
Task { await self.socketServer.start() }
|
Task { await self.socketServer.start() }
|
||||||
|
Task { await BridgeServer.shared.start() }
|
||||||
self.scheduleFirstRunOnboardingIfNeeded()
|
self.scheduleFirstRunOnboardingIfNeeded()
|
||||||
|
|
||||||
// Developer/testing helper: auto-open WebChat when launched with --webchat
|
// 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 RemoteTunnelManager.shared.stopAll() }
|
||||||
Task { await AgentRPC.shared.shutdown() }
|
Task { await AgentRPC.shared.shutdown() }
|
||||||
Task { await self.socketServer.stop() }
|
Task { await self.socketServer.stop() }
|
||||||
|
Task { await BridgeServer.shared.stop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
import Foundation
|
|
||||||
import Darwin
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct ClawdisCLI {
|
struct ClawdisCLI {
|
||||||
@@ -163,6 +163,34 @@ struct ClawdisCLI {
|
|||||||
guard let message else { throw CLIError.help }
|
guard let message else { throw CLIError.help }
|
||||||
return .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to)
|
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":
|
case "canvas":
|
||||||
guard let sub = args.first else { throw CLIError.help }
|
guard let sub = args.first else { throw CLIError.help }
|
||||||
args = Array(args.dropFirst())
|
args = Array(args.dropFirst())
|
||||||
@@ -281,6 +309,8 @@ struct ClawdisCLI {
|
|||||||
clawdis-mac rpc-status
|
clawdis-mac rpc-status
|
||||||
clawdis-mac agent --message <text> [--thinking <low|default|high>]
|
clawdis-mac agent --message <text> [--thinking <low|default|high>]
|
||||||
[--session <key>] [--deliver] [--to <E.164>]
|
[--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 </...>]
|
clawdis-mac canvas show [--session <key>] [--path </...>]
|
||||||
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
|
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
|
||||||
clawdis-mac canvas hide [--session <key>]
|
clawdis-mac canvas hide [--session <key>]
|
||||||
@@ -336,7 +366,7 @@ struct ClawdisCLI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveExecutableURL() -> URL? {
|
private static func resolveExecutableURL() -> URL? {
|
||||||
var size: UInt32 = UInt32(PATH_MAX)
|
var size = UInt32(PATH_MAX)
|
||||||
var buffer = [CChar](repeating: 0, count: Int(size))
|
var buffer = [CChar](repeating: 0, count: Int(size))
|
||||||
|
|
||||||
let result = buffer.withUnsafeMutableBufferPointer { ptr in
|
let result = buffer.withUnsafeMutableBufferPointer { ptr in
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ public enum Request: Sendable {
|
|||||||
case canvasGoto(session: String, path: String, placement: CanvasPlacement?)
|
case canvasGoto(session: String, path: String, placement: CanvasPlacement?)
|
||||||
case canvasEval(session: String, javaScript: String)
|
case canvasEval(session: String, javaScript: String)
|
||||||
case canvasSnapshot(session: String, outPath: String?)
|
case canvasSnapshot(session: String, outPath: String?)
|
||||||
|
case nodeList
|
||||||
|
case nodeInvoke(nodeId: String, command: String, paramsJSON: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Responses
|
// MARK: - Responses
|
||||||
@@ -104,6 +106,9 @@ extension Request: Codable {
|
|||||||
case javaScript
|
case javaScript
|
||||||
case outPath
|
case outPath
|
||||||
case placement
|
case placement
|
||||||
|
case nodeId
|
||||||
|
case nodeCommand
|
||||||
|
case paramsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum Kind: String, Codable {
|
private enum Kind: String, Codable {
|
||||||
@@ -119,6 +124,8 @@ extension Request: Codable {
|
|||||||
case canvasGoto
|
case canvasGoto
|
||||||
case canvasEval
|
case canvasEval
|
||||||
case canvasSnapshot
|
case canvasSnapshot
|
||||||
|
case nodeList
|
||||||
|
case nodeInvoke
|
||||||
}
|
}
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
public func encode(to encoder: Encoder) throws {
|
||||||
@@ -190,6 +197,15 @@ extension Request: Codable {
|
|||||||
try container.encode(Kind.canvasSnapshot, forKey: .type)
|
try container.encode(Kind.canvasSnapshot, forKey: .type)
|
||||||
try container.encode(session, forKey: .session)
|
try container.encode(session, forKey: .session)
|
||||||
try container.encodeIfPresent(outPath, forKey: .outPath)
|
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 session = try container.decode(String.self, forKey: .session)
|
||||||
let outPath = try container.decodeIfPresent(String.self, forKey: .outPath)
|
let outPath = try container.decodeIfPresent(String.self, forKey: .outPath)
|
||||||
self = .canvasSnapshot(session: session, outPath: 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/shared/ClawdisNodeKit/Package.swift
Normal file
22
apps/shared/ClawdisNodeKit/Package.swift
Normal 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"),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user