@elizaos/capacitor-gateway 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,631 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import Network
4
+
5
+ /**
6
+ * Gateway Plugin for Capacitor
7
+ *
8
+ * Provides WebSocket connectivity to an Eliza Gateway server.
9
+ * This implementation handles authentication, reconnection, and RPC-style
10
+ * request/response as well as event streaming. Also supports gateway
11
+ * discovery via Bonjour/mDNS.
12
+ */
13
+ @objc(GatewayPlugin)
14
+ public class GatewayPlugin: CAPPlugin, CAPBridgedPlugin {
15
+ public let identifier = "GatewayPlugin"
16
+ public let jsName = "Gateway"
17
+ public let pluginMethods: [CAPPluginMethod] = [
18
+ CAPPluginMethod(name: "startDiscovery", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "stopDiscovery", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "getDiscoveredGateways", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "connect", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "disconnect", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "isConnected", returnType: CAPPluginReturnPromise),
24
+ CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise),
25
+ CAPPluginMethod(name: "getConnectionInfo", returnType: CAPPluginReturnPromise),
26
+ ]
27
+
28
+ // Discovery
29
+ private var browser: NWBrowser?
30
+ private var discoveredGateways: [String: JSObject] = [:]
31
+ private let serviceType = "_eliza-gw._tcp"
32
+ private var isDiscovering = false
33
+
34
+ private var webSocket: URLSessionWebSocketTask?
35
+ private var urlSession: URLSession?
36
+ private var pendingRequests: [String: (resolve: (JSObject) -> Void, reject: (Error) -> Void)] = [:]
37
+ private var options: JSObject?
38
+ private var sessionId: String?
39
+ private var protocolVersion: Int?
40
+ private var role: String?
41
+ private var scopes: [String] = []
42
+ private var methods: [String] = []
43
+ private var events: [String] = []
44
+ private var lastSeq: Int?
45
+ private var isClosed = false
46
+ private var backoffMs: TimeInterval = 0.8
47
+ private var reconnectTimer: Timer?
48
+ private var connectContinuation: CheckedContinuation<JSObject, Error>?
49
+
50
+ // MARK: - Discovery Methods
51
+
52
+ @objc func startDiscovery(_ call: CAPPluginCall) {
53
+ if isDiscovering {
54
+ call.resolve(buildDiscoveryResult())
55
+ return
56
+ }
57
+
58
+ let parameters = NWBrowser.Descriptor.bonjour(type: serviceType, domain: "local.")
59
+ browser = NWBrowser(for: parameters, using: .tcp)
60
+
61
+ browser?.browseResultsChangedHandler = { [weak self] results, changes in
62
+ guard let self = self else { return }
63
+
64
+ for change in changes {
65
+ switch change {
66
+ case .added(let result):
67
+ self.handleServiceFound(result)
68
+ case .removed(let result):
69
+ self.handleServiceLost(result)
70
+ default:
71
+ break
72
+ }
73
+ }
74
+ }
75
+
76
+ browser?.stateUpdateHandler = { [weak self] state in
77
+ switch state {
78
+ case .ready:
79
+ self?.isDiscovering = true
80
+ case .failed(let error):
81
+ print("[Gateway] Browser failed: \(error)")
82
+ self?.isDiscovering = false
83
+ case .cancelled:
84
+ self?.isDiscovering = false
85
+ default:
86
+ break
87
+ }
88
+ }
89
+
90
+ browser?.start(queue: .main)
91
+
92
+ // Return initial result after brief delay
93
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
94
+ call.resolve(self?.buildDiscoveryResult() ?? [:])
95
+ }
96
+ }
97
+
98
+ @objc func stopDiscovery(_ call: CAPPluginCall) {
99
+ browser?.cancel()
100
+ browser = nil
101
+ isDiscovering = false
102
+ call.resolve()
103
+ }
104
+
105
+ @objc func getDiscoveredGateways(_ call: CAPPluginCall) {
106
+ call.resolve(buildDiscoveryResult())
107
+ }
108
+
109
+ private func handleServiceFound(_ result: NWBrowser.Result) {
110
+ guard case .service(let name, let type, let domain, _) = result.endpoint else { return }
111
+
112
+ let connection = NWConnection(to: result.endpoint, using: .tcp)
113
+ connection.stateUpdateHandler = { [weak self] state in
114
+ guard let self = self else { return }
115
+
116
+ if case .ready = state {
117
+ if let endpoint = connection.currentPath?.remoteEndpoint,
118
+ case .hostPort(let host, let port) = endpoint {
119
+
120
+ let hostString: String
121
+ switch host {
122
+ case .ipv4(let addr):
123
+ hostString = "\(addr)"
124
+ case .ipv6(let addr):
125
+ hostString = "\(addr)"
126
+ case .name(let hostname, _):
127
+ hostString = hostname
128
+ @unknown default:
129
+ hostString = "unknown"
130
+ }
131
+
132
+ let id = self.stableId(name: name, domain: domain)
133
+ let displayName = self.decodeServiceName(name)
134
+
135
+ let gateway: JSObject = [
136
+ "stableId": id,
137
+ "name": displayName,
138
+ "host": hostString,
139
+ "port": Int(port.rawValue),
140
+ "gatewayPort": Int(port.rawValue),
141
+ "tlsEnabled": false,
142
+ "isLocal": true
143
+ ]
144
+
145
+ let isNew = self.discoveredGateways[id] == nil
146
+ self.discoveredGateways[id] = gateway
147
+
148
+ self.notifyListeners("discovery", data: [
149
+ "type": isNew ? "found" : "updated",
150
+ "gateway": gateway
151
+ ])
152
+ }
153
+ connection.cancel()
154
+ }
155
+ }
156
+ connection.start(queue: .main)
157
+
158
+ // Timeout for resolution
159
+ DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
160
+ if connection.state != .ready {
161
+ connection.cancel()
162
+ }
163
+ }
164
+ }
165
+
166
+ private func handleServiceLost(_ result: NWBrowser.Result) {
167
+ guard case .service(let name, _, let domain, _) = result.endpoint else { return }
168
+
169
+ let id = stableId(name: name, domain: domain)
170
+ if let removed = discoveredGateways.removeValue(forKey: id) {
171
+ notifyListeners("discovery", data: [
172
+ "type": "lost",
173
+ "gateway": removed
174
+ ])
175
+ }
176
+ }
177
+
178
+ private func stableId(name: String, domain: String) -> String {
179
+ return "\(serviceType)|.\(domain)|.\(name.lowercased().trimmingCharacters(in: .whitespaces))"
180
+ }
181
+
182
+ private func decodeServiceName(_ raw: String) -> String {
183
+ // Basic Bonjour escape decoding
184
+ var result = raw
185
+ let pattern = #"\\(\d{3})"#
186
+ if let regex = try? NSRegularExpression(pattern: pattern) {
187
+ let range = NSRange(result.startIndex..., in: result)
188
+ let matches = regex.matches(in: result, range: range).reversed()
189
+ for match in matches {
190
+ if let codeRange = Range(match.range(at: 1), in: result),
191
+ let code = Int(result[codeRange]),
192
+ let scalar = Unicode.Scalar(code) {
193
+ let replacement = String(Character(scalar))
194
+ if let fullRange = Range(match.range, in: result) {
195
+ result.replaceSubrange(fullRange, with: replacement)
196
+ }
197
+ }
198
+ }
199
+ }
200
+ return result
201
+ }
202
+
203
+ private func buildDiscoveryResult() -> JSObject {
204
+ let sortedGateways = discoveredGateways.values.sorted {
205
+ ($0["name"] as? String ?? "").lowercased() < ($1["name"] as? String ?? "").lowercased()
206
+ }
207
+
208
+ return [
209
+ "gateways": sortedGateways,
210
+ "status": isDiscovering ? "Discovering..." : "Discovery stopped"
211
+ ]
212
+ }
213
+
214
+ // MARK: - Connection Methods
215
+
216
+ @objc func connect(_ call: CAPPluginCall) {
217
+ guard let urlString = call.getString("url") else {
218
+ call.reject("Missing URL parameter")
219
+ return
220
+ }
221
+
222
+ guard let url = URL(string: urlString) else {
223
+ call.reject("Invalid URL")
224
+ return
225
+ }
226
+
227
+ // Store options for reconnection
228
+ options = call.jsObjectRepresentation
229
+
230
+ // Close existing connection
231
+ closeConnection()
232
+ isClosed = false
233
+ backoffMs = 0.8
234
+
235
+ Task {
236
+ do {
237
+ let result = try await establishConnection(url: url, options: call.jsObjectRepresentation)
238
+ call.resolve(result)
239
+ } catch {
240
+ call.reject("Connection failed: \(error.localizedDescription)")
241
+ }
242
+ }
243
+ }
244
+
245
+ @objc func disconnect(_ call: CAPPluginCall) {
246
+ isClosed = true
247
+ reconnectTimer?.invalidate()
248
+ reconnectTimer = nil
249
+ closeConnection()
250
+ sessionId = nil
251
+ protocolVersion = nil
252
+ notifyStateChange(state: "disconnected", reason: "Client disconnect")
253
+ call.resolve()
254
+ }
255
+
256
+ @objc func isConnected(_ call: CAPPluginCall) {
257
+ let connected = webSocket != nil && webSocket?.state == .running
258
+ call.resolve(["connected": connected])
259
+ }
260
+
261
+ @objc func send(_ call: CAPPluginCall) {
262
+ guard let method = call.getString("method") else {
263
+ call.reject("Missing method parameter")
264
+ return
265
+ }
266
+
267
+ guard let ws = webSocket, ws.state == .running else {
268
+ call.resolve([
269
+ "ok": false,
270
+ "error": [
271
+ "code": "NOT_CONNECTED",
272
+ "message": "Not connected to gateway"
273
+ ]
274
+ ])
275
+ return
276
+ }
277
+
278
+ let id = UUID().uuidString
279
+ let params = call.getObject("params") ?? [:]
280
+
281
+ let frame: [String: Any] = [
282
+ "type": "req",
283
+ "id": id,
284
+ "method": method,
285
+ "params": params
286
+ ]
287
+
288
+ Task {
289
+ do {
290
+ let result = try await sendRequest(id: id, frame: frame)
291
+ call.resolve(result)
292
+ } catch {
293
+ call.resolve([
294
+ "ok": false,
295
+ "error": [
296
+ "code": "REQUEST_FAILED",
297
+ "message": error.localizedDescription
298
+ ]
299
+ ])
300
+ }
301
+ }
302
+ }
303
+
304
+ @objc func getConnectionInfo(_ call: CAPPluginCall) {
305
+ call.resolve([
306
+ "url": options?["url"] as? String ?? NSNull(),
307
+ "sessionId": sessionId ?? NSNull(),
308
+ "protocol": protocolVersion ?? NSNull(),
309
+ "role": role ?? NSNull()
310
+ ])
311
+ }
312
+
313
+ // MARK: - Private Methods
314
+
315
+ private func establishConnection(url: URL, options: JSObject) async throws -> JSObject {
316
+ // Create URL session with delegate
317
+ let config = URLSessionConfiguration.default
318
+ urlSession = URLSession(configuration: config, delegate: nil, delegateQueue: nil)
319
+
320
+ var request = URLRequest(url: url)
321
+ request.timeoutInterval = 30
322
+
323
+ webSocket = urlSession?.webSocketTask(with: request)
324
+ webSocket?.resume()
325
+
326
+ // Start receiving messages
327
+ startReceiving()
328
+
329
+ // Send connect frame
330
+ return try await sendConnectFrame(options: options)
331
+ }
332
+
333
+ private func sendConnectFrame(options: JSObject) async throws -> JSObject {
334
+ return try await withCheckedThrowingContinuation { continuation in
335
+ let clientName = options["clientName"] as? String ?? "eliza-capacitor-ios"
336
+ let clientVersion = options["clientVersion"] as? String ?? "1.0.0"
337
+ let roleParam = options["role"] as? String ?? "operator"
338
+ let scopesParam = options["scopes"] as? [String] ?? ["operator.admin"]
339
+
340
+ var auth: [String: Any] = [:]
341
+ if let token = options["token"] as? String {
342
+ auth["token"] = token
343
+ }
344
+ if let password = options["password"] as? String {
345
+ auth["password"] = password
346
+ }
347
+
348
+ let params: [String: Any] = [
349
+ "minProtocol": 3,
350
+ "maxProtocol": 3,
351
+ "client": [
352
+ "id": clientName,
353
+ "version": clientVersion,
354
+ "platform": "ios",
355
+ "mode": "ui"
356
+ ],
357
+ "role": roleParam,
358
+ "scopes": scopesParam,
359
+ "caps": [],
360
+ "auth": auth
361
+ ]
362
+
363
+ let id = UUID().uuidString
364
+ let frame: [String: Any] = [
365
+ "type": "req",
366
+ "id": id,
367
+ "method": "connect",
368
+ "params": params
369
+ ]
370
+
371
+ // Store continuation for response
372
+ self.connectContinuation = continuation
373
+
374
+ do {
375
+ let jsonData = try JSONSerialization.data(withJSONObject: frame)
376
+ guard let jsonString = String(data: jsonData, encoding: .utf8) else {
377
+ continuation.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to serialize connect frame"]))
378
+ self.connectContinuation = nil
379
+ return
380
+ }
381
+
382
+ webSocket?.send(.string(jsonString)) { [weak self] error in
383
+ if let error = error {
384
+ self?.connectContinuation?.resume(throwing: error)
385
+ self?.connectContinuation = nil
386
+ }
387
+ // Response will come via receiveMessage
388
+ }
389
+
390
+ // Set timeout
391
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
392
+ if self?.connectContinuation != nil {
393
+ self?.connectContinuation?.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Connection timeout"]))
394
+ self?.connectContinuation = nil
395
+ }
396
+ }
397
+ } catch {
398
+ continuation.resume(throwing: error)
399
+ self.connectContinuation = nil
400
+ }
401
+ }
402
+ }
403
+
404
+ private func sendRequest(id: String, frame: [String: Any]) async throws -> JSObject {
405
+ return try await withCheckedThrowingContinuation { continuation in
406
+ pendingRequests[id] = (
407
+ resolve: { result in continuation.resume(returning: result) },
408
+ reject: { error in continuation.resume(throwing: error) }
409
+ )
410
+
411
+ do {
412
+ let jsonData = try JSONSerialization.data(withJSONObject: frame)
413
+ guard let jsonString = String(data: jsonData, encoding: .utf8) else {
414
+ pendingRequests.removeValue(forKey: id)
415
+ continuation.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to serialize request"]))
416
+ return
417
+ }
418
+
419
+ webSocket?.send(.string(jsonString)) { [weak self] error in
420
+ if let error = error {
421
+ self?.pendingRequests.removeValue(forKey: id)
422
+ continuation.resume(throwing: error)
423
+ }
424
+ }
425
+
426
+ // Set timeout
427
+ DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
428
+ if self?.pendingRequests[id] != nil {
429
+ self?.pendingRequests.removeValue(forKey: id)
430
+ continuation.resume(returning: [
431
+ "ok": false,
432
+ "error": [
433
+ "code": "TIMEOUT",
434
+ "message": "Request timed out"
435
+ ]
436
+ ])
437
+ }
438
+ }
439
+ } catch {
440
+ pendingRequests.removeValue(forKey: id)
441
+ continuation.resume(throwing: error)
442
+ }
443
+ }
444
+ }
445
+
446
+ private func startReceiving() {
447
+ webSocket?.receive { [weak self] result in
448
+ guard let self = self else { return }
449
+
450
+ switch result {
451
+ case .success(let message):
452
+ switch message {
453
+ case .string(let text):
454
+ self.handleMessage(text)
455
+ case .data(let data):
456
+ if let text = String(data: data, encoding: .utf8) {
457
+ self.handleMessage(text)
458
+ }
459
+ @unknown default:
460
+ break
461
+ }
462
+ // Continue receiving
463
+ if self.webSocket?.state == .running {
464
+ self.startReceiving()
465
+ }
466
+
467
+ case .failure(let error):
468
+ self.handleClose(error: error)
469
+ }
470
+ }
471
+ }
472
+
473
+ private func handleMessage(_ text: String) {
474
+ guard let data = text.data(using: .utf8),
475
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
476
+ return
477
+ }
478
+
479
+ let frameType = json["type"] as? String
480
+
481
+ // Handle response frames
482
+ if frameType == "res" {
483
+ guard let id = json["id"] as? String else { return }
484
+
485
+ // Check if this is the connect response
486
+ if connectContinuation != nil {
487
+ let ok = json["ok"] as? Bool ?? false
488
+ if ok, let payload = json["payload"] as? [String: Any] {
489
+ handleHelloOk(payload)
490
+ let result: JSObject = [
491
+ "connected": true,
492
+ "sessionId": sessionId ?? "",
493
+ "protocol": protocolVersion ?? 3,
494
+ "methods": methods,
495
+ "events": events,
496
+ "role": role ?? "",
497
+ "scopes": scopes
498
+ ]
499
+ connectContinuation?.resume(returning: result)
500
+ connectContinuation = nil
501
+ } else {
502
+ let errorMsg = (json["error"] as? [String: Any])?["message"] as? String ?? "Connection failed"
503
+ connectContinuation?.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMsg]))
504
+ connectContinuation = nil
505
+ }
506
+ return
507
+ }
508
+
509
+ // Handle pending request
510
+ if let pending = pendingRequests[id] {
511
+ pendingRequests.removeValue(forKey: id)
512
+ let ok = json["ok"] as? Bool ?? false
513
+ var result: JSObject = ["ok": ok]
514
+ if let payload = json["payload"] {
515
+ result["payload"] = payload as? JSValue
516
+ }
517
+ if let error = json["error"] as? JSObject {
518
+ result["error"] = error
519
+ }
520
+ pending.resolve(result)
521
+ }
522
+ return
523
+ }
524
+
525
+ // Handle event frames
526
+ if frameType == "event" {
527
+ guard let event = json["event"] as? String else { return }
528
+ let payload = json["payload"]
529
+ let seq = json["seq"] as? Int
530
+
531
+ // Check for sequence gap
532
+ if let seq = seq, let lastSeq = lastSeq, seq > lastSeq + 1 {
533
+ print("[Gateway] Event sequence gap: expected \(lastSeq + 1), got \(seq)")
534
+ }
535
+ if let seq = seq {
536
+ lastSeq = seq
537
+ }
538
+
539
+ // Emit event
540
+ var eventData: JSObject = ["event": event]
541
+ if let payload = payload {
542
+ eventData["payload"] = payload as? JSValue
543
+ }
544
+ if let seq = seq {
545
+ eventData["seq"] = seq
546
+ }
547
+ notifyListeners("gatewayEvent", data: eventData)
548
+ }
549
+ }
550
+
551
+ private func handleHelloOk(_ payload: [String: Any]) {
552
+ sessionId = UUID().uuidString
553
+ protocolVersion = payload["protocol"] as? Int ?? 3
554
+
555
+ if let auth = payload["auth"] as? [String: Any] {
556
+ role = auth["role"] as? String
557
+ scopes = auth["scopes"] as? [String] ?? []
558
+ }
559
+
560
+ if let features = payload["features"] as? [String: Any] {
561
+ methods = features["methods"] as? [String] ?? []
562
+ events = features["events"] as? [String] ?? []
563
+ }
564
+
565
+ backoffMs = 0.8
566
+ notifyStateChange(state: "connected")
567
+ }
568
+
569
+ private func handleClose(error: Error?) {
570
+ webSocket = nil
571
+
572
+ // Reject all pending requests
573
+ for (_, pending) in pendingRequests {
574
+ pending.reject(NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Connection closed"]))
575
+ }
576
+ pendingRequests.removeAll()
577
+
578
+ if isClosed {
579
+ notifyStateChange(state: "disconnected", reason: error?.localizedDescription)
580
+ return
581
+ }
582
+
583
+ // Attempt reconnection
584
+ notifyStateChange(state: "reconnecting", reason: error?.localizedDescription)
585
+ notifyListeners("error", data: [
586
+ "message": "Connection lost: \(error?.localizedDescription ?? "unknown")",
587
+ "willRetry": true
588
+ ])
589
+
590
+ scheduleReconnect()
591
+ }
592
+
593
+ private func scheduleReconnect() {
594
+ guard !isClosed, reconnectTimer == nil else { return }
595
+
596
+ let delay = backoffMs
597
+ backoffMs = min(backoffMs * 1.7, 15.0)
598
+
599
+ reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
600
+ self?.reconnectTimer = nil
601
+ guard let self = self,
602
+ let urlString = self.options?["url"] as? String,
603
+ let url = URL(string: urlString) else {
604
+ return
605
+ }
606
+
607
+ Task {
608
+ do {
609
+ _ = try await self.establishConnection(url: url, options: self.options ?? [:])
610
+ } catch {
611
+ self.handleClose(error: error)
612
+ }
613
+ }
614
+ }
615
+ }
616
+
617
+ private func closeConnection() {
618
+ webSocket?.cancel(with: .goingAway, reason: nil)
619
+ webSocket = nil
620
+ urlSession?.invalidateAndCancel()
621
+ urlSession = nil
622
+ }
623
+
624
+ private func notifyStateChange(state: String, reason: String? = nil) {
625
+ var data: JSObject = ["state": state]
626
+ if let reason = reason {
627
+ data["reason"] = reason
628
+ }
629
+ notifyListeners("stateChange", data: data)
630
+ }
631
+ }