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