426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
632 lines
22 KiB
Swift
632 lines
22 KiB
Swift
import Foundation
|
|
import Capacitor
|
|
import Network
|
|
|
|
/**
|
|
* Gateway Plugin for Capacitor
|
|
*
|
|
* Provides WebSocket connectivity to an Eliza Gateway server.
|
|
* This implementation handles authentication, reconnection, and RPC-style
|
|
* request/response as well as event streaming. Also supports gateway
|
|
* discovery via Bonjour/mDNS.
|
|
*/
|
|
@objc(GatewayPlugin)
|
|
public class GatewayPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
public let identifier = "GatewayPlugin"
|
|
public let jsName = "Gateway"
|
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
CAPPluginMethod(name: "startDiscovery", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "stopDiscovery", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "getDiscoveredGateways", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "connect", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "disconnect", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "isConnected", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "getConnectionInfo", returnType: CAPPluginReturnPromise),
|
|
]
|
|
|
|
// Discovery
|
|
private var browser: NWBrowser?
|
|
private var discoveredGateways: [String: JSObject] = [:]
|
|
private let serviceType = "_eliza-gw._tcp"
|
|
private var isDiscovering = false
|
|
|
|
private var webSocket: URLSessionWebSocketTask?
|
|
private var urlSession: URLSession?
|
|
private var pendingRequests: [String: (resolve: (JSObject) -> Void, reject: (Error) -> Void)] = [:]
|
|
private var options: JSObject?
|
|
private var sessionId: String?
|
|
private var protocolVersion: Int?
|
|
private var role: String?
|
|
private var scopes: [String] = []
|
|
private var methods: [String] = []
|
|
private var events: [String] = []
|
|
private var lastSeq: Int?
|
|
private var isClosed = false
|
|
private var backoffMs: TimeInterval = 0.8
|
|
private var reconnectTimer: Timer?
|
|
private var connectContinuation: CheckedContinuation<JSObject, Error>?
|
|
|
|
// MARK: - Discovery Methods
|
|
|
|
@objc func startDiscovery(_ call: CAPPluginCall) {
|
|
if isDiscovering {
|
|
call.resolve(buildDiscoveryResult())
|
|
return
|
|
}
|
|
|
|
let parameters = NWBrowser.Descriptor.bonjour(type: serviceType, domain: "local.")
|
|
browser = NWBrowser(for: parameters, using: .tcp)
|
|
|
|
browser?.browseResultsChangedHandler = { [weak self] results, changes in
|
|
guard let self = self else { return }
|
|
|
|
for change in changes {
|
|
switch change {
|
|
case .added(let result):
|
|
self.handleServiceFound(result)
|
|
case .removed(let result):
|
|
self.handleServiceLost(result)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
browser?.stateUpdateHandler = { [weak self] state in
|
|
switch state {
|
|
case .ready:
|
|
self?.isDiscovering = true
|
|
case .failed(let error):
|
|
print("[Gateway] Browser failed: \(error)")
|
|
self?.isDiscovering = false
|
|
case .cancelled:
|
|
self?.isDiscovering = false
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
browser?.start(queue: .main)
|
|
|
|
// Return initial result after brief delay
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
call.resolve(self?.buildDiscoveryResult() ?? [:])
|
|
}
|
|
}
|
|
|
|
@objc func stopDiscovery(_ call: CAPPluginCall) {
|
|
browser?.cancel()
|
|
browser = nil
|
|
isDiscovering = false
|
|
call.resolve()
|
|
}
|
|
|
|
@objc func getDiscoveredGateways(_ call: CAPPluginCall) {
|
|
call.resolve(buildDiscoveryResult())
|
|
}
|
|
|
|
private func handleServiceFound(_ result: NWBrowser.Result) {
|
|
guard case .service(let name, let type, let domain, _) = result.endpoint else { return }
|
|
|
|
let connection = NWConnection(to: result.endpoint, using: .tcp)
|
|
connection.stateUpdateHandler = { [weak self] state in
|
|
guard let self = self else { return }
|
|
|
|
if case .ready = state {
|
|
if let endpoint = connection.currentPath?.remoteEndpoint,
|
|
case .hostPort(let host, let port) = endpoint {
|
|
|
|
let hostString: String
|
|
switch host {
|
|
case .ipv4(let addr):
|
|
hostString = "\(addr)"
|
|
case .ipv6(let addr):
|
|
hostString = "\(addr)"
|
|
case .name(let hostname, _):
|
|
hostString = hostname
|
|
@unknown default:
|
|
hostString = "unknown"
|
|
}
|
|
|
|
let id = self.stableId(name: name, domain: domain)
|
|
let displayName = self.decodeServiceName(name)
|
|
|
|
let gateway: JSObject = [
|
|
"stableId": id,
|
|
"name": displayName,
|
|
"host": hostString,
|
|
"port": Int(port.rawValue),
|
|
"gatewayPort": Int(port.rawValue),
|
|
"tlsEnabled": false,
|
|
"isLocal": true
|
|
]
|
|
|
|
let isNew = self.discoveredGateways[id] == nil
|
|
self.discoveredGateways[id] = gateway
|
|
|
|
self.notifyListeners("discovery", data: [
|
|
"type": isNew ? "found" : "updated",
|
|
"gateway": gateway
|
|
])
|
|
}
|
|
connection.cancel()
|
|
}
|
|
}
|
|
connection.start(queue: .main)
|
|
|
|
// Timeout for resolution
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
|
|
if connection.state != .ready {
|
|
connection.cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleServiceLost(_ result: NWBrowser.Result) {
|
|
guard case .service(let name, _, let domain, _) = result.endpoint else { return }
|
|
|
|
let id = stableId(name: name, domain: domain)
|
|
if let removed = discoveredGateways.removeValue(forKey: id) {
|
|
notifyListeners("discovery", data: [
|
|
"type": "lost",
|
|
"gateway": removed
|
|
])
|
|
}
|
|
}
|
|
|
|
private func stableId(name: String, domain: String) -> String {
|
|
return "\(serviceType)|.\(domain)|.\(name.lowercased().trimmingCharacters(in: .whitespaces))"
|
|
}
|
|
|
|
private func decodeServiceName(_ raw: String) -> String {
|
|
// Basic Bonjour escape decoding
|
|
var result = raw
|
|
let pattern = #"\\(\d{3})"#
|
|
if let regex = try? NSRegularExpression(pattern: pattern) {
|
|
let range = NSRange(result.startIndex..., in: result)
|
|
let matches = regex.matches(in: result, range: range).reversed()
|
|
for match in matches {
|
|
if let codeRange = Range(match.range(at: 1), in: result),
|
|
let code = Int(result[codeRange]),
|
|
let scalar = Unicode.Scalar(code) {
|
|
let replacement = String(Character(scalar))
|
|
if let fullRange = Range(match.range, in: result) {
|
|
result.replaceSubrange(fullRange, with: replacement)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func buildDiscoveryResult() -> JSObject {
|
|
let sortedGateways = discoveredGateways.values.sorted {
|
|
($0["name"] as? String ?? "").lowercased() < ($1["name"] as? String ?? "").lowercased()
|
|
}
|
|
|
|
return [
|
|
"gateways": sortedGateways,
|
|
"status": isDiscovering ? "Discovering..." : "Discovery stopped"
|
|
]
|
|
}
|
|
|
|
// MARK: - Connection Methods
|
|
|
|
@objc func connect(_ call: CAPPluginCall) {
|
|
guard let urlString = call.getString("url") else {
|
|
call.reject("Missing URL parameter")
|
|
return
|
|
}
|
|
|
|
guard let url = URL(string: urlString) else {
|
|
call.reject("Invalid URL")
|
|
return
|
|
}
|
|
|
|
// Store options for reconnection
|
|
options = call.jsObjectRepresentation
|
|
|
|
// Close existing connection
|
|
closeConnection()
|
|
isClosed = false
|
|
backoffMs = 0.8
|
|
|
|
Task {
|
|
do {
|
|
let result = try await establishConnection(url: url, options: call.jsObjectRepresentation)
|
|
call.resolve(result)
|
|
} catch {
|
|
call.reject("Connection failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func disconnect(_ call: CAPPluginCall) {
|
|
isClosed = true
|
|
reconnectTimer?.invalidate()
|
|
reconnectTimer = nil
|
|
closeConnection()
|
|
sessionId = nil
|
|
protocolVersion = nil
|
|
notifyStateChange(state: "disconnected", reason: "Client disconnect")
|
|
call.resolve()
|
|
}
|
|
|
|
@objc func isConnected(_ call: CAPPluginCall) {
|
|
let connected = webSocket != nil && webSocket?.state == .running
|
|
call.resolve(["connected": connected])
|
|
}
|
|
|
|
@objc func send(_ call: CAPPluginCall) {
|
|
guard let method = call.getString("method") else {
|
|
call.reject("Missing method parameter")
|
|
return
|
|
}
|
|
|
|
guard let ws = webSocket, ws.state == .running else {
|
|
call.resolve([
|
|
"ok": false,
|
|
"error": [
|
|
"code": "NOT_CONNECTED",
|
|
"message": "Not connected to gateway"
|
|
]
|
|
])
|
|
return
|
|
}
|
|
|
|
let id = UUID().uuidString
|
|
let params = call.getObject("params") ?? [:]
|
|
|
|
let frame: [String: Any] = [
|
|
"type": "req",
|
|
"id": id,
|
|
"method": method,
|
|
"params": params
|
|
]
|
|
|
|
Task {
|
|
do {
|
|
let result = try await sendRequest(id: id, frame: frame)
|
|
call.resolve(result)
|
|
} catch {
|
|
call.resolve([
|
|
"ok": false,
|
|
"error": [
|
|
"code": "REQUEST_FAILED",
|
|
"message": error.localizedDescription
|
|
]
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func getConnectionInfo(_ call: CAPPluginCall) {
|
|
call.resolve([
|
|
"url": options?["url"] as? String ?? NSNull(),
|
|
"sessionId": sessionId ?? NSNull(),
|
|
"protocol": protocolVersion ?? NSNull(),
|
|
"role": role ?? NSNull()
|
|
])
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
private func establishConnection(url: URL, options: JSObject) async throws -> JSObject {
|
|
// Create URL session with delegate
|
|
let config = URLSessionConfiguration.default
|
|
urlSession = URLSession(configuration: config, delegate: nil, delegateQueue: nil)
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 30
|
|
|
|
webSocket = urlSession?.webSocketTask(with: request)
|
|
webSocket?.resume()
|
|
|
|
// Start receiving messages
|
|
startReceiving()
|
|
|
|
// Send connect frame
|
|
return try await sendConnectFrame(options: options)
|
|
}
|
|
|
|
private func sendConnectFrame(options: JSObject) async throws -> JSObject {
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
let clientName = options["clientName"] as? String ?? "eliza-capacitor-ios"
|
|
let clientVersion = options["clientVersion"] as? String ?? "1.0.0"
|
|
let roleParam = options["role"] as? String ?? "operator"
|
|
let scopesParam = options["scopes"] as? [String] ?? ["operator.admin"]
|
|
|
|
var auth: [String: Any] = [:]
|
|
if let token = options["token"] as? String {
|
|
auth["token"] = token
|
|
}
|
|
if let password = options["password"] as? String {
|
|
auth["password"] = password
|
|
}
|
|
|
|
let params: [String: Any] = [
|
|
"minProtocol": 3,
|
|
"maxProtocol": 3,
|
|
"client": [
|
|
"id": clientName,
|
|
"version": clientVersion,
|
|
"platform": "ios",
|
|
"mode": "ui"
|
|
],
|
|
"role": roleParam,
|
|
"scopes": scopesParam,
|
|
"caps": [],
|
|
"auth": auth
|
|
]
|
|
|
|
let id = UUID().uuidString
|
|
let frame: [String: Any] = [
|
|
"type": "req",
|
|
"id": id,
|
|
"method": "connect",
|
|
"params": params
|
|
]
|
|
|
|
// Store continuation for response
|
|
self.connectContinuation = continuation
|
|
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: frame)
|
|
guard let jsonString = String(data: jsonData, encoding: .utf8) else {
|
|
continuation.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to serialize connect frame"]))
|
|
self.connectContinuation = nil
|
|
return
|
|
}
|
|
|
|
webSocket?.send(.string(jsonString)) { [weak self] error in
|
|
if let error = error {
|
|
self?.connectContinuation?.resume(throwing: error)
|
|
self?.connectContinuation = nil
|
|
}
|
|
// Response will come via receiveMessage
|
|
}
|
|
|
|
// Set timeout
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
|
|
if self?.connectContinuation != nil {
|
|
self?.connectContinuation?.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Connection timeout"]))
|
|
self?.connectContinuation = nil
|
|
}
|
|
}
|
|
} catch {
|
|
continuation.resume(throwing: error)
|
|
self.connectContinuation = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendRequest(id: String, frame: [String: Any]) async throws -> JSObject {
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
pendingRequests[id] = (
|
|
resolve: { result in continuation.resume(returning: result) },
|
|
reject: { error in continuation.resume(throwing: error) }
|
|
)
|
|
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: frame)
|
|
guard let jsonString = String(data: jsonData, encoding: .utf8) else {
|
|
pendingRequests.removeValue(forKey: id)
|
|
continuation.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to serialize request"]))
|
|
return
|
|
}
|
|
|
|
webSocket?.send(.string(jsonString)) { [weak self] error in
|
|
if let error = error {
|
|
self?.pendingRequests.removeValue(forKey: id)
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
|
|
// Set timeout
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
|
|
if self?.pendingRequests[id] != nil {
|
|
self?.pendingRequests.removeValue(forKey: id)
|
|
continuation.resume(returning: [
|
|
"ok": false,
|
|
"error": [
|
|
"code": "TIMEOUT",
|
|
"message": "Request timed out"
|
|
]
|
|
])
|
|
}
|
|
}
|
|
} catch {
|
|
pendingRequests.removeValue(forKey: id)
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startReceiving() {
|
|
webSocket?.receive { [weak self] result in
|
|
guard let self = self else { return }
|
|
|
|
switch result {
|
|
case .success(let message):
|
|
switch message {
|
|
case .string(let text):
|
|
self.handleMessage(text)
|
|
case .data(let data):
|
|
if let text = String(data: data, encoding: .utf8) {
|
|
self.handleMessage(text)
|
|
}
|
|
@unknown default:
|
|
break
|
|
}
|
|
// Continue receiving
|
|
if self.webSocket?.state == .running {
|
|
self.startReceiving()
|
|
}
|
|
|
|
case .failure(let error):
|
|
self.handleClose(error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleMessage(_ text: String) {
|
|
guard let data = text.data(using: .utf8),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return
|
|
}
|
|
|
|
let frameType = json["type"] as? String
|
|
|
|
// Handle response frames
|
|
if frameType == "res" {
|
|
guard let id = json["id"] as? String else { return }
|
|
|
|
// Check if this is the connect response
|
|
if connectContinuation != nil {
|
|
let ok = json["ok"] as? Bool ?? false
|
|
if ok, let payload = json["payload"] as? [String: Any] {
|
|
handleHelloOk(payload)
|
|
let result: JSObject = [
|
|
"connected": true,
|
|
"sessionId": sessionId ?? "",
|
|
"protocol": protocolVersion ?? 3,
|
|
"methods": methods,
|
|
"events": events,
|
|
"role": role ?? "",
|
|
"scopes": scopes
|
|
]
|
|
connectContinuation?.resume(returning: result)
|
|
connectContinuation = nil
|
|
} else {
|
|
let errorMsg = (json["error"] as? [String: Any])?["message"] as? String ?? "Connection failed"
|
|
connectContinuation?.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMsg]))
|
|
connectContinuation = nil
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle pending request
|
|
if let pending = pendingRequests[id] {
|
|
pendingRequests.removeValue(forKey: id)
|
|
let ok = json["ok"] as? Bool ?? false
|
|
var result: JSObject = ["ok": ok]
|
|
if let payload = json["payload"] {
|
|
result["payload"] = payload as? JSValue
|
|
}
|
|
if let error = json["error"] as? JSObject {
|
|
result["error"] = error
|
|
}
|
|
pending.resolve(result)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle event frames
|
|
if frameType == "event" {
|
|
guard let event = json["event"] as? String else { return }
|
|
let payload = json["payload"]
|
|
let seq = json["seq"] as? Int
|
|
|
|
// Check for sequence gap
|
|
if let seq = seq, let lastSeq = lastSeq, seq > lastSeq + 1 {
|
|
print("[Gateway] Event sequence gap: expected \(lastSeq + 1), got \(seq)")
|
|
}
|
|
if let seq = seq {
|
|
lastSeq = seq
|
|
}
|
|
|
|
// Emit event
|
|
var eventData: JSObject = ["event": event]
|
|
if let payload = payload {
|
|
eventData["payload"] = payload as? JSValue
|
|
}
|
|
if let seq = seq {
|
|
eventData["seq"] = seq
|
|
}
|
|
notifyListeners("gatewayEvent", data: eventData)
|
|
}
|
|
}
|
|
|
|
private func handleHelloOk(_ payload: [String: Any]) {
|
|
sessionId = UUID().uuidString
|
|
protocolVersion = payload["protocol"] as? Int ?? 3
|
|
|
|
if let auth = payload["auth"] as? [String: Any] {
|
|
role = auth["role"] as? String
|
|
scopes = auth["scopes"] as? [String] ?? []
|
|
}
|
|
|
|
if let features = payload["features"] as? [String: Any] {
|
|
methods = features["methods"] as? [String] ?? []
|
|
events = features["events"] as? [String] ?? []
|
|
}
|
|
|
|
backoffMs = 0.8
|
|
notifyStateChange(state: "connected")
|
|
}
|
|
|
|
private func handleClose(error: Error?) {
|
|
webSocket = nil
|
|
|
|
// Reject all pending requests
|
|
for (_, pending) in pendingRequests {
|
|
pending.reject(NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Connection closed"]))
|
|
}
|
|
pendingRequests.removeAll()
|
|
|
|
if isClosed {
|
|
notifyStateChange(state: "disconnected", reason: error?.localizedDescription)
|
|
return
|
|
}
|
|
|
|
// Attempt reconnection
|
|
notifyStateChange(state: "reconnecting", reason: error?.localizedDescription)
|
|
notifyListeners("error", data: [
|
|
"message": "Connection lost: \(error?.localizedDescription ?? "unknown")",
|
|
"willRetry": true
|
|
])
|
|
|
|
scheduleReconnect()
|
|
}
|
|
|
|
private func scheduleReconnect() {
|
|
guard !isClosed, reconnectTimer == nil else { return }
|
|
|
|
let delay = backoffMs
|
|
backoffMs = min(backoffMs * 1.7, 15.0)
|
|
|
|
reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
|
|
self?.reconnectTimer = nil
|
|
guard let self = self,
|
|
let urlString = self.options?["url"] as? String,
|
|
let url = URL(string: urlString) else {
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
_ = try await self.establishConnection(url: url, options: self.options ?? [:])
|
|
} catch {
|
|
self.handleClose(error: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func closeConnection() {
|
|
webSocket?.cancel(with: .goingAway, reason: nil)
|
|
webSocket = nil
|
|
urlSession?.invalidateAndCancel()
|
|
urlSession = nil
|
|
}
|
|
|
|
private func notifyStateChange(state: String, reason: String? = nil) {
|
|
var data: JSObject = ["state": state]
|
|
if let reason = reason {
|
|
data["reason"] = reason
|
|
}
|
|
notifyListeners("stateChange", data: data)
|
|
}
|
|
}
|