Gateway: finalize WS control plane
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -9,73 +8,52 @@ struct ControlRequestParams: @unchecked Sendable {
|
||||
actor AgentRPC {
|
||||
static let shared = AgentRPC()
|
||||
|
||||
struct HeartbeatEvent: Codable {
|
||||
let ts: Double
|
||||
let status: String
|
||||
let to: String?
|
||||
let preview: String?
|
||||
let durationMs: Double?
|
||||
let hasMedia: Bool?
|
||||
let reason: String?
|
||||
}
|
||||
|
||||
static let heartbeatNotification = Notification.Name("clawdis.rpc.heartbeat")
|
||||
static let agentEventNotification = Notification.Name("clawdis.rpc.agent")
|
||||
|
||||
private struct ControlResponse: Decodable {
|
||||
let type: String
|
||||
let id: String
|
||||
let ok: Bool
|
||||
let payload: AnyCodable?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
struct AnyCodable: Codable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) { self.value = value }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
||||
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
||||
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
||||
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
||||
if container.decodeNil() { self.value = NSNull(); return }
|
||||
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
|
||||
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self.value {
|
||||
case let intVal as Int: try container.encode(intVal)
|
||||
case let doubleVal as Double: try container.encode(doubleVal)
|
||||
case let boolVal as Bool: try container.encode(boolVal)
|
||||
case let stringVal as String: try container.encode(stringVal)
|
||||
case is NSNull: try container.encodeNil()
|
||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||
case let array as [AnyCodable]: try container.encode(array)
|
||||
default:
|
||||
let context = EncodingError.Context(
|
||||
codingPath: encoder.codingPath,
|
||||
debugDescription: "Unsupported type")
|
||||
throw EncodingError.invalidValue(self.value, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var process: Process?
|
||||
private var stdinHandle: FileHandle?
|
||||
private var stdoutHandle: FileHandle?
|
||||
private var buffer = Data()
|
||||
private var waiters: [CheckedContinuation<String, Error>] = []
|
||||
private var controlWaiters: [String: CheckedContinuation<Data, Error>] = [:]
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "agent.rpc")
|
||||
private var starting = false
|
||||
private let gateway = GatewayChannel()
|
||||
private var configured = false
|
||||
|
||||
private struct RpcError: Error { let message: String }
|
||||
private var gatewayURL: URL {
|
||||
let port = UserDefaults.standard.integer(forKey: "gatewayPort")
|
||||
let effectivePort = port > 0 ? port : 18789
|
||||
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
|
||||
}
|
||||
|
||||
private var gatewayToken: String? {
|
||||
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
if configured { return }
|
||||
await gateway.configure(url: gatewayURL, token: gatewayToken)
|
||||
configured = true
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
// no-op for WS; socket managed by GatewayChannel
|
||||
}
|
||||
|
||||
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
|
||||
do {
|
||||
_ = try await controlRequest(method: "set-heartbeats", params: ControlRequestParams(raw: ["enabled": AnyHashable(enabled)]))
|
||||
return true
|
||||
} catch {
|
||||
logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func status() async -> (ok: Bool, error: String?) {
|
||||
do {
|
||||
let data = try await controlRequest(method: "status")
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
(obj["ok"] as? Bool) ?? true {
|
||||
return (true, nil)
|
||||
}
|
||||
return (false, "status error")
|
||||
} catch {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func send(
|
||||
text: String,
|
||||
@@ -84,305 +62,25 @@ actor AgentRPC {
|
||||
deliver: Bool,
|
||||
to: String?) async -> (ok: Bool, text: String?, error: String?)
|
||||
{
|
||||
if self.process?.isRunning != true {
|
||||
do {
|
||||
try await self.start()
|
||||
} catch {
|
||||
return (false, nil, "rpc worker not running: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
do {
|
||||
var payload: [String: Any] = [
|
||||
"type": "send",
|
||||
"text": text,
|
||||
"session": session,
|
||||
"thinking": thinking ?? "default",
|
||||
"deliver": deliver,
|
||||
let params: [String: AnyHashable] = [
|
||||
"message": AnyHashable(text),
|
||||
"sessionId": AnyHashable(session),
|
||||
"thinking": AnyHashable(thinking ?? "default"),
|
||||
"deliver": AnyHashable(deliver),
|
||||
"to": AnyHashable(to ?? ""),
|
||||
"idempotencyKey": AnyHashable(UUID().uuidString),
|
||||
]
|
||||
if let to { payload["to"] = to }
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
guard let stdinHandle else { throw RpcError(message: "stdin missing") }
|
||||
stdinHandle.write(data)
|
||||
stdinHandle.write(Data([0x0A]))
|
||||
|
||||
let parsed = try await self.nextJSONObject()
|
||||
|
||||
if let ok = parsed["ok"] as? Bool, let type = parsed["type"] as? String, type == "result" {
|
||||
if ok {
|
||||
if let payloadDict = parsed["payload"] as? [String: Any],
|
||||
let payloads = payloadDict["payloads"] as? [[String: Any]],
|
||||
let first = payloads.first,
|
||||
let txt = first["text"] as? String
|
||||
{
|
||||
return (true, txt, nil)
|
||||
}
|
||||
return (true, nil, nil)
|
||||
}
|
||||
}
|
||||
if let err = parsed["error"] as? String {
|
||||
return (false, nil, err)
|
||||
}
|
||||
return (false, nil, "rpc returned unexpected response: \(parsed)")
|
||||
_ = try await controlRequest(method: "agent", params: ControlRequestParams(raw: params))
|
||||
return (true, nil, nil)
|
||||
} catch {
|
||||
self.logger.error("rpc send failed: \(error.localizedDescription, privacy: .public)")
|
||||
await self.stop()
|
||||
return (false, nil, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func status() async -> (ok: Bool, error: String?) {
|
||||
if self.process?.isRunning != true {
|
||||
do {
|
||||
try await self.start()
|
||||
} catch {
|
||||
return (false, "rpc worker not running: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
do {
|
||||
let payload: [String: Any] = ["type": "status"]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
guard let stdinHandle else { throw RpcError(message: "stdin missing") }
|
||||
stdinHandle.write(data)
|
||||
stdinHandle.write(Data([0x0A]))
|
||||
|
||||
let parsed = try await self.nextJSONObject()
|
||||
if let ok = parsed["ok"] as? Bool, ok { return (true, nil) }
|
||||
return (false, parsed["error"] as? String ?? "rpc status failed: \(parsed)")
|
||||
} catch {
|
||||
self.logger.error("rpc status failed: \(error.localizedDescription, privacy: .public)")
|
||||
await self.stop()
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
|
||||
guard self.process?.isRunning == true else { return false }
|
||||
do {
|
||||
let payload: [String: Any] = ["type": "set-heartbeats", "enabled": enabled]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
guard let stdinHandle else { throw RpcError(message: "stdin missing") }
|
||||
stdinHandle.write(data)
|
||||
stdinHandle.write(Data([0x0A]))
|
||||
|
||||
let line = try await nextLine()
|
||||
let parsed = try JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]
|
||||
if let ok = parsed?["ok"] as? Bool, ok { return true }
|
||||
return false
|
||||
} catch {
|
||||
self.logger.error("rpc set-heartbeats failed: \(error.localizedDescription, privacy: .public)")
|
||||
await self.stop()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data {
|
||||
if self.process?.isRunning != true {
|
||||
try await self.start()
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
var frame: [String: Any] = ["type": "control-request", "id": id, "method": method]
|
||||
if let params { frame["params"] = params.raw }
|
||||
let data = try JSONSerialization.data(withJSONObject: frame)
|
||||
guard let stdinHandle else { throw RpcError(message: "stdin missing") }
|
||||
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
|
||||
self.controlWaiters[id] = cont
|
||||
stdinHandle.write(data)
|
||||
stdinHandle.write(Data([0x0A]))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Process lifecycle
|
||||
|
||||
func start() async throws {
|
||||
if self.starting { return }
|
||||
self.starting = true
|
||||
defer { self.starting = false }
|
||||
let process = Process()
|
||||
let command = CommandResolver.clawdisCommand(subcommand: "rpc")
|
||||
process.executableURL = URL(fileURLWithPath: command.first ?? "/usr/bin/env")
|
||||
process.arguments = Array(command.dropFirst())
|
||||
process.currentDirectoryURL = URL(fileURLWithPath: CommandResolver.projectRootPath())
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
process.environment = env
|
||||
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
process.standardInput = stdinPipe
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = Pipe()
|
||||
|
||||
try process.run()
|
||||
|
||||
self.process = process
|
||||
self.stdinHandle = stdinPipe.fileHandleForWriting
|
||||
self.stdoutHandle = stdoutPipe.fileHandleForReading
|
||||
|
||||
stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
guard let self else { return }
|
||||
let data = handle.availableData
|
||||
if data.isEmpty { return }
|
||||
Task { await self.ingest(data: data) }
|
||||
}
|
||||
|
||||
Task.detached { [weak self] in
|
||||
// Ensure all waiters are failed if the worker dies (e.g., crash or SIGTERM).
|
||||
process.waitUntilExit()
|
||||
await self?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
await self.stop()
|
||||
}
|
||||
|
||||
private func stop() async {
|
||||
self.stdoutHandle?.readabilityHandler = nil
|
||||
let proc = self.process
|
||||
proc?.terminate()
|
||||
if let proc, proc.isRunning {
|
||||
try? await Task.sleep(nanoseconds: 700_000_000)
|
||||
if proc.isRunning {
|
||||
kill(proc.processIdentifier, SIGKILL)
|
||||
}
|
||||
}
|
||||
proc?.waitUntilExit()
|
||||
self.process = nil
|
||||
self.stdinHandle = nil
|
||||
self.stdoutHandle = nil
|
||||
self.buffer.removeAll(keepingCapacity: false)
|
||||
let waiters = self.waiters
|
||||
self.waiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: RpcError(message: "rpc process stopped"))
|
||||
}
|
||||
let control = self.controlWaiters
|
||||
self.controlWaiters.removeAll()
|
||||
for (_, waiter) in control {
|
||||
waiter.resume(throwing: RpcError(message: "rpc process stopped"))
|
||||
}
|
||||
}
|
||||
|
||||
private func ingest(data: Data) {
|
||||
self.buffer.append(data)
|
||||
while let range = buffer.firstRange(of: Data([0x0A])) {
|
||||
let lineData = self.buffer.subdata(in: self.buffer.startIndex..<range.lowerBound)
|
||||
self.buffer.removeSubrange(self.buffer.startIndex...range.lowerBound)
|
||||
guard let line = String(data: lineData, encoding: .utf8) else { continue }
|
||||
|
||||
// Event frames are pushed without request/response pairing (e.g., heartbeats/agent).
|
||||
if self.handleEventLine(line) {
|
||||
continue
|
||||
}
|
||||
if self.handleControlResponse(line) {
|
||||
continue
|
||||
}
|
||||
if let waiter = waiters.first {
|
||||
self.waiters.removeFirst()
|
||||
waiter.resume(returning: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the next line that successfully parses as JSON. Non-JSON lines (e.g., stray stdout logs)
|
||||
/// are skipped to keep the RPC bridge resilient to accidental prints.
|
||||
private func nextJSONObject(maxSkips: Int = 30) async throws -> [String: Any] {
|
||||
var skipped = 0
|
||||
while true {
|
||||
let line = try await self.nextLine()
|
||||
guard let data = line.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else {
|
||||
skipped += 1
|
||||
if skipped >= maxSkips {
|
||||
throw RpcError(message: "rpc returned non-JSON output: \(line)")
|
||||
}
|
||||
continue
|
||||
}
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
private func parseHeartbeatEvent(from line: String) -> HeartbeatEvent? {
|
||||
guard let data = line.data(using: .utf8) else { return nil }
|
||||
guard
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = obj["type"] as? String,
|
||||
type == "event",
|
||||
let evt = obj["event"] as? String,
|
||||
evt == "heartbeat",
|
||||
let payload = obj["payload"] as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
guard let payloadData = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
|
||||
return try? decoder.decode(HeartbeatEvent.self, from: payloadData)
|
||||
}
|
||||
|
||||
private func parseAgentEvent(from line: String) -> ControlAgentEvent? {
|
||||
guard let data = line.data(using: .utf8) else { return nil }
|
||||
guard
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = obj["type"] as? String,
|
||||
type == "event",
|
||||
let evt = obj["event"] as? String,
|
||||
evt == "agent",
|
||||
let payload = obj["payload"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let payloadData = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
|
||||
return try? JSONDecoder().decode(ControlAgentEvent.self, from: payloadData)
|
||||
}
|
||||
|
||||
private func handleEventLine(_ line: String) -> Bool {
|
||||
if let hb = self.parseHeartbeatEvent(from: line) {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: Self.heartbeatNotification, object: hb)
|
||||
NotificationCenter.default.post(name: .controlHeartbeat, object: hb)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if let agent = self.parseAgentEvent(from: line) {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: Self.agentEventNotification, object: agent)
|
||||
NotificationCenter.default.post(name: .controlAgentEvent, object: agent)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func handleControlResponse(_ line: String) -> Bool {
|
||||
guard let data = line.data(using: .utf8) else { return false }
|
||||
guard let parsed = try? JSONDecoder().decode(ControlResponse.self, from: data) else { return false }
|
||||
guard parsed.type == "control-response" else { return false }
|
||||
self.logger.debug("control response parsed id=\(parsed.id, privacy: .public) ok=\(parsed.ok, privacy: .public)")
|
||||
guard let waiter = self.controlWaiters.removeValue(forKey: parsed.id) else {
|
||||
self.logger.debug("control response with no waiter id=\(parsed.id, privacy: .public)")
|
||||
return true
|
||||
}
|
||||
if parsed.ok {
|
||||
let payloadData: Data = {
|
||||
if let payload = parsed.payload {
|
||||
return (try? JSONEncoder().encode(payload)) ?? Data()
|
||||
}
|
||||
// Use an empty JSON array to keep callers happy when payload is missing.
|
||||
return Data("[]".utf8)
|
||||
}()
|
||||
waiter.resume(returning: payloadData)
|
||||
} else {
|
||||
waiter.resume(throwing: RpcError(message: parsed.error ?? "control error"))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func nextLine() async throws -> String {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
|
||||
self.waiters.append(cont)
|
||||
}
|
||||
try await start()
|
||||
let rawParams = params?.raw.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
|
||||
return try await gateway.request(method: method, params: rawParams)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,20 +77,26 @@ final class ControlChannel: ObservableObject {
|
||||
case degraded(String)
|
||||
}
|
||||
|
||||
enum Mode: Equatable {
|
||||
case local
|
||||
case remote(target: String, identity: String)
|
||||
}
|
||||
|
||||
@Published private(set) var state: ConnectionState = .disconnected
|
||||
@Published private(set) var lastPingMs: Double?
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||
private let gateway = GatewayChannel()
|
||||
private var gatewayURL: URL {
|
||||
let port = UserDefaults.standard.integer(forKey: "gatewayPort")
|
||||
let effectivePort = port > 0 ? port : 18789
|
||||
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
|
||||
}
|
||||
private var gatewayToken: String? {
|
||||
ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"]
|
||||
}
|
||||
private var eventTokens: [NSObjectProtocol] = []
|
||||
|
||||
func configure() async {
|
||||
do {
|
||||
self.state = .connecting
|
||||
try await AgentRPC.shared.start()
|
||||
await gateway.configure(url: gatewayURL, token: gatewayToken)
|
||||
self.startEventStream()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
@@ -98,16 +104,16 @@ final class ControlChannel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func configure(mode: Mode) async throws {
|
||||
// Mode is retained for API compatibility; transport is always stdio now.
|
||||
await self.configure()
|
||||
}
|
||||
func configure(mode _: Any? = nil) async throws { await self.configure() }
|
||||
|
||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||
let params = timeout.map { ControlRequestParams(raw: ["timeoutMs": AnyHashable(Int($0 * 1000))]) }
|
||||
do {
|
||||
let start = Date()
|
||||
let payload = try await AgentRPC.shared.controlRequest(method: "health", params: params)
|
||||
var params: [String: AnyHashable]? = nil
|
||||
if let timeout {
|
||||
params = ["timeout": AnyHashable(Int(timeout * 1000))]
|
||||
}
|
||||
let payload = try await self.request(method: "health", params: params)
|
||||
let ms = Date().timeIntervalSince(start) * 1000
|
||||
self.lastPingMs = ms
|
||||
self.state = .connected
|
||||
@@ -119,14 +125,14 @@ final class ControlChannel: ObservableObject {
|
||||
}
|
||||
|
||||
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
|
||||
let data = try await AgentRPC.shared.controlRequest(method: "last-heartbeat")
|
||||
if data.isEmpty { return nil }
|
||||
return try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data)
|
||||
// Heartbeat removed in new protocol
|
||||
return nil
|
||||
}
|
||||
|
||||
func request(method: String, params: ControlRequestParams? = nil) async throws -> Data {
|
||||
func request(method: String, params: [String: AnyHashable]? = nil) async throws -> Data {
|
||||
do {
|
||||
let data = try await AgentRPC.shared.controlRequest(method: method, params: params)
|
||||
let rawParams = params?.reduce(into: [String: Any]()) { $0[$1.key] = $1.value }
|
||||
let data = try await gateway.request(method: method, params: rawParams)
|
||||
self.state = .connected
|
||||
return data
|
||||
} catch {
|
||||
@@ -136,9 +142,48 @@ final class ControlChannel: ObservableObject {
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ text: String) async throws {
|
||||
_ = try await self.request(
|
||||
method: "system-event",
|
||||
params: ControlRequestParams(raw: ["text": AnyHashable(text)]))
|
||||
_ = try await self.request(method: "system-event", params: ["text": AnyHashable(text)])
|
||||
}
|
||||
|
||||
private func startEventStream() {
|
||||
for tok in eventTokens { NotificationCenter.default.removeObserver(tok) }
|
||||
eventTokens.removeAll()
|
||||
let ev = NotificationCenter.default.addObserver(
|
||||
forName: .gatewayEvent,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { note in
|
||||
guard let obj = note.userInfo as? [String: Any],
|
||||
let event = obj["event"] as? String else { return }
|
||||
switch event {
|
||||
case "agent":
|
||||
if let payload = obj["payload"] as? [String: Any],
|
||||
let runId = payload["runId"] as? String,
|
||||
let seq = payload["seq"] as? Int,
|
||||
let stream = payload["stream"] as? String,
|
||||
let ts = payload["ts"] as? Double,
|
||||
let dataDict = payload["data"] as? [String: Any]
|
||||
{
|
||||
let wrapped = dataDict.mapValues { AnyCodable($0) }
|
||||
AgentEventStore.shared.append(ControlAgentEvent(runId: runId, seq: seq, stream: stream, ts: ts, data: wrapped))
|
||||
}
|
||||
case "presence":
|
||||
// InstancesStore listens separately via notification
|
||||
break
|
||||
case "shutdown":
|
||||
self.state = .degraded("gateway shutdown")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
let tick = NotificationCenter.default.addObserver(
|
||||
forName: .gatewaySnapshot,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
self.state = .connected
|
||||
}
|
||||
eventTokens = [ev, tick]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
221
apps/macos/Sources/Clawdis/GatewayChannel.swift
Normal file
221
apps/macos/Sources/Clawdis/GatewayChannel.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct GatewayEvent: Codable {
|
||||
let type: String
|
||||
let event: String?
|
||||
let payload: AnyCodable?
|
||||
let seq: Int?
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let gatewaySnapshot = Notification.Name("clawdis.gateway.snapshot")
|
||||
static let gatewayEvent = Notification.Name("clawdis.gateway.event")
|
||||
static let gatewaySeqGap = Notification.Name("clawdis.gateway.seqgap")
|
||||
}
|
||||
|
||||
private actor GatewayChannelActor {
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway")
|
||||
private var task: URLSessionWebSocketTask?
|
||||
private var pending: [String: CheckedContinuation<Data, Error>] = [:]
|
||||
private var connected = false
|
||||
private var url: URL
|
||||
private var token: String?
|
||||
private let session = URLSession(configuration: .default)
|
||||
private var backoffMs: Double = 500
|
||||
private var shouldReconnect = true
|
||||
private var lastSeq: Int?
|
||||
|
||||
init(url: URL, token: String?) {
|
||||
self.url = url
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
if connected, task?.state == .running { return }
|
||||
task?.cancel(with: .goingAway, reason: nil)
|
||||
task = session.webSocketTask(with: url)
|
||||
task?.resume()
|
||||
try await sendHello()
|
||||
listen()
|
||||
connected = true
|
||||
backoffMs = 500
|
||||
lastSeq = nil
|
||||
}
|
||||
|
||||
private func sendHello() async throws {
|
||||
let hello: [String: Any] = [
|
||||
"type": "hello",
|
||||
"minProtocol": 1,
|
||||
"maxProtocol": 1,
|
||||
"client": [
|
||||
"name": "clawdis-mac",
|
||||
"version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev",
|
||||
"platform": "macos",
|
||||
"mode": "app",
|
||||
"instanceId": Host.current().localizedName ?? UUID().uuidString,
|
||||
],
|
||||
"caps": [],
|
||||
"auth": token != nil ? ["token": token!] : [:],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: hello)
|
||||
try await task?.send(.data(data))
|
||||
// wait for hello-ok
|
||||
if let msg = try await task?.receive() {
|
||||
if try await handleHelloResponse(msg) { return }
|
||||
}
|
||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "hello failed"])
|
||||
}
|
||||
|
||||
private func handleHelloResponse(_ msg: URLSessionWebSocketTask.Message) async throws -> Bool {
|
||||
let data: Data?
|
||||
switch msg {
|
||||
case .data(let d): data = d
|
||||
case .string(let s): data = s.data(using: .utf8)
|
||||
@unknown default: data = nil
|
||||
}
|
||||
guard let data else { return false }
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = obj["type"] as? String else { return false }
|
||||
if type == "hello-ok" {
|
||||
NotificationCenter.default.post(name: .gatewaySnapshot, object: nil, userInfo: obj)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func listen() {
|
||||
task?.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .failure(let err):
|
||||
self.logger.error("gateway ws receive failed \(err.localizedDescription, privacy: .public)")
|
||||
self.connected = false
|
||||
self.scheduleReconnect()
|
||||
case .success(let msg):
|
||||
Task { await self.handle(msg) }
|
||||
self.listen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ msg: URLSessionWebSocketTask.Message) async {
|
||||
let data: Data?
|
||||
switch msg {
|
||||
case .data(let d): data = d
|
||||
case .string(let s): data = s.data(using: .utf8)
|
||||
@unknown default: data = nil
|
||||
}
|
||||
guard let data else { return }
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = obj["type"] as? String else { return }
|
||||
switch type {
|
||||
case "res":
|
||||
if let id = obj["id"] as? String, let waiter = pending.removeValue(forKey: id) {
|
||||
waiter.resume(returning: data)
|
||||
}
|
||||
case "event":
|
||||
if let seq = obj["seq"] as? Int {
|
||||
if let last = lastSeq, seq > last + 1 {
|
||||
NotificationCenter.default.post(
|
||||
name: .gatewaySeqGap,
|
||||
object: nil,
|
||||
userInfo: ["expected": last + 1, "received": seq]
|
||||
)
|
||||
}
|
||||
lastSeq = seq
|
||||
}
|
||||
NotificationCenter.default.post(name: .gatewayEvent, object: nil, userInfo: obj)
|
||||
case "hello-ok":
|
||||
NotificationCenter.default.post(name: .gatewaySnapshot, object: nil, userInfo: obj)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
guard shouldReconnect else { return }
|
||||
let delay = backoffMs / 1000
|
||||
backoffMs = min(backoffMs * 2, 30_000)
|
||||
Task.detached { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
self.logger.error("gateway reconnect failed \(error.localizedDescription, privacy: .public)")
|
||||
self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: Any]?) async throws -> Data {
|
||||
try await connect()
|
||||
let id = UUID().uuidString
|
||||
let frame: [String: Any] = [
|
||||
"type": "req",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params ?? [:],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: frame)
|
||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
|
||||
pending[id] = cont
|
||||
Task {
|
||||
do {
|
||||
try await task?.send(.data(data))
|
||||
} catch {
|
||||
pending.removeValue(forKey: id)
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
actor GatewayChannel {
|
||||
private var inner: GatewayChannelActor?
|
||||
|
||||
func configure(url: URL, token: String?) {
|
||||
inner = GatewayChannelActor(url: url, token: token)
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: Any]?) async throws -> Data {
|
||||
guard let inner else {
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "not configured"])
|
||||
}
|
||||
return try await inner.request(method: method, params: params)
|
||||
}
|
||||
}
|
||||
|
||||
struct AnyCodable: Codable {
|
||||
let value: Any
|
||||
init(_ value: Any) { self.value = value }
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
||||
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
||||
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
||||
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
||||
if container.decodeNil() { self.value = NSNull(); return }
|
||||
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
|
||||
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
|
||||
}
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self.value {
|
||||
case let intVal as Int: try container.encode(intVal)
|
||||
case let doubleVal as Double: try container.encode(doubleVal)
|
||||
case let boolVal as Bool: try container.encode(boolVal)
|
||||
case let stringVal as String: try container.encode(stringVal)
|
||||
case is NSNull: try container.encodeNil()
|
||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||
case let array as [AnyCodable]: try container.encode(array)
|
||||
default:
|
||||
let ctx = EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")
|
||||
throw EncodingError.invalidValue(self.value, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,11 @@ final class InstancesStore: ObservableObject {
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "instances")
|
||||
private var task: Task<Void, Never>?
|
||||
private let interval: TimeInterval = 30
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
self.observeGatewayEvents()
|
||||
self.task = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh()
|
||||
@@ -52,6 +54,45 @@ final class InstancesStore: ObservableObject {
|
||||
func stop() {
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
for token in observers {
|
||||
NotificationCenter.default.removeObserver(token)
|
||||
}
|
||||
observers.removeAll()
|
||||
}
|
||||
|
||||
private func observeGatewayEvents() {
|
||||
let ev = NotificationCenter.default.addObserver(
|
||||
forName: .gatewayEvent,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
guard let self,
|
||||
let obj = note.userInfo as? [String: Any],
|
||||
let event = obj["event"] as? String else { return }
|
||||
if event == "presence", let payload = obj["payload"] as? [String: Any] {
|
||||
self.handlePresencePayload(payload)
|
||||
}
|
||||
}
|
||||
let gap = NotificationCenter.default.addObserver(
|
||||
forName: .gatewaySeqGap,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { await self.refresh() }
|
||||
}
|
||||
let snap = NotificationCenter.default.addObserver(
|
||||
forName: .gatewaySnapshot,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
guard let self,
|
||||
let obj = note.userInfo as? [String: Any],
|
||||
let snapshot = obj["snapshot"] as? [String: Any],
|
||||
let presence = snapshot["presence"] else { return }
|
||||
self.decodeAndApplyPresence(presence: presence)
|
||||
}
|
||||
observers = [ev, snap, gap]
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
@@ -213,4 +254,35 @@ final class InstancesStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePresencePayload(_ payload: [String: Any]) {
|
||||
if let presence = payload["presence"] {
|
||||
self.decodeAndApplyPresence(presence: presence)
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeAndApplyPresence(presence: Any) {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: presence) else { return }
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
|
||||
let withIDs = decoded.map { entry -> InstanceInfo in
|
||||
let key = entry.host ?? entry.ip ?? entry.text
|
||||
return InstanceInfo(
|
||||
id: key,
|
||||
host: entry.host,
|
||||
ip: entry.ip,
|
||||
version: entry.version,
|
||||
lastInputSeconds: entry.lastInputSeconds,
|
||||
mode: entry.mode,
|
||||
reason: entry.reason,
|
||||
text: entry.text,
|
||||
ts: entry.ts)
|
||||
}
|
||||
self.instances = withIDs
|
||||
self.statusMessage = nil
|
||||
self.lastError = nil
|
||||
} catch {
|
||||
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,12 +89,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
||||
RelayProcessManager.shared.setActive(!state.isPaused)
|
||||
}
|
||||
Task {
|
||||
let controlMode: ControlChannel.Mode = AppStateStore.shared.connectionMode == .remote
|
||||
? .remote(target: AppStateStore.shared.remoteTarget, identity: AppStateStore.shared.remoteIdentity)
|
||||
: .local
|
||||
try? await ControlChannel.shared.configure(mode: controlMode)
|
||||
try? await AgentRPC.shared.start()
|
||||
_ = await AgentRPC.shared.setHeartbeatsEnabled(AppStateStore.shared.heartbeatsEnabled)
|
||||
try? await ControlChannel.shared.configure()
|
||||
PresenceReporter.shared.start()
|
||||
}
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
895
apps/macos/Sources/ClawdisProtocol/Protocol.swift
Normal file
895
apps/macos/Sources/ClawdisProtocol/Protocol.swift
Normal file
@@ -0,0 +1,895 @@
|
||||
// This file was generated from JSON Schema using quicktype, do not modify it directly.
|
||||
// To parse the JSON, add this file to your project and do:
|
||||
//
|
||||
// let clawdisGateway = try ClawdisGateway(json)
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Handshake, request/response, and event frames for the Gateway WebSocket.
|
||||
// MARK: - ClawdisGateway
|
||||
struct ClawdisGateway: Codable {
|
||||
let auth: Auth?
|
||||
let caps: [String]?
|
||||
let client: Client?
|
||||
let locale: String?
|
||||
let maxProtocol, minProtocol: Int?
|
||||
let type: TypeEnum
|
||||
let userAgent: String?
|
||||
let features: Features?
|
||||
let policy: Policy?
|
||||
let clawdisGatewayProtocol: Int?
|
||||
let server: Server?
|
||||
let snapshot: Snapshot?
|
||||
let expectedProtocol: Int?
|
||||
let minClient, reason, id, method: String?
|
||||
let params: JSONAny?
|
||||
let error: Error?
|
||||
let ok: Bool?
|
||||
let payload: JSONAny?
|
||||
let event: String?
|
||||
let seq: Int?
|
||||
let stateVersion: ClawdisGatewayStateVersion?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case auth, caps, client, locale, maxProtocol, minProtocol, type, userAgent, features, policy
|
||||
case clawdisGatewayProtocol = "protocol"
|
||||
case server, snapshot, expectedProtocol, minClient, reason, id, method, params, error, ok, payload, event, seq, stateVersion
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: ClawdisGateway convenience initializers and mutators
|
||||
|
||||
extension ClawdisGateway {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(ClawdisGateway.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
auth: Auth?? = nil,
|
||||
caps: [String]?? = nil,
|
||||
client: Client?? = nil,
|
||||
locale: String?? = nil,
|
||||
maxProtocol: Int?? = nil,
|
||||
minProtocol: Int?? = nil,
|
||||
type: TypeEnum? = nil,
|
||||
userAgent: String?? = nil,
|
||||
features: Features?? = nil,
|
||||
policy: Policy?? = nil,
|
||||
clawdisGatewayProtocol: Int?? = nil,
|
||||
server: Server?? = nil,
|
||||
snapshot: Snapshot?? = nil,
|
||||
expectedProtocol: Int?? = nil,
|
||||
minClient: String?? = nil,
|
||||
reason: String?? = nil,
|
||||
id: String?? = nil,
|
||||
method: String?? = nil,
|
||||
params: JSONAny?? = nil,
|
||||
error: Error?? = nil,
|
||||
ok: Bool?? = nil,
|
||||
payload: JSONAny?? = nil,
|
||||
event: String?? = nil,
|
||||
seq: Int?? = nil,
|
||||
stateVersion: ClawdisGatewayStateVersion?? = nil
|
||||
) -> ClawdisGateway {
|
||||
return ClawdisGateway(
|
||||
auth: auth ?? self.auth,
|
||||
caps: caps ?? self.caps,
|
||||
client: client ?? self.client,
|
||||
locale: locale ?? self.locale,
|
||||
maxProtocol: maxProtocol ?? self.maxProtocol,
|
||||
minProtocol: minProtocol ?? self.minProtocol,
|
||||
type: type ?? self.type,
|
||||
userAgent: userAgent ?? self.userAgent,
|
||||
features: features ?? self.features,
|
||||
policy: policy ?? self.policy,
|
||||
clawdisGatewayProtocol: clawdisGatewayProtocol ?? self.clawdisGatewayProtocol,
|
||||
server: server ?? self.server,
|
||||
snapshot: snapshot ?? self.snapshot,
|
||||
expectedProtocol: expectedProtocol ?? self.expectedProtocol,
|
||||
minClient: minClient ?? self.minClient,
|
||||
reason: reason ?? self.reason,
|
||||
id: id ?? self.id,
|
||||
method: method ?? self.method,
|
||||
params: params ?? self.params,
|
||||
error: error ?? self.error,
|
||||
ok: ok ?? self.ok,
|
||||
payload: payload ?? self.payload,
|
||||
event: event ?? self.event,
|
||||
seq: seq ?? self.seq,
|
||||
stateVersion: stateVersion ?? self.stateVersion
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
struct Auth: Codable {
|
||||
let token: String?
|
||||
}
|
||||
|
||||
// MARK: Auth convenience initializers and mutators
|
||||
|
||||
extension Auth {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Auth.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
token: String?? = nil
|
||||
) -> Auth {
|
||||
return Auth(
|
||||
token: token ?? self.token
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
struct Client: Codable {
|
||||
let instanceID: String?
|
||||
let mode, name, platform, version: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case instanceID = "instanceId"
|
||||
case mode, name, platform, version
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Client convenience initializers and mutators
|
||||
|
||||
extension Client {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Client.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
instanceID: String?? = nil,
|
||||
mode: String? = nil,
|
||||
name: String? = nil,
|
||||
platform: String? = nil,
|
||||
version: String? = nil
|
||||
) -> Client {
|
||||
return Client(
|
||||
instanceID: instanceID ?? self.instanceID,
|
||||
mode: mode ?? self.mode,
|
||||
name: name ?? self.name,
|
||||
platform: platform ?? self.platform,
|
||||
version: version ?? self.version
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error
|
||||
struct Error: Codable {
|
||||
let code: String
|
||||
let details: JSONAny?
|
||||
let message: String
|
||||
let retryable: Bool?
|
||||
let retryAfterMS: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case code, details, message, retryable
|
||||
case retryAfterMS = "retryAfterMs"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Error convenience initializers and mutators
|
||||
|
||||
extension Error {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Error.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
code: String? = nil,
|
||||
details: JSONAny?? = nil,
|
||||
message: String? = nil,
|
||||
retryable: Bool?? = nil,
|
||||
retryAfterMS: Int?? = nil
|
||||
) -> Error {
|
||||
return Error(
|
||||
code: code ?? self.code,
|
||||
details: details ?? self.details,
|
||||
message: message ?? self.message,
|
||||
retryable: retryable ?? self.retryable,
|
||||
retryAfterMS: retryAfterMS ?? self.retryAfterMS
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Features
|
||||
struct Features: Codable {
|
||||
let events, methods: [String]
|
||||
}
|
||||
|
||||
// MARK: Features convenience initializers and mutators
|
||||
|
||||
extension Features {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Features.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
events: [String]? = nil,
|
||||
methods: [String]? = nil
|
||||
) -> Features {
|
||||
return Features(
|
||||
events: events ?? self.events,
|
||||
methods: methods ?? self.methods
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Policy
|
||||
struct Policy: Codable {
|
||||
let maxBufferedBytes, maxPayload, tickIntervalMS: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case maxBufferedBytes, maxPayload
|
||||
case tickIntervalMS = "tickIntervalMs"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Policy convenience initializers and mutators
|
||||
|
||||
extension Policy {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Policy.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
maxBufferedBytes: Int? = nil,
|
||||
maxPayload: Int? = nil,
|
||||
tickIntervalMS: Int? = nil
|
||||
) -> Policy {
|
||||
return Policy(
|
||||
maxBufferedBytes: maxBufferedBytes ?? self.maxBufferedBytes,
|
||||
maxPayload: maxPayload ?? self.maxPayload,
|
||||
tickIntervalMS: tickIntervalMS ?? self.tickIntervalMS
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server
|
||||
struct Server: Codable {
|
||||
let commit: String?
|
||||
let connID: String
|
||||
let host: String?
|
||||
let version: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case commit
|
||||
case connID = "connId"
|
||||
case host, version
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Server convenience initializers and mutators
|
||||
|
||||
extension Server {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Server.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
commit: String?? = nil,
|
||||
connID: String? = nil,
|
||||
host: String?? = nil,
|
||||
version: String? = nil
|
||||
) -> Server {
|
||||
return Server(
|
||||
commit: commit ?? self.commit,
|
||||
connID: connID ?? self.connID,
|
||||
host: host ?? self.host,
|
||||
version: version ?? self.version
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshot
|
||||
struct Snapshot: Codable {
|
||||
let health: JSONAny
|
||||
let presence: [Presence]
|
||||
let stateVersion: SnapshotStateVersion
|
||||
let uptimeMS: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case health, presence, stateVersion
|
||||
case uptimeMS = "uptimeMs"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Snapshot convenience initializers and mutators
|
||||
|
||||
extension Snapshot {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Snapshot.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
health: JSONAny? = nil,
|
||||
presence: [Presence]? = nil,
|
||||
stateVersion: SnapshotStateVersion? = nil,
|
||||
uptimeMS: Int? = nil
|
||||
) -> Snapshot {
|
||||
return Snapshot(
|
||||
health: health ?? self.health,
|
||||
presence: presence ?? self.presence,
|
||||
stateVersion: stateVersion ?? self.stateVersion,
|
||||
uptimeMS: uptimeMS ?? self.uptimeMS
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Presence
|
||||
struct Presence: Codable {
|
||||
let host, instanceID, ip: String?
|
||||
let lastInputSeconds: Int?
|
||||
let mode, reason: String?
|
||||
let tags: [String]?
|
||||
let text: String?
|
||||
let ts: Int
|
||||
let version: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case host
|
||||
case instanceID = "instanceId"
|
||||
case ip, lastInputSeconds, mode, reason, tags, text, ts, version
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Presence convenience initializers and mutators
|
||||
|
||||
extension Presence {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(Presence.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
host: String?? = nil,
|
||||
instanceID: String?? = nil,
|
||||
ip: String?? = nil,
|
||||
lastInputSeconds: Int?? = nil,
|
||||
mode: String?? = nil,
|
||||
reason: String?? = nil,
|
||||
tags: [String]?? = nil,
|
||||
text: String?? = nil,
|
||||
ts: Int? = nil,
|
||||
version: String?? = nil
|
||||
) -> Presence {
|
||||
return Presence(
|
||||
host: host ?? self.host,
|
||||
instanceID: instanceID ?? self.instanceID,
|
||||
ip: ip ?? self.ip,
|
||||
lastInputSeconds: lastInputSeconds ?? self.lastInputSeconds,
|
||||
mode: mode ?? self.mode,
|
||||
reason: reason ?? self.reason,
|
||||
tags: tags ?? self.tags,
|
||||
text: text ?? self.text,
|
||||
ts: ts ?? self.ts,
|
||||
version: version ?? self.version
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SnapshotStateVersion
|
||||
struct SnapshotStateVersion: Codable {
|
||||
let health, presence: Int
|
||||
}
|
||||
|
||||
// MARK: SnapshotStateVersion convenience initializers and mutators
|
||||
|
||||
extension SnapshotStateVersion {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(SnapshotStateVersion.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
health: Int? = nil,
|
||||
presence: Int? = nil
|
||||
) -> SnapshotStateVersion {
|
||||
return SnapshotStateVersion(
|
||||
health: health ?? self.health,
|
||||
presence: presence ?? self.presence
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ClawdisGatewayStateVersion
|
||||
struct ClawdisGatewayStateVersion: Codable {
|
||||
let health, presence: Int
|
||||
}
|
||||
|
||||
// MARK: ClawdisGatewayStateVersion convenience initializers and mutators
|
||||
|
||||
extension ClawdisGatewayStateVersion {
|
||||
init(data: Data) throws {
|
||||
self = try newJSONDecoder().decode(ClawdisGatewayStateVersion.self, from: data)
|
||||
}
|
||||
|
||||
init(_ json: String, using encoding: String.Encoding = .utf8) throws {
|
||||
guard let data = json.data(using: encoding) else {
|
||||
throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil)
|
||||
}
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(fromURL url: URL) throws {
|
||||
try self.init(data: try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
func with(
|
||||
health: Int? = nil,
|
||||
presence: Int? = nil
|
||||
) -> ClawdisGatewayStateVersion {
|
||||
return ClawdisGatewayStateVersion(
|
||||
health: health ?? self.health,
|
||||
presence: presence ?? self.presence
|
||||
)
|
||||
}
|
||||
|
||||
func jsonData() throws -> Data {
|
||||
return try newJSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
func jsonString(encoding: String.Encoding = .utf8) throws -> String? {
|
||||
return String(data: try self.jsonData(), encoding: encoding)
|
||||
}
|
||||
}
|
||||
|
||||
enum TypeEnum: String, Codable {
|
||||
case event = "event"
|
||||
case hello = "hello"
|
||||
case helloError = "hello-error"
|
||||
case helloOk = "hello-ok"
|
||||
case req = "req"
|
||||
case res = "res"
|
||||
}
|
||||
|
||||
// MARK: - Helper functions for creating encoders and decoders
|
||||
|
||||
func newJSONDecoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) {
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
|
||||
func newJSONEncoder() -> JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) {
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
}
|
||||
return encoder
|
||||
}
|
||||
|
||||
// MARK: - Encode/decode helpers
|
||||
|
||||
class JSONNull: Codable, Hashable {
|
||||
|
||||
public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
public var hashValue: Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if !container.decodeNil() {
|
||||
throw DecodingError.typeMismatch(JSONNull.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for JSONNull"))
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encodeNil()
|
||||
}
|
||||
}
|
||||
|
||||
class JSONCodingKey: CodingKey {
|
||||
let key: String
|
||||
|
||||
required init?(intValue: Int) {
|
||||
return nil
|
||||
}
|
||||
|
||||
required init?(stringValue: String) {
|
||||
key = stringValue
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
return nil
|
||||
}
|
||||
|
||||
var stringValue: String {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
class JSONAny: Codable {
|
||||
|
||||
let value: Any
|
||||
|
||||
static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError {
|
||||
let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny")
|
||||
return DecodingError.typeMismatch(JSONAny.self, context)
|
||||
}
|
||||
|
||||
static func encodingError(forValue value: Any, codingPath: [CodingKey]) -> EncodingError {
|
||||
let context = EncodingError.Context(codingPath: codingPath, debugDescription: "Cannot encode JSONAny")
|
||||
return EncodingError.invalidValue(value, context)
|
||||
}
|
||||
|
||||
static func decode(from container: SingleValueDecodingContainer) throws -> Any {
|
||||
if let value = try? container.decode(Bool.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Int64.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Double.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(String.self) {
|
||||
return value
|
||||
}
|
||||
if container.decodeNil() {
|
||||
return JSONNull()
|
||||
}
|
||||
throw decodingError(forCodingPath: container.codingPath)
|
||||
}
|
||||
|
||||
static func decode(from container: inout UnkeyedDecodingContainer) throws -> Any {
|
||||
if let value = try? container.decode(Bool.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Int64.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Double.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(String.self) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decodeNil() {
|
||||
if value {
|
||||
return JSONNull()
|
||||
}
|
||||
}
|
||||
if var container = try? container.nestedUnkeyedContainer() {
|
||||
return try decodeArray(from: &container)
|
||||
}
|
||||
if var container = try? container.nestedContainer(keyedBy: JSONCodingKey.self) {
|
||||
return try decodeDictionary(from: &container)
|
||||
}
|
||||
throw decodingError(forCodingPath: container.codingPath)
|
||||
}
|
||||
|
||||
static func decode(from container: inout KeyedDecodingContainer<JSONCodingKey>, forKey key: JSONCodingKey) throws -> Any {
|
||||
if let value = try? container.decode(Bool.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Int64.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Double.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decodeNil(forKey: key) {
|
||||
if value {
|
||||
return JSONNull()
|
||||
}
|
||||
}
|
||||
if var container = try? container.nestedUnkeyedContainer(forKey: key) {
|
||||
return try decodeArray(from: &container)
|
||||
}
|
||||
if var container = try? container.nestedContainer(keyedBy: JSONCodingKey.self, forKey: key) {
|
||||
return try decodeDictionary(from: &container)
|
||||
}
|
||||
throw decodingError(forCodingPath: container.codingPath)
|
||||
}
|
||||
|
||||
static func decodeArray(from container: inout UnkeyedDecodingContainer) throws -> [Any] {
|
||||
var arr: [Any] = []
|
||||
while !container.isAtEnd {
|
||||
let value = try decode(from: &container)
|
||||
arr.append(value)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
static func decodeDictionary(from container: inout KeyedDecodingContainer<JSONCodingKey>) throws -> [String: Any] {
|
||||
var dict = [String: Any]()
|
||||
for key in container.allKeys {
|
||||
let value = try decode(from: &container, forKey: key)
|
||||
dict[key.stringValue] = value
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
static func encode(to container: inout UnkeyedEncodingContainer, array: [Any]) throws {
|
||||
for value in array {
|
||||
if let value = value as? Bool {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? Int64 {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? Double {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? String {
|
||||
try container.encode(value)
|
||||
} else if value is JSONNull {
|
||||
try container.encodeNil()
|
||||
} else if let value = value as? [Any] {
|
||||
var container = container.nestedUnkeyedContainer()
|
||||
try encode(to: &container, array: value)
|
||||
} else if let value = value as? [String: Any] {
|
||||
var container = container.nestedContainer(keyedBy: JSONCodingKey.self)
|
||||
try encode(to: &container, dictionary: value)
|
||||
} else {
|
||||
throw encodingError(forValue: value, codingPath: container.codingPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func encode(to container: inout KeyedEncodingContainer<JSONCodingKey>, dictionary: [String: Any]) throws {
|
||||
for (key, value) in dictionary {
|
||||
let key = JSONCodingKey(stringValue: key)!
|
||||
if let value = value as? Bool {
|
||||
try container.encode(value, forKey: key)
|
||||
} else if let value = value as? Int64 {
|
||||
try container.encode(value, forKey: key)
|
||||
} else if let value = value as? Double {
|
||||
try container.encode(value, forKey: key)
|
||||
} else if let value = value as? String {
|
||||
try container.encode(value, forKey: key)
|
||||
} else if value is JSONNull {
|
||||
try container.encodeNil(forKey: key)
|
||||
} else if let value = value as? [Any] {
|
||||
var container = container.nestedUnkeyedContainer(forKey: key)
|
||||
try encode(to: &container, array: value)
|
||||
} else if let value = value as? [String: Any] {
|
||||
var container = container.nestedContainer(keyedBy: JSONCodingKey.self, forKey: key)
|
||||
try encode(to: &container, dictionary: value)
|
||||
} else {
|
||||
throw encodingError(forValue: value, codingPath: container.codingPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func encode(to container: inout SingleValueEncodingContainer, value: Any) throws {
|
||||
if let value = value as? Bool {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? Int64 {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? Double {
|
||||
try container.encode(value)
|
||||
} else if let value = value as? String {
|
||||
try container.encode(value)
|
||||
} else if value is JSONNull {
|
||||
try container.encodeNil()
|
||||
} else {
|
||||
throw encodingError(forValue: value, codingPath: container.codingPath)
|
||||
}
|
||||
}
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
if var arrayContainer = try? decoder.unkeyedContainer() {
|
||||
self.value = try JSONAny.decodeArray(from: &arrayContainer)
|
||||
} else if var container = try? decoder.container(keyedBy: JSONCodingKey.self) {
|
||||
self.value = try JSONAny.decodeDictionary(from: &container)
|
||||
} else {
|
||||
let container = try decoder.singleValueContainer()
|
||||
self.value = try JSONAny.decode(from: container)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
if let arr = self.value as? [Any] {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try JSONAny.encode(to: &container, array: arr)
|
||||
} else if let dict = self.value as? [String: Any] {
|
||||
var container = encoder.container(keyedBy: JSONCodingKey.self)
|
||||
try JSONAny.encode(to: &container, dictionary: dict)
|
||||
} else {
|
||||
var container = encoder.singleValueContainer()
|
||||
try JSONAny.encode(to: &container, value: self.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user