@adeeliore/p2p-lan-signaling 0.1.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.
Files changed (43) hide show
  1. package/API_REFERENCE.md +158 -0
  2. package/CHANGELOG.md +9 -0
  3. package/LICENSE +12 -0
  4. package/README.md +118 -0
  5. package/app.plugin.js +1 -0
  6. package/dist/constants.d.ts +17 -0
  7. package/dist/constants.d.ts.map +1 -0
  8. package/dist/constants.js +19 -0
  9. package/dist/discovery.d.ts +3 -0
  10. package/dist/discovery.d.ts.map +1 -0
  11. package/dist/discovery.js +10 -0
  12. package/dist/errors.d.ts +16 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/errors.js +16 -0
  15. package/dist/host.d.ts +5 -0
  16. package/dist/host.d.ts.map +1 -0
  17. package/dist/host.js +23 -0
  18. package/dist/index.d.ts +11 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +35 -0
  21. package/dist/messageValidation.d.ts +3 -0
  22. package/dist/messageValidation.d.ts.map +1 -0
  23. package/dist/messageValidation.js +90 -0
  24. package/dist/nativeModule.d.ts +7 -0
  25. package/dist/nativeModule.d.ts.map +1 -0
  26. package/dist/nativeModule.js +39 -0
  27. package/dist/provider.d.ts +20 -0
  28. package/dist/provider.d.ts.map +1 -0
  29. package/dist/provider.js +91 -0
  30. package/dist/providerUtils.d.ts +3 -0
  31. package/dist/providerUtils.d.ts.map +1 -0
  32. package/dist/providerUtils.js +18 -0
  33. package/dist/roomUtils.d.ts +3 -0
  34. package/dist/roomUtils.d.ts.map +1 -0
  35. package/dist/roomUtils.js +15 -0
  36. package/dist/types.d.ts +99 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +2 -0
  39. package/package.json +54 -0
  40. package/plugin/templates/android/OfflineSignalingPackage.kt +609 -0
  41. package/plugin/templates/ios/OfflineSignalingHost.swift +703 -0
  42. package/plugin/templates/ios/OfflineSignalingHostBridge.m +22 -0
  43. package/plugin/withP2PLanSignaling.js +144 -0
@@ -0,0 +1,703 @@
1
+ import CryptoKit
2
+ import Foundation
3
+ import Network
4
+ import React
5
+
6
+ @objc(OfflineSignalingHost)
7
+ final class OfflineSignalingHost: NSObject {
8
+ private let manager = IosOfflineSignalingHostManager()
9
+
10
+ @objc
11
+ static func requiresMainQueueSetup() -> Bool {
12
+ return false
13
+ }
14
+
15
+ @objc(startHost:roomId:displayName:roomSecret:resolver:rejecter:)
16
+ func startHost(
17
+ _ port: NSNumber,
18
+ roomId: String?,
19
+ displayName: String?,
20
+ roomSecret: String?,
21
+ resolver resolve: RCTPromiseResolveBlock,
22
+ rejecter reject: RCTPromiseRejectBlock
23
+ ) {
24
+ do {
25
+ resolve(try manager.start(port: port.intValue, roomId: roomId, displayName: displayName, roomSecret: roomSecret).dictionary)
26
+ } catch {
27
+ reject("offline_host_start_failed", error.localizedDescription, error)
28
+ }
29
+ }
30
+
31
+ @objc(stopHost:rejecter:)
32
+ func stopHost(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
33
+ manager.stop()
34
+ resolve(nil)
35
+ }
36
+
37
+ @objc(getStatus:rejecter:)
38
+ func getStatus(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
39
+ resolve(manager.status.dictionary)
40
+ }
41
+
42
+ @objc(discoverHosts:resolver:rejecter:)
43
+ func discoverHosts(_ timeoutMs: NSNumber, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
44
+ resolve(manager.discover(timeoutMs: timeoutMs.intValue).map { $0.dictionary })
45
+ }
46
+ }
47
+
48
+ private struct IosOfflineHostStatus {
49
+ let running: Bool
50
+ let hostAddress: String?
51
+ let port: Int?
52
+ let url: String?
53
+ let roomId: String?
54
+ let serviceName: String?
55
+ let serviceType: String?
56
+
57
+ var dictionary: [String: Any?] {
58
+ [
59
+ "running": running,
60
+ "hostAddress": hostAddress,
61
+ "port": port,
62
+ "url": url,
63
+ "roomId": roomId,
64
+ "serviceName": serviceName,
65
+ "serviceType": serviceType
66
+ ]
67
+ }
68
+ }
69
+
70
+ private struct IosOfflineDiscoveredHost {
71
+ let serviceName: String
72
+ let serviceType: String
73
+ let hostAddress: String
74
+ let port: Int
75
+ let url: String
76
+ let roomId: String?
77
+ let displayName: String?
78
+
79
+ var dictionary: [String: Any?] {
80
+ [
81
+ "serviceName": serviceName,
82
+ "serviceType": serviceType,
83
+ "hostAddress": hostAddress,
84
+ "port": port,
85
+ "url": url,
86
+ "roomId": roomId,
87
+ "displayName": displayName
88
+ ]
89
+ }
90
+ }
91
+
92
+ private enum IosOfflineSecurityPolicy {
93
+ static let maxParticipants = 10
94
+ static let maxTextFrameBytes = 65_536
95
+ static let sessionPattern = "^[A-Za-z0-9_-]{8,64}$"
96
+ static let clientPattern = "^[A-Za-z0-9_-]{3,64}$"
97
+
98
+ static func isValidSessionId(_ value: String) -> Bool {
99
+ return value.range(of: sessionPattern, options: .regularExpression) != nil
100
+ }
101
+
102
+ static func isValidClientId(_ value: String) -> Bool {
103
+ return value.range(of: clientPattern, options: .regularExpression) != nil
104
+ }
105
+ }
106
+
107
+ private enum IosOfflineSignalingContract {
108
+ static let moduleName = "OfflineSignalingHost"
109
+ static let serviceType = "_drawin._tcp"
110
+ static let bonjourServiceType = "\(serviceType)."
111
+ static let bonjourDomain = "local."
112
+ static let websocketPath = "/ws"
113
+ static let querySessionId = "sessionId"
114
+ static let queryClientId = "clientId"
115
+ static let queryAccessToken = "accessToken"
116
+ static let queryRoomSecret = "roomSecret"
117
+ static let txtRoomId = "roomId"
118
+ static let txtDisplayName = "displayName"
119
+ static let defaultFallbackPort: UInt16 = 8090
120
+ static let handshakeReadBufferBytes = 4096
121
+ static let discoveryMinTimeoutMs = 500
122
+ static let discoveryMaxTimeoutMs = 10_000
123
+ static let serviceResolveTimeoutSeconds: TimeInterval = 2
124
+ }
125
+
126
+ private final class IosOfflineSignalingHostManager {
127
+ private let queue = DispatchQueue(label: "drawin.offline.host.manager")
128
+ private let discovery = IosLanServiceDiscovery()
129
+ private var server: IosOfflineSignalingServer?
130
+
131
+ var status: IosOfflineHostStatus {
132
+ queue.sync {
133
+ server?.status ?? IosOfflineHostStatus(
134
+ running: false,
135
+ hostAddress: nil,
136
+ port: nil,
137
+ url: nil,
138
+ roomId: nil,
139
+ serviceName: nil,
140
+ serviceType: IosOfflineSignalingContract.serviceType
141
+ )
142
+ }
143
+ }
144
+
145
+ func start(port: Int, roomId: String?, displayName: String?, roomSecret: String?) throws -> IosOfflineHostStatus {
146
+ try queue.sync {
147
+ if let existing = server, existing.matches(port: port, roomId: roomId, displayName: displayName, roomSecret: roomSecret) {
148
+ return existing.status
149
+ }
150
+ server?.stop()
151
+ let next = IosOfflineSignalingServer(port: port, roomId: roomId, displayName: displayName, roomSecret: roomSecret, discovery: discovery)
152
+ server = next
153
+ return try next.start()
154
+ }
155
+ }
156
+
157
+ func stop() {
158
+ queue.sync {
159
+ server?.stop()
160
+ server = nil
161
+ }
162
+ }
163
+
164
+ func discover(timeoutMs: Int) -> [IosOfflineDiscoveredHost] {
165
+ discovery.discover(timeoutMs: timeoutMs)
166
+ }
167
+ }
168
+
169
+ private final class IosOfflineSignalingServer {
170
+ private let requestedPort: Int
171
+ private let roomId: String?
172
+ private let displayName: String?
173
+ private let roomSecret: String?
174
+ private let discovery: IosLanServiceDiscovery
175
+ private let queue = DispatchQueue(label: "drawin.offline.host.server")
176
+ private let router = IosOfflineSignalingRouter()
177
+ private var listener: NWListener?
178
+ private var clients: [ObjectIdentifier: IosOfflineSignalingClient] = [:]
179
+ private var running = false
180
+ private var hostAddress: String?
181
+ private var actualPort: Int?
182
+
183
+ init(port: Int, roomId: String?, displayName: String?, roomSecret: String?, discovery: IosLanServiceDiscovery) {
184
+ self.requestedPort = port
185
+ self.roomId = roomId
186
+ self.displayName = displayName
187
+ self.roomSecret = roomSecret
188
+ self.discovery = discovery
189
+ }
190
+
191
+ var status: IosOfflineHostStatus {
192
+ let address = hostAddress
193
+ let port = actualPort ?? requestedPort
194
+ return IosOfflineHostStatus(
195
+ running: running,
196
+ hostAddress: address,
197
+ port: running ? port : nil,
198
+ url: running && address != nil ? "ws://\(address!):\(port)" : nil,
199
+ roomId: roomId,
200
+ serviceName: running ? serviceName : nil,
201
+ serviceType: IosOfflineSignalingContract.serviceType
202
+ )
203
+ }
204
+
205
+ var serviceName: String {
206
+ if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
207
+ return displayName
208
+ }
209
+ return "Drawin \(roomId?.isEmpty == false ? roomId! : String(requestedPort))"
210
+ }
211
+
212
+ func matches(port: Int, roomId: String?, displayName: String?, roomSecret: String?) -> Bool {
213
+ requestedPort == port && self.roomId == roomId && self.displayName == displayName && self.roomSecret == roomSecret
214
+ }
215
+
216
+ func start() throws -> IosOfflineHostStatus {
217
+ guard !running else { return status }
218
+ let nwPort = NWEndpoint.Port(rawValue: UInt16(requestedPort)) ?? NWEndpoint.Port(rawValue: IosOfflineSignalingContract.defaultFallbackPort)!
219
+ let listener = try NWListener(using: .tcp, on: nwPort)
220
+ self.listener = listener
221
+ self.hostAddress = IosNetworkAddressResolver.resolveIpv4Address()
222
+ self.actualPort = Int(listener.port?.rawValue ?? nwPort.rawValue)
223
+
224
+ listener.newConnectionHandler = { [weak self] connection in
225
+ self?.queue.async {
226
+ guard let self, self.running else {
227
+ connection.cancel()
228
+ return
229
+ }
230
+ let client = IosOfflineSignalingClient(connection: connection, router: self.router, roomSecret: self.roomSecret)
231
+ self.clients[ObjectIdentifier(client)] = client
232
+ client.onClose = { [weak self, weak client] in
233
+ guard let self, let client else { return }
234
+ self.queue.async {
235
+ self.router.unregister(client)
236
+ self.clients.removeValue(forKey: ObjectIdentifier(client))
237
+ }
238
+ }
239
+ client.start()
240
+ }
241
+ }
242
+
243
+ running = true
244
+ listener.start(queue: queue)
245
+ discovery.register(serviceName: serviceName, port: actualPort ?? requestedPort, roomId: roomId, displayName: displayName)
246
+ return status
247
+ }
248
+
249
+ func stop() {
250
+ guard running else { return }
251
+ running = false
252
+ discovery.unregister()
253
+ clients.values.forEach { $0.close() }
254
+ clients.removeAll()
255
+ listener?.cancel()
256
+ listener = nil
257
+ }
258
+ }
259
+
260
+ private final class IosOfflineSignalingClient {
261
+ private let connection: NWConnection
262
+ private let router: IosOfflineSignalingRouter
263
+ private let roomSecret: String?
264
+ private let sendQueue = DispatchQueue(label: "drawin.offline.host.client.send")
265
+ private var handshakeBuffer = Data()
266
+ private var frameBuffer = Data()
267
+ private var closed = false
268
+
269
+ var sessionId: String?
270
+ var clientId: String?
271
+ var onClose: (() -> Void)?
272
+
273
+ init(connection: NWConnection, router: IosOfflineSignalingRouter, roomSecret: String?) {
274
+ self.connection = connection
275
+ self.router = router
276
+ self.roomSecret = roomSecret
277
+ }
278
+
279
+ func start() {
280
+ connection.stateUpdateHandler = { [weak self] state in
281
+ if case .failed = state { self?.close() }
282
+ if case .cancelled = state { self?.close() }
283
+ }
284
+ connection.start(queue: DispatchQueue(label: "drawin.offline.host.client"))
285
+ readHandshake()
286
+ }
287
+
288
+ func attach(sessionId: String, clientId: String) {
289
+ self.sessionId = sessionId
290
+ self.clientId = clientId
291
+ }
292
+
293
+ func send(_ message: String) {
294
+ guard !closed else { return }
295
+ let data = IosWebSocketFrameCodec.encodeText(message)
296
+ sendQueue.async { [weak self] in
297
+ self?.connection.send(content: data, completion: .contentProcessed { error in
298
+ if error != nil { self?.close() }
299
+ })
300
+ }
301
+ }
302
+
303
+ func close() {
304
+ guard !closed else { return }
305
+ closed = true
306
+ connection.cancel()
307
+ onClose?()
308
+ }
309
+
310
+ private func readHandshake() {
311
+ connection.receive(minimumIncompleteLength: 1, maximumLength: IosOfflineSignalingContract.handshakeReadBufferBytes) { [weak self] data, _, isComplete, error in
312
+ guard let self else { return }
313
+ if error != nil || isComplete {
314
+ self.close()
315
+ return
316
+ }
317
+ if let data { self.handshakeBuffer.append(data) }
318
+ guard let headerEnd = self.handshakeBuffer.range(of: Data("\r\n\r\n".utf8)) else {
319
+ self.readHandshake()
320
+ return
321
+ }
322
+ let headerData = self.handshakeBuffer[..<headerEnd.upperBound]
323
+ let remaining = self.handshakeBuffer[headerEnd.upperBound...]
324
+ do {
325
+ let request = try IosWebSocketHandshake.accept(headerData: Data(headerData), connection: self.connection, roomSecret: self.roomSecret)
326
+ self.router.register(sessionId: request.sessionId, requestedClientId: request.clientId, client: self)
327
+ if !remaining.isEmpty {
328
+ self.frameBuffer.append(remaining)
329
+ self.consumeFrames()
330
+ }
331
+ self.readFrames()
332
+ } catch {
333
+ self.close()
334
+ }
335
+ }
336
+ }
337
+
338
+ private func readFrames() {
339
+ connection.receive(minimumIncompleteLength: 1, maximumLength: IosOfflineSecurityPolicy.maxTextFrameBytes) { [weak self] data, _, isComplete, error in
340
+ guard let self else { return }
341
+ if error != nil || isComplete {
342
+ self.close()
343
+ return
344
+ }
345
+ if let data {
346
+ self.frameBuffer.append(data)
347
+ self.consumeFrames()
348
+ }
349
+ self.readFrames()
350
+ }
351
+ }
352
+
353
+ private func consumeFrames() {
354
+ while true {
355
+ switch IosWebSocketFrameCodec.decodeText(from: &frameBuffer) {
356
+ case .message(let message):
357
+ router.handleClientMessage(client: self, rawMessage: message)
358
+ case .closed:
359
+ close()
360
+ return
361
+ case .needMoreData:
362
+ return
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ private final class IosOfflineSignalingRouter {
369
+ private var sessions: [String: [String: IosOfflineSignalingClient]] = [:]
370
+ private let lock = NSLock()
371
+
372
+ func register(sessionId: String, requestedClientId: String?, client: IosOfflineSignalingClient) {
373
+ lock.lock()
374
+ defer { lock.unlock() }
375
+
376
+ guard IosOfflineSecurityPolicy.isValidSessionId(sessionId) else {
377
+ client.send(error(code: "invalid_session_id", message: "Invalid session id"))
378
+ client.close()
379
+ return
380
+ }
381
+ var session = sessions[sessionId] ?? [:]
382
+ guard session.count < IosOfflineSecurityPolicy.maxParticipants else {
383
+ client.send(error(code: "session_full", message: "Session is full"))
384
+ client.close()
385
+ return
386
+ }
387
+ let clientId = resolveClientId(session: session, requestedClientId: requestedClientId)
388
+ guard IosOfflineSecurityPolicy.isValidClientId(clientId) else {
389
+ client.send(error(code: "invalid_client_id", message: "Invalid client id"))
390
+ client.close()
391
+ return
392
+ }
393
+
394
+ client.attach(sessionId: sessionId, clientId: clientId)
395
+ let peers = session.keys.map { ["clientId": $0] }
396
+ session[clientId] = client
397
+ sessions[sessionId] = session
398
+ client.send(json(["type": "welcome", "sessionId": sessionId, "clientId": clientId, "peers": peers]))
399
+ broadcastExcept(sessionId: sessionId, exceptClientId: clientId, message: json(["type": "peer-joined", "sessionId": sessionId, "clientId": clientId]))
400
+ }
401
+
402
+ func unregister(_ client: IosOfflineSignalingClient) {
403
+ lock.lock()
404
+ defer { lock.unlock() }
405
+ guard let sessionId = client.sessionId, let clientId = client.clientId, var session = sessions[sessionId] else { return }
406
+ session.removeValue(forKey: clientId)
407
+ if session.isEmpty {
408
+ sessions.removeValue(forKey: sessionId)
409
+ return
410
+ }
411
+ sessions[sessionId] = session
412
+ broadcast(sessionId: sessionId, message: json(["type": "peer-left", "sessionId": sessionId, "clientId": clientId]))
413
+ }
414
+
415
+ func handleClientMessage(client: IosOfflineSignalingClient, rawMessage: String) {
416
+ guard !rawMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
417
+ let data = rawMessage.data(using: .utf8),
418
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
419
+ let type = object["type"] as? String else {
420
+ return
421
+ }
422
+ switch type {
423
+ case "ping":
424
+ client.send(json(["type": "pong"]))
425
+ case "offer", "answer", "ice":
426
+ relay(client: client, type: type, object: object)
427
+ default:
428
+ client.send(error(code: "unsupported_message", message: "Unsupported signaling message type: \(type)"))
429
+ }
430
+ }
431
+
432
+ private func relay(client: IosOfflineSignalingClient, type: String, object: [String: Any]) {
433
+ lock.lock()
434
+ defer { lock.unlock() }
435
+ guard let sessionId = client.sessionId, let from = client.clientId, let to = object["to"] as? String else { return }
436
+ guard IosOfflineSecurityPolicy.isValidClientId(to) else {
437
+ client.send(error(code: "invalid_to", message: "Invalid relay target"))
438
+ return
439
+ }
440
+ guard let target = sessions[sessionId]?[to] else {
441
+ client.send(error(code: "peer_not_found", message: "Peer not found: \(to)"))
442
+ return
443
+ }
444
+ var message: [String: Any] = ["type": type, "sessionId": sessionId, "from": from, "to": to]
445
+ if let payload = object["payload"] { message["payload"] = payload }
446
+ target.send(json(message))
447
+ }
448
+
449
+ private func broadcast(sessionId: String, message: String) {
450
+ sessions[sessionId]?.values.forEach { $0.send(message) }
451
+ }
452
+
453
+ private func broadcastExcept(sessionId: String, exceptClientId: String, message: String) {
454
+ sessions[sessionId]?.filter { $0.key != exceptClientId }.values.forEach { $0.send(message) }
455
+ }
456
+
457
+ private func resolveClientId(session: [String: IosOfflineSignalingClient], requestedClientId: String?) -> String {
458
+ let base = requestedClientId?.isEmpty == false ? requestedClientId! : "user-\(UUID().uuidString.prefix(4).lowercased())"
459
+ guard IosOfflineSecurityPolicy.isValidClientId(base) else {
460
+ return "user-\(UUID().uuidString.prefix(4).lowercased())"
461
+ }
462
+ if session[base] == nil { return base }
463
+ var suffix = 2
464
+ while session["\(base)-\(suffix)"] != nil { suffix += 1 }
465
+ return "\(base)-\(suffix)"
466
+ }
467
+
468
+ private func error(code: String, message: String) -> String {
469
+ json(["type": "error", "code": code, "message": message])
470
+ }
471
+
472
+ private func json(_ object: Any) -> String {
473
+ let data = (try? JSONSerialization.data(withJSONObject: object)) ?? Data("{}".utf8)
474
+ return String(data: data, encoding: .utf8) ?? "{}"
475
+ }
476
+ }
477
+
478
+ private struct IosWebSocketHandshakeRequest {
479
+ let sessionId: String
480
+ let clientId: String?
481
+ }
482
+
483
+ private enum IosWebSocketHandshake {
484
+ static let guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
485
+
486
+ static func accept(headerData: Data, connection: NWConnection, roomSecret: String?) throws -> IosWebSocketHandshakeRequest {
487
+ guard let headerText = String(data: headerData, encoding: .utf8) else {
488
+ throw NSError(domain: "OfflineSignalingHost", code: 1)
489
+ }
490
+ let lines = headerText.components(separatedBy: "\r\n")
491
+ guard let requestLine = lines.first else { throw NSError(domain: "OfflineSignalingHost", code: 2) }
492
+ let parts = requestLine.split(separator: " ")
493
+ guard parts.count >= 2 else { throw NSError(domain: "OfflineSignalingHost", code: 3) }
494
+ let path = String(parts[1])
495
+ let headers = Dictionary(uniqueKeysWithValues: lines.dropFirst().compactMap { line -> (String, String)? in
496
+ guard let separator = line.firstIndex(of: ":") else { return nil }
497
+ return (line[..<separator].lowercased(), line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces))
498
+ })
499
+ guard let key = headers["sec-websocket-key"] else { throw NSError(domain: "OfflineSignalingHost", code: 4) }
500
+ let digest = Insecure.SHA1.hash(data: Data((key + guid).utf8))
501
+ let acceptKey = Data(digest).base64EncodedString()
502
+ let response = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: \(acceptKey)\r\n\r\n"
503
+ connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
504
+
505
+ guard let components = URLComponents(string: "ws://localhost\(path)"),
506
+ components.path == IosOfflineSignalingContract.websocketPath else {
507
+ throw NSError(domain: "OfflineSignalingHost", code: 5)
508
+ }
509
+ let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") })
510
+ let sessionId = query[IosOfflineSignalingContract.querySessionId]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
511
+ guard IosOfflineSecurityPolicy.isValidSessionId(sessionId) else {
512
+ throw NSError(domain: "OfflineSignalingHost", code: 6)
513
+ }
514
+ let clientId = query[IosOfflineSignalingContract.queryClientId]?.trimmingCharacters(in: .whitespacesAndNewlines)
515
+ if let clientId, !clientId.isEmpty, !IosOfflineSecurityPolicy.isValidClientId(clientId) {
516
+ throw NSError(domain: "OfflineSignalingHost", code: 7)
517
+ }
518
+ if let roomSecret, !roomSecret.isEmpty {
519
+ let providedSecret = query[IosOfflineSignalingContract.queryAccessToken] ?? query[IosOfflineSignalingContract.queryRoomSecret]
520
+ guard providedSecret == roomSecret else { throw NSError(domain: "OfflineSignalingHost", code: 8) }
521
+ }
522
+ return IosWebSocketHandshakeRequest(sessionId: sessionId, clientId: clientId?.isEmpty == false ? clientId : nil)
523
+ }
524
+ }
525
+
526
+ private enum IosWebSocketDecodeResult {
527
+ case message(String)
528
+ case closed
529
+ case needMoreData
530
+ }
531
+
532
+ private enum IosWebSocketFrameCodec {
533
+ static func decodeText(from buffer: inout Data) -> IosWebSocketDecodeResult {
534
+ guard buffer.count >= 2 else { return .needMoreData }
535
+ let first = buffer[buffer.startIndex]
536
+ let second = buffer[buffer.index(after: buffer.startIndex)]
537
+ let opcode = first & 0x0F
538
+ let masked = (second & 0x80) != 0
539
+ var offset = 2
540
+ var length = Int(second & 0x7F)
541
+ if length == 126 {
542
+ guard buffer.count >= offset + 2 else { return .needMoreData }
543
+ length = (Int(buffer[offset]) << 8) | Int(buffer[offset + 1])
544
+ offset += 2
545
+ } else if length == 127 {
546
+ guard buffer.count >= offset + 8 else { return .needMoreData }
547
+ var longLength = 0
548
+ for _ in 0..<8 {
549
+ longLength = (longLength << 8) | Int(buffer[offset])
550
+ offset += 1
551
+ }
552
+ length = longLength
553
+ }
554
+ guard length <= IosOfflineSecurityPolicy.maxTextFrameBytes else { return .closed }
555
+ let maskLength = masked ? 4 : 0
556
+ guard buffer.count >= offset + maskLength + length else { return .needMoreData }
557
+ var mask: [UInt8] = []
558
+ if masked {
559
+ mask = Array(buffer[offset..<offset + 4])
560
+ offset += 4
561
+ }
562
+ var payload = Array(buffer[offset..<offset + length])
563
+ if masked {
564
+ for index in payload.indices {
565
+ payload[index] = payload[index] ^ mask[index % 4]
566
+ }
567
+ }
568
+ buffer.removeSubrange(buffer.startIndex..<buffer.index(buffer.startIndex, offsetBy: offset + length))
569
+ if opcode == 0x8 { return .closed }
570
+ if opcode != 0x1 { return .message("") }
571
+ return .message(String(bytes: payload, encoding: .utf8) ?? "")
572
+ }
573
+
574
+ static func encodeText(_ message: String) -> Data {
575
+ let payload = Array(message.utf8)
576
+ var data = Data([0x81])
577
+ if payload.count < 126 {
578
+ data.append(UInt8(payload.count))
579
+ } else if payload.count <= 65_535 {
580
+ data.append(126)
581
+ data.append(UInt8((payload.count >> 8) & 0xFF))
582
+ data.append(UInt8(payload.count & 0xFF))
583
+ } else {
584
+ data.append(127)
585
+ let count = UInt64(payload.count)
586
+ for shift in stride(from: 56, through: 0, by: -8) {
587
+ data.append(UInt8((count >> UInt64(shift)) & 0xFF))
588
+ }
589
+ }
590
+ data.append(contentsOf: payload)
591
+ return data
592
+ }
593
+ }
594
+
595
+ private final class IosLanServiceDiscovery: NSObject, NetServiceBrowserDelegate, NetServiceDelegate {
596
+ private var service: NetService?
597
+ private var browser: NetServiceBrowser?
598
+ private var pendingResolve: [NetService] = []
599
+ private var results: [IosOfflineDiscoveredHost] = []
600
+ private var semaphore: DispatchSemaphore?
601
+ private let lock = NSLock()
602
+
603
+ func register(serviceName: String, port: Int, roomId: String?, displayName: String?) {
604
+ unregister()
605
+ let next = NetService(domain: IosOfflineSignalingContract.bonjourDomain, type: IosOfflineSignalingContract.bonjourServiceType, name: serviceName, port: Int32(port))
606
+ var txt: [String: Data] = [:]
607
+ if let roomId, !roomId.isEmpty { txt[IosOfflineSignalingContract.txtRoomId] = Data(roomId.utf8) }
608
+ if let displayName, !displayName.isEmpty { txt[IosOfflineSignalingContract.txtDisplayName] = Data(displayName.utf8) }
609
+ next.setTXTRecord(NetService.data(fromTXTRecord: txt))
610
+ next.delegate = self
611
+ next.publish(options: [])
612
+ service = next
613
+ }
614
+
615
+ func unregister() {
616
+ service?.stop()
617
+ service = nil
618
+ }
619
+
620
+ func discover(timeoutMs: Int) -> [IosOfflineDiscoveredHost] {
621
+ let sem = DispatchSemaphore(value: 0)
622
+ DispatchQueue.main.async {
623
+ self.lock.lock()
624
+ self.results = []
625
+ self.pendingResolve = []
626
+ self.lock.unlock()
627
+ let browser = NetServiceBrowser()
628
+ self.browser = browser
629
+ browser.delegate = self
630
+ self.semaphore = sem
631
+ browser.searchForServices(ofType: IosOfflineSignalingContract.bonjourServiceType, inDomain: IosOfflineSignalingContract.bonjourDomain)
632
+ }
633
+ _ = sem.wait(timeout: .now() + .milliseconds(max(IosOfflineSignalingContract.discoveryMinTimeoutMs, min(timeoutMs, IosOfflineSignalingContract.discoveryMaxTimeoutMs))))
634
+ DispatchQueue.main.async {
635
+ self.browser?.stop()
636
+ self.browser = nil
637
+ self.semaphore = nil
638
+ }
639
+ lock.lock()
640
+ let snapshot = results
641
+ lock.unlock()
642
+ return Array(Dictionary(grouping: snapshot, by: { "\($0.url):\($0.roomId ?? "")" }).compactMap { $0.value.first })
643
+ }
644
+
645
+ func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
646
+ service.delegate = self
647
+ pendingResolve.append(service)
648
+ service.resolve(withTimeout: IosOfflineSignalingContract.serviceResolveTimeoutSeconds)
649
+ }
650
+
651
+ func netServiceDidResolveAddress(_ sender: NetService) {
652
+ let host = sender.hostName?.trimmingCharacters(in: CharacterSet(charactersIn: ".")) ?? sender.hostName ?? ""
653
+ guard sender.port > 0, !host.isEmpty else { return }
654
+ let txt = NetService.dictionary(fromTXTRecord: sender.txtRecordData() ?? Data())
655
+ let roomId = txt[IosOfflineSignalingContract.txtRoomId].flatMap { String(data: $0, encoding: .utf8) } ?? parseRoomId(sender.name)
656
+ let displayName = txt[IosOfflineSignalingContract.txtDisplayName].flatMap { String(data: $0, encoding: .utf8) }
657
+ lock.lock()
658
+ results.append(IosOfflineDiscoveredHost(
659
+ serviceName: sender.name,
660
+ serviceType: sender.type,
661
+ hostAddress: host,
662
+ port: sender.port,
663
+ url: "ws://\(host):\(sender.port)",
664
+ roomId: roomId,
665
+ displayName: displayName
666
+ ))
667
+ lock.unlock()
668
+ }
669
+
670
+ func netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser) {
671
+ semaphore?.signal()
672
+ }
673
+
674
+ private func parseRoomId(_ serviceName: String) -> String? {
675
+ serviceName.range(of: "ROOM-[A-Z0-9]+", options: .regularExpression).map { String(serviceName[$0]) }
676
+ }
677
+ }
678
+
679
+ private enum IosNetworkAddressResolver {
680
+ static func resolveIpv4Address() -> String? {
681
+ var addresses: [String] = []
682
+ var interfaces: UnsafeMutablePointer<ifaddrs>?
683
+ guard getifaddrs(&interfaces) == 0, let first = interfaces else { return nil }
684
+ defer { freeifaddrs(interfaces) }
685
+ var cursor: UnsafeMutablePointer<ifaddrs>? = first
686
+ while cursor != nil {
687
+ defer { cursor = cursor?.pointee.ifa_next }
688
+ guard let interface = cursor?.pointee,
689
+ let address = interface.ifa_addr,
690
+ address.pointee.sa_family == UInt8(AF_INET) else {
691
+ continue
692
+ }
693
+ let name = String(cString: interface.ifa_name)
694
+ guard name == "en0" || name.hasPrefix("en") else { continue }
695
+ var addr = address.pointee
696
+ var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
697
+ getnameinfo(&addr, socklen_t(address.pointee.sa_len), &hostname, socklen_t(hostname.count), nil, 0, NI_NUMERICHOST)
698
+ let value = String(cString: hostname)
699
+ if !value.isEmpty && value != "127.0.0.1" { addresses.append(value) }
700
+ }
701
+ return addresses.first { $0.hasPrefix("192.168.") || $0.hasPrefix("10.") || $0.hasPrefix("172.") } ?? addresses.first
702
+ }
703
+ }