@mentra/glasses-media 3.3.0-dev.408 → 3.3.0-dev.413

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 CHANGED
@@ -4,7 +4,7 @@ Native receivers, WHIP publishers and network helpers shared by the Mentra App.
4
4
 
5
5
  - Android: the existing `GlassesMediaSource`, local WHIP and WHEP receivers, scoped SoftAP networking, decoded I420/PCM delivery, and transport diagnostics moved here. The ACS session and raw outgoing streams remain in `acs-meeting`.
6
6
  - iOS: `DecodedGlassesMediaSource` delivers `CVPixelBuffer` video and interleaved PCM16. `LocalWhipIngestSource` answers the glasses' existing WHIP client; `WhepVideoSource` retains the cloud subscription path.
7
- - `GlassesHotspotNetwork` uses the same persistent `NEHotspotConfiguration` join as gallery transfers. Wi-Fi carries the glasses link; cellular carries the call. It verifies the joined SSID and `en0` address, reports network loss, and retains a cancelled join until the uncancellable system callback has been cleaned up.
7
+ - `GlassesHotspotNetwork` uses the same persistent `NEHotspotConfiguration` join as gallery transfers. Wi-Fi carries the glasses link; cellular carries the call. It verifies the joined SSID and the address of the one interface Network framework reports as Wi-Fi (not a fixed `en0`; a Mac can use `en1`), keeps that interface for loss checks and ACS binding, reports network loss, and retains a cancelled join until the uncancellable system callback has been cleaned up.
8
8
 
9
9
  The publisher owns its outgoing encoders, audio policy, and destination. Receivers do not open the phone microphone or play the glasses microphone locally. iOS uses a receive-only audio device to drive decoded PCM delivery without changing the app's audio session.
10
10
 
@@ -0,0 +1,122 @@
1
+ import Foundation
2
+
3
+ /// One IPv4 address row reported by `getifaddrs`.
4
+ public struct HotspotInterfaceAddress: Equatable {
5
+ public let name: String
6
+ public let isUp: Bool
7
+ public let ipv4: String
8
+
9
+ public init(name: String, isUp: Bool, ipv4: String) {
10
+ self.name = name
11
+ self.isUp = isUp
12
+ self.ipv4 = ipv4
13
+ }
14
+ }
15
+
16
+ /// One `getifaddrs` sample: every attached interface's kernel index (its `AF_LINK` row, present
17
+ /// with or without an IPv4 address) and every IPv4 address row.
18
+ public struct HotspotInterfaceSnapshot: Equatable {
19
+ public let links: [String: Int]
20
+ public let addresses: [HotspotInterfaceAddress]
21
+
22
+ public init(links: [String: Int], addresses: [HotspotInterfaceAddress]) {
23
+ self.links = links
24
+ self.addresses = addresses
25
+ }
26
+ }
27
+
28
+ /// Network framework's type for one interface on one evaluated path.
29
+ public struct HotspotInterfaceReport: Equatable {
30
+ public let name: String
31
+ public let index: Int
32
+ public let isWifi: Bool
33
+
34
+ public init(name: String, index: Int, isWifi: Bool) {
35
+ self.name = name
36
+ self.index = index
37
+ self.isWifi = isWifi
38
+ }
39
+ }
40
+
41
+ /// The Wi-Fi interface carrying the glasses hotspot and the DHCP address it held when verified.
42
+ public struct HotspotInterfaceBinding: Hashable {
43
+ public let interface: String
44
+ public let index: Int
45
+ public let address: String
46
+
47
+ public init(interface: String, index: Int, address: String) {
48
+ self.interface = interface
49
+ self.index = index
50
+ self.address = address
51
+ }
52
+ }
53
+
54
+ /// Wi-Fi identity learned during one hotspot session. Create a new value for every join.
55
+ ///
56
+ /// A path's interface list describes routes, not hardware: after Wi-Fi moves to an AP without
57
+ /// internet, both the default and the Wi-Fi-constrained path can omit it. An interface's type
58
+ /// does not change while it stays attached, so a positive Wi-Fi report is kept for the same
59
+ /// name and kernel index until that interface is detached or replaced. Omission from a later
60
+ /// report is not evidence. Any non-Wi-Fi report for a name rejects it for the rest of the session.
61
+ public struct HotspotWifiIdentity: Equatable {
62
+ public private(set) var trusted: [String: Int] = [:]
63
+ public private(set) var rejected: Set<String> = []
64
+ /// Trusted names later seen detached or replaced. A fresh report may trust the name again
65
+ /// for discovery, but an address verified before the change stays lost.
66
+ public private(set) var detached: Set<String> = []
67
+
68
+ public init() {}
69
+
70
+ public mutating func observe(_ reports: [HotspotInterfaceReport]) {
71
+ for report in reports where !report.isWifi {
72
+ rejected.insert(report.name)
73
+ trusted[report.name] = nil
74
+ }
75
+ for report in reports where report.isWifi && !report.name.isEmpty && report.index > 0 && !rejected.contains(report.name) {
76
+ // A new index means a different interface took the name; only this report vouches for it.
77
+ if let index = trusted[report.name], index != report.index { detached.insert(report.name) }
78
+ trusted[report.name] = report.index
79
+ }
80
+ }
81
+
82
+ /// Forget an interface that is no longer attached, or whose name now has another index.
83
+ /// Only a new positive report can re-establish it.
84
+ public mutating func observe(_ snapshot: HotspotInterfaceSnapshot) {
85
+ for (name, index) in trusted where snapshot.links[name] != index {
86
+ trusted[name] = nil
87
+ detached.insert(name)
88
+ }
89
+ }
90
+ }
91
+
92
+ /// Wi-Fi is not always `en0`: a Mac can have built-in Ethernet on `en0` and Wi-Fi on `en1`.
93
+ /// Identify the hotspot link by interface type and address, never by name or by the first
94
+ /// private address, and keep that exact interface for the rest of the session.
95
+ public enum HotspotInterfacePolicy {
96
+ /// The unique up IPv4 address, on an interface this session trusts as Wi-Fi and that is still
97
+ /// attached with the same index, that can be the hotspot client. With a BLE-reported gateway
98
+ /// it must be a valid client on that gateway's /24. Missing or ambiguous candidates return nil.
99
+ public static func select(snapshot: HotspotInterfaceSnapshot, identity: HotspotWifiIdentity,
100
+ gateway: String?) -> HotspotInterfaceBinding?
101
+ {
102
+ let bindings = Set(snapshot.addresses.compactMap { row -> HotspotInterfaceBinding? in
103
+ guard let index = identity.trusted[row.name], snapshot.links[row.name] == index,
104
+ row.isUp, LocalMediaPolicy.isPrivate(row.ipv4) else { return nil }
105
+ if let gateway, !LocalMediaPolicy.isHotspotClientAddress(row.ipv4, gateway: gateway) { return nil }
106
+ return HotspotInterfaceBinding(interface: row.name, index: index, address: row.ipv4)
107
+ })
108
+ return bindings.count == 1 ? bindings.first : nil
109
+ }
110
+
111
+ /// A verified link remains usable only while the same attached interface is up with the same
112
+ /// address. Later path reports may omit it. Another interface acquiring that address, a
113
+ /// detach or replacement under the same name, or a non-Wi-Fi report ends it for the session.
114
+ /// Pass an identity that has already observed `snapshot`.
115
+ public static func isIntact(_ binding: HotspotInterfaceBinding, snapshot: HotspotInterfaceSnapshot,
116
+ identity: HotspotWifiIdentity) -> Bool
117
+ {
118
+ guard identity.trusted[binding.interface] == binding.index, !identity.detached.contains(binding.interface),
119
+ snapshot.links[binding.interface] == binding.index else { return false }
120
+ return snapshot.addresses.contains { $0.name == binding.interface && $0.isUp && $0.ipv4 == binding.address }
121
+ }
122
+ }
@@ -10,7 +10,8 @@ public final class GlassesHotspotNetwork {
10
10
  private let queue = DispatchQueue(label: "com.mentra.glassesmedia.hotspot")
11
11
  private var ssid: String?
12
12
  private var lastHotspotSSID: String?
13
- private var localAddress: String?
13
+ /// The verified Wi-Fi interface and DHCP address. Every later check uses this exact pair.
14
+ private var binding: HotspotInterfaceBinding?
14
15
  private var gatewayAddress: String?
15
16
  private var generation = 0
16
17
  private var applying = false
@@ -18,8 +19,13 @@ public final class GlassesHotspotNetwork {
18
19
  private var cancelled = false
19
20
  private var joinReply: ((Result<String, Error>) -> Void)?
20
21
  private var leaveReplies: [() -> Void] = []
21
- private var monitor: NWPathMonitor?
22
+ private var interfaceMonitors: [NWPathMonitor] = []
23
+ /// Wi-Fi identity learned since this join started; replaced on every join and cleared on leave.
24
+ private var wifiIdentity = HotspotWifiIdentity()
25
+ private var monitorsReported: [Bool] = []
26
+ private var interfaceReportsReady: (() -> Void)?
22
27
  private var localAccess: LocalNetworkAccessRequest?
28
+ private var localAddress: String? { binding?.address }
23
29
  #if MENTRA_E2E
24
30
  private var testLeaseConsumed = false
25
31
  #endif
@@ -42,40 +48,42 @@ public final class GlassesHotspotNetwork {
42
48
  self.applying = false
43
49
  self.ownsConfiguration = false
44
50
  self.joinReply = completion
51
+ self.startInterfaceMonitors(generation: gen)
45
52
  if ProcessInfo.processInfo.isiOSAppOnMac {
46
- #if MENTRA_E2E
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()
53
+ // Reuse is decided once, so it needs the interface types first.
54
+ self.awaitInterfaceReports(generation: gen) {
55
+ #if MENTRA_E2E
56
+ if let raw = ProcessInfo.processInfo.environment["MENTRA_E2E_PREJOINED_HOTSPOT"] {
57
+ let selected = self.selectBinding()
58
+ guard !self.testLeaseConsumed,
59
+ let lease = try? JSONDecoder().decode(MacE2EHotspotLease.self, from: Data(raw.utf8)),
60
+ lease.matches(ssid: ssid, gateway: gateway, address: selected?.address, now: Date().timeIntervalSince1970),
61
+ let selected
62
+ else {
63
+ self.finishJoin(.failure(LocalMediaError("Mac E2E network lease is invalid, expired or already consumed")))
64
+ self.finishLeave()
65
+ return
66
+ }
67
+ self.testLeaseConsumed = true
68
+ self.bind(selected)
69
+ self.logger.notice("HOTSPOT_JOIN test_harness_connection native_association_untested")
70
+ self.waitForLocalAccess(address: selected.address, generation: gen)
56
71
  return
57
72
  }
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)
73
+ #endif
74
+ NEHotspotNetwork.fetchCurrent { network in
75
+ self.queue.async {
76
+ guard gen == self.generation, !self.cancelled else { return }
77
+ let selected = self.selectBinding()
78
+ if let selected, LocalMediaPolicy.canReuseHotspot(requestedSSID: ssid, currentSSID: network?.ssid,
79
+ address: selected.address, gateway: gateway)
80
+ {
81
+ self.bind(selected)
82
+ self.logger.info("HOTSPOT_JOIN reuse_verified_macos_connection")
83
+ self.waitForLocalAccess(address: selected.address, generation: gen)
84
+ } else {
85
+ self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
86
+ }
79
87
  }
80
88
  }
81
89
  }
@@ -106,6 +114,15 @@ public final class GlassesHotspotNetwork {
106
114
  }
107
115
  }
108
116
 
117
+ /// The verified hotspot address while its own Wi-Fi interface still holds it; nil after
118
+ /// loss, leave or an address change. Never falls back to another interface.
119
+ public func verifiedAddress(completion: @escaping (String?) -> Void) {
120
+ queue.async {
121
+ guard let binding = self.binding, !self.cancelled, self.isBindingIntact(binding) else { completion(nil); return }
122
+ completion(binding.address)
123
+ }
124
+ }
125
+
109
126
  public func info(completion: @escaping ([String: Any]) -> Void) {
110
127
  queue.async {
111
128
  var value: [String: Any] = ["available": self.localAddress != nil]
@@ -124,7 +141,7 @@ public final class GlassesHotspotNetwork {
124
141
  let parameters = NWParameters.tcp
125
142
  if ProcessInfo.processInfo.isiOSAppOnMac {
126
143
  // The Mac route probe rejects the Wi-Fi type constraint on an otherwise
127
- // usable en0 route. Restrict this connection to the verified source IP.
144
+ // usable hotspot route. Restrict this connection to the verified source IP.
128
145
  parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(address), port: .any)
129
146
  } else {
130
147
  parameters.requiredInterfaceType = .wifi
@@ -221,34 +238,97 @@ public final class GlassesHotspotNetwork {
221
238
  NEHotspotNetwork.fetchCurrent { [weak self] network in
222
239
  self?.queue.async {
223
240
  guard let self, gen == self.generation, !self.cancelled else { return }
224
- if network?.ssid == ssid, let address = Self.wifiAddress(),
225
- self.gatewayAddress.map({ LocalMediaPolicy.isHotspotClientAddress(address, gateway: $0) }) ?? true
226
- {
227
- self.localAddress = address
228
- self.startMonitor(generation: gen)
229
- self.waitForLocalAccess(address: address, generation: gen)
241
+ // The selection applies the BLE gateway's client check when one was reported.
242
+ if network?.ssid == ssid, let selected = self.selectBinding() {
243
+ self.bind(selected)
244
+ self.waitForLocalAccess(address: selected.address, generation: gen)
230
245
  } else if remaining > 0 {
231
246
  self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
232
247
  } else {
233
248
  let association = network == nil ? "unavailable" : (network?.ssid == ssid ? "matched" : "different")
234
- let address = Self.wifiAddress() ?? "none"
235
- self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address (SSID=\(association), Wi-Fi IPv4=\(address))")))
249
+ self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address (SSID=\(association), \(self.wifiDiagnostic()))")))
236
250
  self.finishLeave()
237
251
  }
238
252
  }
239
253
  }
240
254
  }
241
255
 
242
- private func startMonitor(generation gen: Int) {
243
- let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
244
- self.monitor = monitor
245
- monitor.pathUpdateHandler = { [weak self] _ in
246
- guard let self, gen == generation, !self.cancelled else { return }
247
- // This AP intentionally has no internet. Loss of its default internet path is not
248
- // loss of the local link; use the actual interface address instead.
249
- if Self.wifiAddress() != localAddress { onLost?("Glasses hotspot connection was lost") }
256
+ /// iOS can omit an internet-less Wi-Fi from its default path, and iOS-on-Mac has rejected
257
+ /// a Wi-Fi type constraint on a usable hotspot route. Both paths' interface types feed one
258
+ /// session identity (HotspotWifiIdentity): a positive Wi-Fi report outlives later omission
259
+ /// only while the same interface stays attached; contrary reports and replacement fail closed.
260
+ private func startInterfaceMonitors(generation gen: Int) {
261
+ interfaceMonitors.forEach { $0.cancel() }
262
+ let monitors = [NWPathMonitor(requiredInterfaceType: .wifi), NWPathMonitor()]
263
+ interfaceMonitors = monitors
264
+ wifiIdentity = HotspotWifiIdentity()
265
+ monitorsReported = Array(repeating: false, count: monitors.count)
266
+ for (index, monitor) in monitors.enumerated() {
267
+ monitor.pathUpdateHandler = { [weak self] path in
268
+ guard let self, gen == self.generation else { return }
269
+ self.wifiIdentity.observe(path.availableInterfaces.map {
270
+ HotspotInterfaceReport(name: $0.name, index: $0.index, isWifi: $0.type == .wifi)
271
+ })
272
+ self.monitorsReported[index] = true
273
+ if !self.monitorsReported.contains(false) { self.fireInterfaceReportsReady() }
274
+ // This AP intentionally has no internet. Loss of its default internet path is not
275
+ // loss of the local link; check the verified interface and address instead.
276
+ if let binding = self.binding, !self.cancelled, !self.isBindingIntact(binding) {
277
+ self.onLost?("Glasses hotspot connection was lost")
278
+ }
279
+ }
280
+ monitor.start(queue: queue)
281
+ }
282
+ }
283
+
284
+ /// Continue once every monitor has reported, or after a short bound with whatever arrived.
285
+ private func awaitInterfaceReports(generation gen: Int, then proceed: @escaping () -> Void) {
286
+ interfaceReportsReady = { [weak self] in
287
+ guard let self, gen == self.generation, !self.cancelled else { return }
288
+ proceed()
289
+ }
290
+ if !monitorsReported.contains(false) { fireInterfaceReportsReady(); return }
291
+ queue.asyncAfter(deadline: .now() + 2) { [weak self] in
292
+ guard let self, gen == self.generation else { return }
293
+ self.fireInterfaceReportsReady()
250
294
  }
251
- monitor.start(queue: queue)
295
+ }
296
+
297
+ private func fireInterfaceReportsReady() {
298
+ let ready = interfaceReportsReady
299
+ interfaceReportsReady = nil
300
+ ready?()
301
+ }
302
+
303
+ /// Every check samples interfaces first, so a detach or replacement between path updates
304
+ /// is recorded in the session identity before it is used.
305
+ private func observeInterfaces() -> HotspotInterfaceSnapshot {
306
+ let snapshot = Self.interfaceSnapshot()
307
+ wifiIdentity.observe(snapshot)
308
+ return snapshot
309
+ }
310
+
311
+ private func selectBinding() -> HotspotInterfaceBinding? {
312
+ HotspotInterfacePolicy.select(snapshot: observeInterfaces(), identity: wifiIdentity, gateway: gatewayAddress)
313
+ }
314
+
315
+ private func isBindingIntact(_ binding: HotspotInterfaceBinding) -> Bool {
316
+ let snapshot = observeInterfaces()
317
+ return HotspotInterfacePolicy.isIntact(binding, snapshot: snapshot, identity: wifiIdentity)
318
+ }
319
+
320
+ private func bind(_ selected: HotspotInterfaceBinding) {
321
+ binding = selected
322
+ logger.info("HOTSPOT_JOIN address_verified interface=\(selected.interface, privacy: .public)")
323
+ }
324
+
325
+ /// Interface names and their IPv4 addresses only; never credentials or SSIDs.
326
+ private func wifiDiagnostic() -> String {
327
+ let snapshot = observeInterfaces()
328
+ let wifi = Set(wifiIdentity.trusted.keys)
329
+ let addresses = snapshot.addresses.filter { wifi.contains($0.name) }.map { "\($0.name)=\($0.ipv4)" }
330
+ return "Wi-Fi interfaces=\(wifi.isEmpty ? "none" : wifi.sorted().joined(separator: ",")), " +
331
+ "Wi-Fi IPv4=\(addresses.isEmpty ? "none" : addresses.sorted().joined(separator: ","))"
252
332
  }
253
333
 
254
334
  private func waitForLocalAccess(address: String, generation gen: Int) {
@@ -281,32 +361,46 @@ public final class GlassesHotspotNetwork {
281
361
  private func finishLeave() {
282
362
  generation += 1
283
363
  localAccess?.cancel(); localAccess = nil
284
- monitor?.cancel(); monitor = nil
364
+ interfaceMonitors.forEach { $0.cancel() }
365
+ interfaceMonitors = []
366
+ wifiIdentity = HotspotWifiIdentity()
367
+ monitorsReported = []
368
+ interfaceReportsReady = nil
285
369
  if let ssid, ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
286
370
  ownsConfiguration = false
287
371
  ssid = nil
288
- localAddress = nil
372
+ binding = nil
289
373
  gatewayAddress = nil
290
374
  let replies = leaveReplies
291
375
  leaveReplies.removeAll()
292
376
  replies.forEach { $0() }
293
377
  }
294
378
 
295
- public static func wifiAddress() -> String? {
379
+ /// Every attached interface's kernel index (its AF_LINK row) and every IPv4 address with its
380
+ /// interface name and up state. Selection belongs to HotspotInterfacePolicy; an interface
381
+ /// name alone never identifies Wi-Fi. A failed read yields an empty sample, which fails closed.
382
+ static func interfaceSnapshot() -> HotspotInterfaceSnapshot {
296
383
  var interfaces: UnsafeMutablePointer<ifaddrs>?
297
- guard getifaddrs(&interfaces) == 0 else { return nil }
384
+ guard getifaddrs(&interfaces) == 0 else { return HotspotInterfaceSnapshot(links: [:], addresses: []) }
298
385
  defer { freeifaddrs(interfaces) }
386
+ var links: [String: Int] = [:]
387
+ var rows: [HotspotInterfaceAddress] = []
299
388
  var cursor = interfaces
300
389
  while let item = cursor {
301
390
  defer { cursor = item.pointee.ifa_next }
302
391
  let value = item.pointee
303
- guard String(cString: value.ifa_name) == "en0", value.ifa_flags & UInt32(IFF_UP) != 0,
304
- let address = value.ifa_addr, address.pointee.sa_family == UInt8(AF_INET) else { continue }
392
+ guard let address = value.ifa_addr else { continue }
393
+ let name = String(cString: value.ifa_name)
394
+ if address.pointee.sa_family == UInt8(AF_LINK) {
395
+ let index = address.withMemoryRebound(to: sockaddr_dl.self, capacity: 1) { Int($0.pointee.sdl_index) }
396
+ if index > 0 { links[name] = index }
397
+ continue
398
+ }
399
+ guard address.pointee.sa_family == UInt8(AF_INET) else { continue }
305
400
  var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
306
401
  guard getnameinfo(address, socklen_t(address.pointee.sa_len), &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue }
307
- let ip = String(cString: host)
308
- if LocalMediaPolicy.isPrivate(ip) { return ip }
402
+ rows.append(HotspotInterfaceAddress(name: name, isUp: value.ifa_flags & UInt32(IFF_UP) != 0, ipv4: String(cString: host)))
309
403
  }
310
- return nil
404
+ return HotspotInterfaceSnapshot(links: links, addresses: rows)
311
405
  }
312
406
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.3.0-dev.408",
3
+ "version": "3.3.0-dev.413",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",