@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.
@@ -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
- /// Same persistent hotspot join as gallery (`joinOnce=false`). Only local traffic uses Wi-Fi.
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
- 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?
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 monitor: NWPathMonitor?
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
- #if MENTRA_E2E
24
- private var testLeaseConsumed = false
25
- #endif
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
- #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()
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 en0 route. Restrict this connection to the verified source IP.
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
- 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)
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
- let address = Self.wifiAddress() ?? "none"
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
- 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") }
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
- monitor?.cancel(); monitor = nil
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
- localAddress = nil
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
- public static func wifiAddress() -> String? {
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 nil }
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 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 }
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
- let ip = String(cString: host)
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 nil
491
+ return HotspotInterfaceSnapshot(links: links, addresses: rows, ethernetLinks: ethernetLinks)
311
492
  }
312
493
  }
@@ -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
- if self.session === session { self.session = nil }
43
- promise.resolve(nil)
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 { self.session = nil }
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 !stopped else { return }
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
- group.enter(); hotspot.leave { group.leave() }
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; self.stopFinished = true
137
- let replies = self.stopReplies; self.stopReplies.removeAll(); replies.forEach { $0() }
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.2.1-dev.314",
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",