@mentra/glasses-media 3.2.1-dev.314 → 3.3.0-beta.942
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/README.md +14 -2
- package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +8 -7
- package/android/src/main/java/com/mentra/glassesmedia/publisher/PhoneWhipPublisher.kt +11 -8
- package/android/src/main/java/com/mentra/glassesmedia/source/DecodedFrameTap.kt +224 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/StillPhotoInbox.kt +50 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestProtocol.kt +22 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +110 -10
- package/index.d.ts +11 -0
- package/index.js +9 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/DecodedFrameTapCore.swift +204 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotAccessPurpose.swift +5 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotInterfacePolicy.swift +155 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotJoinFailure.swift +23 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotNetworkOwnership.swift +22 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAssociation.swift +161 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAuthorization.swift +59 -0
- package/ios/DecodedFrameTap.swift +64 -0
- package/ios/GlassesHotspotNetwork.swift +254 -73
- package/ios/GlassesMedia.podspec +1 -1
- package/ios/GlassesMediaRelayModule.swift +61 -11
- package/package.json +6 -1
- package/ios/CoreKit/Sources/GlassesMediaCore/MacE2EHotspotLease.swift +0 -20
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import CoreVideo
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// A second, optional consumer of decoded glasses video, sitting beside the ACS sender.
|
|
5
|
+
///
|
|
6
|
+
/// The call owns this pipeline; the preview is a guest. `offer` never blocks, never lets a thrown
|
|
7
|
+
/// error reach its caller, and never does real work — a sink may only decide whether it wants the
|
|
8
|
+
/// frame, retain it, and schedule bounded work elsewhere. The logic lives in
|
|
9
|
+
/// `DecodedFrameTapCore` so it can be unit tested without CoreVideo.
|
|
10
|
+
///
|
|
11
|
+
/// Detach is generation-checked because sessions overlap: a call ending late must not tear down
|
|
12
|
+
/// the subscription a newer call already installed.
|
|
13
|
+
public final class DecodedFrameTap {
|
|
14
|
+
public static let shared = DecodedFrameTap()
|
|
15
|
+
|
|
16
|
+
public typealias Metrics = DecodedFrameTapCore<CVPixelBuffer>.Metrics
|
|
17
|
+
|
|
18
|
+
private let core = DecodedFrameTapCore<CVPixelBuffer>()
|
|
19
|
+
|
|
20
|
+
public init() {}
|
|
21
|
+
|
|
22
|
+
/// Install the preview sink and return the generation that owns it.
|
|
23
|
+
@discardableResult
|
|
24
|
+
public func attach(
|
|
25
|
+
_ sink: @escaping (CVPixelBuffer) throws -> Void,
|
|
26
|
+
onSinkError: ((Error) -> Void)? = nil
|
|
27
|
+
) -> UInt64 {
|
|
28
|
+
core.attach(sink, onSinkError: onSinkError)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Remove the sink only if it is still the one this generation installed.
|
|
32
|
+
@discardableResult
|
|
33
|
+
public func detach(generation: UInt64) -> Bool {
|
|
34
|
+
core.detach(generation: generation)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public func isCurrent(generation: UInt64) -> Bool {
|
|
38
|
+
core.isCurrent(generation: generation)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public var hasSink: Bool { core.hasSink }
|
|
42
|
+
|
|
43
|
+
/// Collect cadence, offer cost and the ACS send count even with no sink attached.
|
|
44
|
+
public func setTelemetryEnabled(_ enabled: Bool) {
|
|
45
|
+
core.setTelemetryEnabled(enabled)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public var telemetryEnabled: Bool { core.telemetryEnabled }
|
|
49
|
+
|
|
50
|
+
/// Read and clear the accumulated observations. Called about once a second by the reporter.
|
|
51
|
+
public func drainMetrics() -> Metrics {
|
|
52
|
+
core.drainMetrics()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// The ACS sender accepted a frame. Called by acs-meeting right after a successful send.
|
|
56
|
+
public func recordAcsSend() {
|
|
57
|
+
core.recordAcsSend()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Called on the decoder's thread, immediately before the frame goes to ACS.
|
|
61
|
+
public func offer(_ buffer: CVPixelBuffer) {
|
|
62
|
+
core.offer(buffer)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -4,81 +4,65 @@ import Network
|
|
|
4
4
|
import NetworkExtension
|
|
5
5
|
import OSLog
|
|
6
6
|
|
|
7
|
-
///
|
|
7
|
+
/// Shared hotspot association owner. Only local glasses traffic uses Wi-Fi.
|
|
8
8
|
public final class GlassesHotspotNetwork {
|
|
9
9
|
private let logger = Logger(subsystem: "com.mentra.glassesmedia", category: "hotspot")
|
|
10
10
|
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.hotspot")
|
|
11
11
|
private var ssid: String?
|
|
12
12
|
private var lastHotspotSSID: String?
|
|
13
|
-
|
|
13
|
+
/// The verified Wi-Fi interface and DHCP address. Every later check uses this exact pair.
|
|
14
|
+
private var binding: HotspotInterfaceBinding?
|
|
15
|
+
private var accessPurpose: HotspotAccessPurpose = .outgoingClient
|
|
14
16
|
private var gatewayAddress: String?
|
|
15
17
|
private var generation = 0
|
|
16
18
|
private var applying = false
|
|
17
19
|
private var ownsConfiguration = false
|
|
18
20
|
private var cancelled = false
|
|
19
21
|
private var joinReply: ((Result<String, Error>) -> Void)?
|
|
20
|
-
private var leaveReplies: [() -> Void] = []
|
|
21
|
-
private var
|
|
22
|
+
private var leaveReplies: [(Result<Void, Error>) -> Void] = []
|
|
23
|
+
private var interfaceMonitors: [NWPathMonitor] = []
|
|
24
|
+
/// Wi-Fi identity learned since this join started; replaced on every join and cleared on leave.
|
|
25
|
+
private var wifiIdentity = HotspotWifiIdentity()
|
|
22
26
|
private var localAccess: LocalNetworkAccessRequest?
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
private var localAddress: String? {
|
|
28
|
+
binding?.address
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private let macWorker = DispatchQueue(label: "com.mentra.glassesmedia.mac-wifi")
|
|
32
|
+
private var macAssociation: MacHotspotAssociation?
|
|
33
|
+
private var macAuthorization: MacHotspotAuthorization?
|
|
34
|
+
private var ownershipToken: UUID?
|
|
35
|
+
private var finishingLeave = false
|
|
26
36
|
public var onLost: ((String) -> Void)?
|
|
27
37
|
public var onPermissionRequired: (() -> Void)?
|
|
28
38
|
public init() {}
|
|
29
39
|
|
|
30
|
-
public func join(ssid: String, passphrase: String, gateway: String? = nil, completion: @escaping (Result<String, Error>) -> Void) {
|
|
40
|
+
public func join(ssid: String, passphrase: String, gateway: String? = nil, purpose: HotspotAccessPurpose = .outgoingClient, completion: @escaping (Result<String, Error>) -> Void) {
|
|
31
41
|
queue.async {
|
|
32
42
|
guard self.ssid == nil, !self.applying else { completion(.failure(LocalMediaError("Previous hotspot session has not finished cleaning up"))); return }
|
|
33
43
|
if let gateway, !LocalMediaPolicy.isPrivate(gateway) {
|
|
34
44
|
completion(.failure(LocalMediaError("The glasses reported an invalid hotspot gateway"))); return
|
|
35
45
|
}
|
|
46
|
+
if ProcessInfo.processInfo.isiOSAppOnMac, gateway == nil {
|
|
47
|
+
completion(.failure(LocalMediaError("The glasses did not report their hotspot gateway"))); return
|
|
48
|
+
}
|
|
49
|
+
guard let token = HotspotNetworkOwnership.shared.acquire() else {
|
|
50
|
+
completion(.failure(LocalMediaError("The glasses hotspot is already in use"))); return
|
|
51
|
+
}
|
|
52
|
+
self.ownershipToken = token
|
|
36
53
|
self.generation += 1
|
|
37
54
|
let gen = self.generation
|
|
38
55
|
self.ssid = ssid
|
|
39
56
|
self.lastHotspotSSID = ssid
|
|
40
57
|
self.gatewayAddress = gateway
|
|
58
|
+
self.accessPurpose = purpose
|
|
41
59
|
self.cancelled = false
|
|
42
60
|
self.applying = false
|
|
43
61
|
self.ownsConfiguration = false
|
|
44
62
|
self.joinReply = completion
|
|
63
|
+
self.startInterfaceMonitors(generation: gen)
|
|
45
64
|
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
46
|
-
|
|
47
|
-
if let raw = ProcessInfo.processInfo.environment["MENTRA_E2E_PREJOINED_HOTSPOT"] {
|
|
48
|
-
let address = Self.wifiAddress()
|
|
49
|
-
guard !self.testLeaseConsumed,
|
|
50
|
-
let lease = try? JSONDecoder().decode(MacE2EHotspotLease.self, from: Data(raw.utf8)),
|
|
51
|
-
lease.matches(ssid: ssid, gateway: gateway, address: address, now: Date().timeIntervalSince1970),
|
|
52
|
-
let address
|
|
53
|
-
else {
|
|
54
|
-
self.finishJoin(.failure(LocalMediaError("Mac E2E network lease is invalid, expired or already consumed")))
|
|
55
|
-
self.finishLeave()
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
self.testLeaseConsumed = true
|
|
59
|
-
self.localAddress = address
|
|
60
|
-
self.logger.notice("HOTSPOT_JOIN test_harness_connection native_association_untested")
|
|
61
|
-
self.startMonitor(generation: gen)
|
|
62
|
-
self.waitForLocalAccess(address: address, generation: gen)
|
|
63
|
-
return
|
|
64
|
-
}
|
|
65
|
-
#endif
|
|
66
|
-
NEHotspotNetwork.fetchCurrent { network in
|
|
67
|
-
self.queue.async {
|
|
68
|
-
guard gen == self.generation, !self.cancelled else { return }
|
|
69
|
-
let address = Self.wifiAddress()
|
|
70
|
-
if let address, LocalMediaPolicy.canReuseHotspot(requestedSSID: ssid, currentSSID: network?.ssid,
|
|
71
|
-
address: address, gateway: gateway)
|
|
72
|
-
{
|
|
73
|
-
self.localAddress = address
|
|
74
|
-
self.logger.info("HOTSPOT_JOIN reuse_verified_macos_connection")
|
|
75
|
-
self.startMonitor(generation: gen)
|
|
76
|
-
self.waitForLocalAccess(address: address, generation: gen)
|
|
77
|
-
} else {
|
|
78
|
-
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
65
|
+
self.authorizeMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
82
66
|
} else {
|
|
83
67
|
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
84
68
|
}
|
|
@@ -87,6 +71,8 @@ public final class GlassesHotspotNetwork {
|
|
|
87
71
|
// Its response time is not a hotspot association timeout.
|
|
88
72
|
guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
|
|
89
73
|
self.cancelled = true
|
|
74
|
+
self.macAssociation?.cancel()
|
|
75
|
+
self.macAuthorization?.cancel()
|
|
90
76
|
self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
|
|
91
77
|
// apply() cannot be cancelled. Retain the reservation until its callback and remove the
|
|
92
78
|
// late configuration before another call is permitted to acquire the network.
|
|
@@ -96,9 +82,11 @@ public final class GlassesHotspotNetwork {
|
|
|
96
82
|
}
|
|
97
83
|
}
|
|
98
84
|
|
|
99
|
-
public func leave(completion: @escaping () -> Void) {
|
|
85
|
+
public func leave(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
100
86
|
queue.async {
|
|
101
87
|
self.cancelled = true
|
|
88
|
+
self.macAssociation?.cancel()
|
|
89
|
+
self.macAuthorization?.cancel()
|
|
102
90
|
self.leaveReplies.append(completion)
|
|
103
91
|
self.finishJoin(.failure(LocalMediaError("Hotspot join cancelled")))
|
|
104
92
|
if let ssid = self.ssid, self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
@@ -106,6 +94,24 @@ public final class GlassesHotspotNetwork {
|
|
|
106
94
|
}
|
|
107
95
|
}
|
|
108
96
|
|
|
97
|
+
/// The verified hotspot address while its own Wi-Fi interface still holds it; nil after
|
|
98
|
+
/// loss, leave or an address change. Never falls back to another interface.
|
|
99
|
+
public func verifiedAddress(completion: @escaping (String?) -> Void) {
|
|
100
|
+
queue.async {
|
|
101
|
+
guard let binding = self.binding, !self.cancelled, self.isBindingIntact(binding) else { completion(nil); return }
|
|
102
|
+
if let association = self.macAssociation {
|
|
103
|
+
let ssid = self.ssid
|
|
104
|
+
let gen = self.generation
|
|
105
|
+
self.macWorker.async {
|
|
106
|
+
let matched = (try? association.currentSSID()) == ssid
|
|
107
|
+
self.queue.async {
|
|
108
|
+
completion(gen == self.generation && !self.cancelled && matched && self.isBindingIntact(binding) ? binding.address : nil)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} else { completion(binding.address) }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
109
115
|
public func info(completion: @escaping ([String: Any]) -> Void) {
|
|
110
116
|
queue.async {
|
|
111
117
|
var value: [String: Any] = ["available": self.localAddress != nil]
|
|
@@ -124,7 +130,7 @@ public final class GlassesHotspotNetwork {
|
|
|
124
130
|
let parameters = NWParameters.tcp
|
|
125
131
|
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
126
132
|
// The Mac route probe rejects the Wi-Fi type constraint on an otherwise
|
|
127
|
-
// usable
|
|
133
|
+
// usable hotspot route. Restrict this connection to the verified source IP.
|
|
128
134
|
parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(address), port: .any)
|
|
129
135
|
} else {
|
|
130
136
|
parameters.requiredInterfaceType = .wifi
|
|
@@ -188,6 +194,72 @@ public final class GlassesHotspotNetwork {
|
|
|
188
194
|
}
|
|
189
195
|
}
|
|
190
196
|
|
|
197
|
+
private func authorizeMacAssociation(ssid: String, passphrase: String, generation gen: Int) {
|
|
198
|
+
let authorization = MacHotspotAuthorization()
|
|
199
|
+
macAuthorization = authorization
|
|
200
|
+
authorization.start { result in
|
|
201
|
+
self.queue.async {
|
|
202
|
+
guard gen == self.generation, !self.cancelled else { return }
|
|
203
|
+
self.macAuthorization = nil
|
|
204
|
+
switch result {
|
|
205
|
+
case .success: self.applyMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
206
|
+
case let .failure(error):
|
|
207
|
+
self.finishJoin(.failure(error)); self.finishLeave()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private func applyMacAssociation(ssid: String, passphrase: String, generation gen: Int) {
|
|
214
|
+
let association = MacHotspotAssociation()
|
|
215
|
+
macAssociation = association
|
|
216
|
+
applying = true
|
|
217
|
+
macWorker.async {
|
|
218
|
+
let result = Result { try association.associate(ssid: ssid, password: passphrase) }
|
|
219
|
+
self.queue.async {
|
|
220
|
+
guard gen == self.generation else { return }
|
|
221
|
+
self.applying = false
|
|
222
|
+
if self.cancelled { self.finishLeave(); return }
|
|
223
|
+
switch result {
|
|
224
|
+
case let .success(name):
|
|
225
|
+
let index = Int(if_nametoindex(name))
|
|
226
|
+
guard index > 0 else {
|
|
227
|
+
self.finishJoin(.failure(LocalMediaError("Mac Wi-Fi interface disappeared during association")))
|
|
228
|
+
self.finishLeave(); return
|
|
229
|
+
}
|
|
230
|
+
self.wifiIdentity.observe([HotspotInterfaceReport(name: name, index: index, isWifi: true)])
|
|
231
|
+
self.waitForMacAddress(ssid: ssid, association: association, generation: gen, remaining: 60)
|
|
232
|
+
case let .failure(error):
|
|
233
|
+
self.finishJoin(.failure(error)); self.finishLeave()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private func waitForMacAddress(ssid: String, association: MacHotspotAssociation, generation gen: Int, remaining: Int) {
|
|
240
|
+
guard gen == generation, !cancelled else { return }
|
|
241
|
+
macWorker.async {
|
|
242
|
+
let membership = Result { try association.currentSSID() == ssid }
|
|
243
|
+
self.queue.async {
|
|
244
|
+
guard gen == self.generation, !self.cancelled else { return }
|
|
245
|
+
if case let .failure(error) = membership {
|
|
246
|
+
self.finishJoin(.failure(error)); self.finishLeave(); return
|
|
247
|
+
}
|
|
248
|
+
if (try? membership.get()) == true, let selected = self.selectBinding() {
|
|
249
|
+
self.bind(selected)
|
|
250
|
+
self.finishAssociation(address: selected.address, generation: gen)
|
|
251
|
+
} else if remaining > 0 {
|
|
252
|
+
self.queue.asyncAfter(deadline: .now() + 0.5) {
|
|
253
|
+
self.waitForMacAddress(ssid: ssid, association: association, generation: gen, remaining: remaining - 1)
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
self.finishJoin(.failure(LocalMediaError("Mac Wi-Fi has no verified glasses hotspot address")))
|
|
257
|
+
self.finishLeave()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
191
263
|
private func applyConfiguration(ssid: String, passphrase: String, generation gen: Int) {
|
|
192
264
|
let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
|
|
193
265
|
config.joinOnce = false
|
|
@@ -221,34 +293,93 @@ public final class GlassesHotspotNetwork {
|
|
|
221
293
|
NEHotspotNetwork.fetchCurrent { [weak self] network in
|
|
222
294
|
self?.queue.async {
|
|
223
295
|
guard let self, gen == self.generation, !self.cancelled else { return }
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
self.
|
|
228
|
-
self.startMonitor(generation: gen)
|
|
229
|
-
self.waitForLocalAccess(address: address, generation: gen)
|
|
296
|
+
// The selection applies the BLE gateway's client check when one was reported.
|
|
297
|
+
if network?.ssid == ssid, let selected = self.selectBinding() {
|
|
298
|
+
self.bind(selected)
|
|
299
|
+
self.finishAssociation(address: selected.address, generation: gen)
|
|
230
300
|
} else if remaining > 0 {
|
|
231
301
|
self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
|
|
232
302
|
} else {
|
|
233
303
|
let association = network == nil ? "unavailable" : (network?.ssid == ssid ? "matched" : "different")
|
|
234
|
-
|
|
235
|
-
self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address (SSID=\(association), Wi-Fi IPv4=\(address))")))
|
|
304
|
+
self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address (SSID=\(association), \(self.wifiDiagnostic()))")))
|
|
236
305
|
self.finishLeave()
|
|
237
306
|
}
|
|
238
307
|
}
|
|
239
308
|
}
|
|
240
309
|
}
|
|
241
310
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
311
|
+
/// iOS can omit an internet-less Wi-Fi from its default path, and iOS-on-Mac has rejected
|
|
312
|
+
/// a Wi-Fi type constraint on a usable hotspot route. Both paths' interface types feed one
|
|
313
|
+
/// session identity (HotspotWifiIdentity): a positive Wi-Fi report outlives later omission
|
|
314
|
+
/// only while the same interface stays attached; contrary reports and replacement fail closed.
|
|
315
|
+
/// A phone that starts on cellular never gets a report; HotspotInterfacePolicy then uses
|
|
316
|
+
/// the hotspot client address as the evidence.
|
|
317
|
+
private func startInterfaceMonitors(generation gen: Int) {
|
|
318
|
+
interfaceMonitors.forEach { $0.cancel() }
|
|
319
|
+
let monitors = [NWPathMonitor(requiredInterfaceType: .wifi), NWPathMonitor()]
|
|
320
|
+
interfaceMonitors = monitors
|
|
321
|
+
wifiIdentity = HotspotWifiIdentity()
|
|
322
|
+
for monitor in monitors {
|
|
323
|
+
monitor.pathUpdateHandler = { [weak self] path in
|
|
324
|
+
guard let self, gen == self.generation else { return }
|
|
325
|
+
self.wifiIdentity.observe(path.availableInterfaces.map {
|
|
326
|
+
HotspotInterfaceReport(name: $0.name, index: $0.index, isWifi: $0.type == .wifi)
|
|
327
|
+
})
|
|
328
|
+
// This AP intentionally has no internet. Loss of its default internet path is not
|
|
329
|
+
// loss of the local link; check the verified interface and address instead.
|
|
330
|
+
if let binding = self.binding, !self.cancelled, !self.isBindingIntact(binding) {
|
|
331
|
+
self.onLost?("Glasses hotspot connection was lost")
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
monitor.start(queue: queue)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/// Every check samples interfaces first, so a detach or replacement between path updates
|
|
339
|
+
/// is recorded in the session identity before it is used.
|
|
340
|
+
private func observeInterfaces() -> HotspotInterfaceSnapshot {
|
|
341
|
+
let snapshot = Self.interfaceSnapshot()
|
|
342
|
+
wifiIdentity.observe(snapshot)
|
|
343
|
+
return snapshot
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private func selectBinding() -> HotspotInterfaceBinding? {
|
|
347
|
+
HotspotInterfacePolicy.select(snapshot: observeInterfaces(), identity: wifiIdentity, gateway: gatewayAddress)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private func isBindingIntact(_ binding: HotspotInterfaceBinding) -> Bool {
|
|
351
|
+
let snapshot = observeInterfaces()
|
|
352
|
+
return HotspotInterfacePolicy.isIntact(binding, snapshot: snapshot, identity: wifiIdentity)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private func bind(_ selected: HotspotInterfaceBinding) {
|
|
356
|
+
let evidence = wifiIdentity.trusted[selected.interface] == selected.index ? "wifi_report" : "hotspot_client"
|
|
357
|
+
wifiIdentity.adopt(selected)
|
|
358
|
+
binding = selected
|
|
359
|
+
logger.info("HOTSPOT_JOIN address_verified interface=\(selected.interface, privacy: .public) evidence=\(evidence, privacy: .public)")
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/// Interface names and their IPv4 addresses only; never credentials or SSIDs.
|
|
363
|
+
private func wifiDiagnostic() -> String {
|
|
364
|
+
let snapshot = observeInterfaces()
|
|
365
|
+
let wifi = Set(wifiIdentity.trusted.keys)
|
|
366
|
+
let addresses = snapshot.addresses.filter { wifi.contains($0.name) }.map { "\($0.name)=\($0.ipv4)" }
|
|
367
|
+
let other = snapshot.addresses.filter { !wifi.contains($0.name) && LocalMediaPolicy.isPrivate($0.ipv4) }
|
|
368
|
+
.map { "\($0.name)\(snapshot.ethernetLinks.contains($0.name) ? "" : "(non-ethernet)")=\($0.ipv4)" }
|
|
369
|
+
return "Wi-Fi interfaces=\(wifi.isEmpty ? "none" : wifi.sorted().joined(separator: ",")), " +
|
|
370
|
+
"Wi-Fi IPv4=\(addresses.isEmpty ? "none" : addresses.sorted().joined(separator: ",")), " +
|
|
371
|
+
"other private IPv4=\(other.isEmpty ? "none" : other.sorted().joined(separator: ","))"
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private func finishAssociation(address: String, generation gen: Int) {
|
|
375
|
+
// OTA serves incoming TCP. Its native listener proves server readiness; dialing
|
|
376
|
+
// the glasses' unrelated Gallery server would add a client permission dependency.
|
|
377
|
+
// TN3179: accepting incoming TCP does not require Local Network access.
|
|
378
|
+
if accessPurpose == .incomingServer {
|
|
379
|
+
finishJoin(.success(address))
|
|
380
|
+
} else {
|
|
381
|
+
waitForLocalAccess(address: address, generation: gen)
|
|
250
382
|
}
|
|
251
|
-
monitor.start(queue: queue)
|
|
252
383
|
}
|
|
253
384
|
|
|
254
385
|
private func waitForLocalAccess(address: String, generation gen: Int) {
|
|
@@ -279,34 +410,84 @@ public final class GlassesHotspotNetwork {
|
|
|
279
410
|
}
|
|
280
411
|
|
|
281
412
|
private func finishLeave() {
|
|
413
|
+
guard !finishingLeave else { return }
|
|
414
|
+
finishingLeave = true
|
|
415
|
+
if let association = macAssociation {
|
|
416
|
+
association.cancel()
|
|
417
|
+
// This queue is also the association queue: cleanup cannot overtake
|
|
418
|
+
// a blocking scan/join, and process-wide ownership remains held.
|
|
419
|
+
macWorker.async {
|
|
420
|
+
let result = Result { try association.leave() }
|
|
421
|
+
self.queue.async {
|
|
422
|
+
switch result {
|
|
423
|
+
case .success:
|
|
424
|
+
self.macAssociation = nil
|
|
425
|
+
self.completeLeave()
|
|
426
|
+
case let .failure(error):
|
|
427
|
+
// Retain the owner when we cannot prove which association
|
|
428
|
+
// cleanup would disconnect. A later leave can retry safely.
|
|
429
|
+
self.finishingLeave = false
|
|
430
|
+
let replies = self.leaveReplies
|
|
431
|
+
self.leaveReplies.removeAll()
|
|
432
|
+
replies.forEach { $0(.failure(error)) }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} else { completeLeave() }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private func completeLeave() {
|
|
282
440
|
generation += 1
|
|
441
|
+
macAuthorization?.cancel(); macAuthorization = nil
|
|
283
442
|
localAccess?.cancel(); localAccess = nil
|
|
284
|
-
|
|
443
|
+
interfaceMonitors.forEach { $0.cancel() }
|
|
444
|
+
interfaceMonitors = []
|
|
445
|
+
wifiIdentity = HotspotWifiIdentity()
|
|
285
446
|
if let ssid, ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
286
447
|
ownsConfiguration = false
|
|
287
448
|
ssid = nil
|
|
288
|
-
|
|
449
|
+
binding = nil
|
|
289
450
|
gatewayAddress = nil
|
|
451
|
+
finishingLeave = false
|
|
452
|
+
if let token = ownershipToken { HotspotNetworkOwnership.shared.release(token) }
|
|
453
|
+
ownershipToken = nil
|
|
290
454
|
let replies = leaveReplies
|
|
291
455
|
leaveReplies.removeAll()
|
|
292
|
-
replies.forEach { $0() }
|
|
456
|
+
replies.forEach { $0(.success(())) }
|
|
293
457
|
}
|
|
294
458
|
|
|
295
|
-
|
|
459
|
+
/// Every attached interface's kernel index and link type (its AF_LINK row) and every IPv4
|
|
460
|
+
/// address with its interface name and up state. Selection belongs to HotspotInterfacePolicy;
|
|
461
|
+
/// an interface name alone never identifies Wi-Fi. A failed read yields an empty sample, which
|
|
462
|
+
/// fails closed.
|
|
463
|
+
static func interfaceSnapshot() -> HotspotInterfaceSnapshot {
|
|
296
464
|
var interfaces: UnsafeMutablePointer<ifaddrs>?
|
|
297
|
-
guard getifaddrs(&interfaces) == 0 else { return
|
|
465
|
+
guard getifaddrs(&interfaces) == 0 else { return HotspotInterfaceSnapshot(links: [:], addresses: []) }
|
|
298
466
|
defer { freeifaddrs(interfaces) }
|
|
467
|
+
var links: [String: Int] = [:]
|
|
468
|
+
var ethernetLinks: Set<String> = []
|
|
469
|
+
var rows: [HotspotInterfaceAddress] = []
|
|
299
470
|
var cursor = interfaces
|
|
300
471
|
while let item = cursor {
|
|
301
472
|
defer { cursor = item.pointee.ifa_next }
|
|
302
473
|
let value = item.pointee
|
|
303
|
-
guard
|
|
304
|
-
|
|
474
|
+
guard let address = value.ifa_addr else { continue }
|
|
475
|
+
let name = String(cString: value.ifa_name)
|
|
476
|
+
if address.pointee.sa_family == UInt8(AF_LINK) {
|
|
477
|
+
let (index, type) = address.withMemoryRebound(to: sockaddr_dl.self, capacity: 1) {
|
|
478
|
+
(Int($0.pointee.sdl_index), Int32($0.pointee.sdl_type))
|
|
479
|
+
}
|
|
480
|
+
if index > 0 {
|
|
481
|
+
links[name] = index
|
|
482
|
+
if type == IFT_ETHER { ethernetLinks.insert(name) }
|
|
483
|
+
}
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
guard address.pointee.sa_family == UInt8(AF_INET) else { continue }
|
|
305
487
|
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
|
306
488
|
guard getnameinfo(address, socklen_t(address.pointee.sa_len), &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue }
|
|
307
|
-
|
|
308
|
-
if LocalMediaPolicy.isPrivate(ip) { return ip }
|
|
489
|
+
rows.append(HotspotInterfaceAddress(name: name, isUp: value.ifa_flags & UInt32(IFF_UP) != 0, ipv4: String(cString: host)))
|
|
309
490
|
}
|
|
310
|
-
return
|
|
491
|
+
return HotspotInterfaceSnapshot(links: links, addresses: rows, ethernetLinks: ethernetLinks)
|
|
311
492
|
}
|
|
312
493
|
}
|
package/ios/GlassesMedia.podspec
CHANGED
|
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
|
|
|
14
14
|
s.dependency 'ExpoModulesCore'
|
|
15
15
|
# Keep in sync with the iOS-only @livekit/react-native-webrtc podspec patch.
|
|
16
16
|
s.dependency 'WebRTC-SDK', '144.7559.15'
|
|
17
|
-
s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'Network', 'NetworkExtension'
|
|
17
|
+
s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'CoreLocation', 'Network', 'NetworkExtension'
|
|
18
18
|
s.source_files = '*.{swift,h,m,mm}', 'CoreKit/Sources/GlassesMediaCore/*.swift'
|
|
19
19
|
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
|
|
20
20
|
end
|
|
@@ -4,11 +4,49 @@ import Foundation
|
|
|
4
4
|
public final class GlassesMediaRelayModule: Module {
|
|
5
5
|
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.relay")
|
|
6
6
|
private var session: ManagedRelaySession?
|
|
7
|
+
private let localHotspot = GlassesHotspotNetwork()
|
|
8
|
+
private var localHotspotOwner: String?
|
|
7
9
|
|
|
8
10
|
public func definition() -> ModuleDefinition {
|
|
9
11
|
Name("MentraGlassesMediaRelay")
|
|
10
12
|
Events("onRelayState")
|
|
11
13
|
|
|
14
|
+
AsyncFunction("joinLocalHotspot") { (id: String, ssid: String, password: String, gateway: String?, purpose: String, promise: Promise) in
|
|
15
|
+
self.queue.async {
|
|
16
|
+
guard self.localHotspotOwner == nil else {
|
|
17
|
+
promise.reject(LocalMediaError("Previous hotspot session has not been released")); return
|
|
18
|
+
}
|
|
19
|
+
guard let accessPurpose = HotspotAccessPurpose(rawValue: purpose) else {
|
|
20
|
+
promise.reject(LocalMediaError("Invalid hotspot access purpose")); return
|
|
21
|
+
}
|
|
22
|
+
self.localHotspotOwner = id
|
|
23
|
+
self.localHotspot.join(ssid: ssid, passphrase: password, gateway: gateway, purpose: accessPurpose) { result in
|
|
24
|
+
switch result {
|
|
25
|
+
case let .success(address): promise.resolve(address)
|
|
26
|
+
case let .failure(error):
|
|
27
|
+
let failure = HotspotJoinFailure(error)
|
|
28
|
+
promise.reject(Exception(name: "HotspotJoinFailure", description: failure.message, code: failure.code))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
AsyncFunction("leaveLocalHotspot") { (id: String, promise: Promise) in
|
|
35
|
+
self.queue.async {
|
|
36
|
+
guard self.localHotspotOwner == id else { promise.resolve(nil); return }
|
|
37
|
+
self.localHotspot.leave { result in
|
|
38
|
+
self.queue.async {
|
|
39
|
+
switch result {
|
|
40
|
+
case .success:
|
|
41
|
+
if self.localHotspotOwner == id { self.localHotspotOwner = nil }
|
|
42
|
+
promise.resolve(nil)
|
|
43
|
+
case let .failure(error): promise.reject(error)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
12
50
|
AsyncFunction("prepare") { (options: [String: Any], promise: Promise) in
|
|
13
51
|
guard let id = options["attemptId"] as? String,
|
|
14
52
|
let ingest = options["ingestUrl"] as? String, let url = URL(string: ingest), url.scheme == "https",
|
|
@@ -38,16 +76,23 @@ public final class GlassesMediaRelayModule: Module {
|
|
|
38
76
|
AsyncFunction("stop") { (id: String, promise: Promise) in
|
|
39
77
|
self.queue.async {
|
|
40
78
|
guard let session = self.session, session.id == id else { promise.resolve(nil); return }
|
|
41
|
-
session.stop {
|
|
42
|
-
|
|
43
|
-
|
|
79
|
+
session.stop { result in
|
|
80
|
+
switch result {
|
|
81
|
+
case .success:
|
|
82
|
+
if self.session === session { self.session = nil }
|
|
83
|
+
promise.resolve(nil)
|
|
84
|
+
case let .failure(error): promise.reject(error)
|
|
85
|
+
}
|
|
44
86
|
}
|
|
45
87
|
}
|
|
46
88
|
}
|
|
47
89
|
|
|
48
90
|
OnDestroy {
|
|
49
91
|
self.queue.async {
|
|
50
|
-
self.session?.stop {
|
|
92
|
+
self.session?.stop { result in
|
|
93
|
+
if case .success = result { self.session = nil }
|
|
94
|
+
}
|
|
95
|
+
self.localHotspot.leave { _ in }
|
|
51
96
|
}
|
|
52
97
|
}
|
|
53
98
|
}
|
|
@@ -62,7 +107,7 @@ private final class ManagedRelaySession {
|
|
|
62
107
|
private var publisher: PhoneWhipPublisher?
|
|
63
108
|
private var stopped = false
|
|
64
109
|
private var stopFinished = false
|
|
65
|
-
private var stopReplies: [() -> Void] = []
|
|
110
|
+
private var stopReplies: [(Result<Void, Error>) -> Void] = []
|
|
66
111
|
private var ready: ((Result<String, Error>) -> Void)?
|
|
67
112
|
|
|
68
113
|
init(id: String, queue: DispatchQueue) {
|
|
@@ -116,10 +161,11 @@ private final class ManagedRelaySession {
|
|
|
116
161
|
let callback = ready; ready = nil; callback?(result)
|
|
117
162
|
}
|
|
118
163
|
|
|
119
|
-
func stop(completion: @escaping () -> Void) {
|
|
120
|
-
if stopFinished { completion(); return }
|
|
164
|
+
func stop(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
165
|
+
if stopFinished { completion(.success(())); return }
|
|
166
|
+
let stopping = !stopReplies.isEmpty
|
|
121
167
|
stopReplies.append(completion)
|
|
122
|
-
guard !
|
|
168
|
+
guard !stopping else { return }
|
|
123
169
|
stopped = true
|
|
124
170
|
finish(.failure(LocalMediaError("Relay cancelled")))
|
|
125
171
|
hotspot.onLost = nil
|
|
@@ -131,10 +177,14 @@ private final class ManagedRelaySession {
|
|
|
131
177
|
if let publisher {
|
|
132
178
|
group.enter(); publisher.stop { group.leave() }
|
|
133
179
|
}
|
|
134
|
-
|
|
180
|
+
var cleanupResult: Result<Void, Error> = .success(())
|
|
181
|
+
group.enter(); hotspot.leave { result in
|
|
182
|
+
self.queue.async { cleanupResult = result; group.leave() }
|
|
183
|
+
}
|
|
135
184
|
group.notify(queue: queue) {
|
|
136
|
-
self.receiver = nil; self.publisher = nil
|
|
137
|
-
|
|
185
|
+
self.receiver = nil; self.publisher = nil
|
|
186
|
+
if case .success = cleanupResult { self.stopFinished = true }
|
|
187
|
+
let replies = self.stopReplies; self.stopReplies.removeAll(); replies.forEach { $0(cleanupResult) }
|
|
138
188
|
}
|
|
139
189
|
}
|
|
140
190
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/glasses-media",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0-beta.942",
|
|
4
4
|
"description": "Shared native glasses media receivers and local network transport",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mentra Community",
|
|
7
7
|
"homepage": "https://github.com/Mentra-Community/MentraOS",
|
|
8
8
|
"main": "index.js",
|
|
9
|
+
"types": "index.d.ts",
|
|
9
10
|
"repository": {
|
|
10
11
|
"type": "git",
|
|
11
12
|
"url": "git+https://github.com/Mentra-Community/MentraOS.git",
|
|
@@ -14,8 +15,12 @@
|
|
|
14
15
|
"publishConfig": {
|
|
15
16
|
"access": "public"
|
|
16
17
|
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"expo-modules-core": "~55.0.25"
|
|
20
|
+
},
|
|
17
21
|
"files": [
|
|
18
22
|
"index.js",
|
|
23
|
+
"index.d.ts",
|
|
19
24
|
"expo-module.config.json",
|
|
20
25
|
"android/build.gradle",
|
|
21
26
|
"android/src/main",
|