@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.
- package/API_REFERENCE.md +158 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +12 -0
- package/README.md +118 -0
- package/app.plugin.js +1 -0
- package/dist/constants.d.ts +17 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +19 -0
- package/dist/discovery.d.ts +3 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +10 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +16 -0
- package/dist/host.d.ts +5 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +23 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/messageValidation.d.ts +3 -0
- package/dist/messageValidation.d.ts.map +1 -0
- package/dist/messageValidation.js +90 -0
- package/dist/nativeModule.d.ts +7 -0
- package/dist/nativeModule.d.ts.map +1 -0
- package/dist/nativeModule.js +39 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +91 -0
- package/dist/providerUtils.d.ts +3 -0
- package/dist/providerUtils.d.ts.map +1 -0
- package/dist/providerUtils.js +18 -0
- package/dist/roomUtils.d.ts +3 -0
- package/dist/roomUtils.d.ts.map +1 -0
- package/dist/roomUtils.js +15 -0
- package/dist/types.d.ts +99 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +54 -0
- package/plugin/templates/android/OfflineSignalingPackage.kt +609 -0
- package/plugin/templates/ios/OfflineSignalingHost.swift +703 -0
- package/plugin/templates/ios/OfflineSignalingHostBridge.m +22 -0
- 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
|
+
}
|