@mentra/glasses-media 3.3.0-dev.864 → 3.3.0-dev.874

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 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.
7
+ - `GlassesHotspotNetwork` owns hotspot association for OTA, gallery transfers and media. iPhone uses persistent `NEHotspotConfiguration`; iOS-on-Mac uses public CoreWLAN in the app process and the standard Location permission needed to identify Wi-Fi networks. Wi-Fi carries the glasses link; cellular carries the call. The owner verifies the requested SSID and actual Wi-Fi interface address, retains that interface for loss checks and ACS binding, and serializes network writers across consumers. Cancellation retains custody until blocking native work and cleanup finish. Cleanup preserves a network the user joined afterward; a failed release rejects and retains ownership for a later cleanup attempt.
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
 
@@ -53,6 +53,18 @@ The iOS internet publisher posts its offer on the first server-reflexive candida
53
53
 
54
54
  Android temporarily lifts the cellular process pin while creating the local WHIP listener, then restores it before starting the outgoing publisher. Otherwise the listener inherits the cellular socket mark and cannot reply to the glasses. The outgoing audio callback supplies its own PCM frame clock: WebRTC's external-recording mode does not pace callbacks and reports zero captured bytes, so the callback fills the entire buffer and waits for the next frame interval without opening the phone microphone.
55
55
 
56
- An attempt-specific ID isolates late callbacks and glasses status. Reconnect tears down the glasses publisher, both phone peers and hotspot before rebuilding against the same provisioned input. Three retries bound consecutive failures; a minute of stable streaming renews the retry budget. Last-subscriber stop cancels startup/backoff, awaits native cleanup and disables the hotspot. Failed cleanup retains ownership and can be retried by stop. BLE-unreachable stop/hotspot commands are deferred until reconnection. ACS and managed streaming share a hotspot reservation. Native media queues keep running when the UI is backgrounded; Android holds a partial wake lock and uses the engine's background timers for retry/readiness.
56
+ An attempt-specific ID isolates late callbacks and glasses status. Reconnect tears down the glasses publisher, both phone peers and hotspot before rebuilding against the same provisioned input. Three retries bound consecutive failures; a minute of stable streaming renews the retry budget. Last-subscriber stop cancels startup/backoff, awaits native cleanup and disables the hotspot. Failed cleanup retains ownership and can be retried by stop. BLE-unreachable stop/hotspot commands are deferred until reconnection. OTA, Gallery, ACS and managed streaming share one hotspot reservation before BLE AP changes or native association. OTA, Gallery and managed relay retain it until their owned cleanup completes. ACS still has an older retirement policy that releases its JavaScript reservation after reporting an unconfirmed stop; native association custody remains retained on failure. Consolidating that ACS retirement is a separate qualification gate. A refused OTA or Gallery caller makes no AP or association changes. Native media queues keep running when the UI is backgrounded; Android holds a partial wake lock and uses the engine's background timers for retry/readiness.
57
57
 
58
58
  Device qualification for this outgoing pipeline must include Android and iPhone: audio/video playback on the provisioned WHEP URL, mute/video-only capture, last-subscriber stop, cancellation at each setup step, cellular/hotspot/BLE loss and recovery, repeated starts, screen-off/background operation and a sustained thermal/audio-sync run. Unit tests and platform compilation cover lifecycle/format contracts; they do not establish those hardware results.
59
+
60
+ ### Incoming OTA server and outgoing media clients
61
+
62
+ Hotspot association verifies the glasses SSID and the Wi-Fi interface/address for
63
+ both uses. Gallery and relay clients also wait for outgoing Local Network access
64
+ by connecting to the glasses HTTP endpoint. OTA selects `incoming-server`: it
65
+ serves already downloaded artifacts to connections initiated by the glasses, and
66
+ its own listener must become ready before the app sends the OTA manifest URL.
67
+ It does not depend on the Gallery HTTP server being available. Apple TN3179 lists
68
+ accepting incoming TCP as an operation that does not require Local Network
69
+ permission. This does not change outgoing Gallery/relay consent or the shared
70
+ network ownership and teardown rules.
package/index.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /** Shared native association owner for the local glasses hotspot. */
2
+ export const glassesHotspot: {
3
+ join(
4
+ id: string,
5
+ ssid: string,
6
+ password: string,
7
+ gateway?: string,
8
+ purpose?: "outgoing-client" | "incoming-server",
9
+ ): Promise<string>
10
+ leave(id: string): Promise<void>
11
+ }
package/index.js CHANGED
@@ -1 +1,10 @@
1
1
  // Native library. Media stays in native code; publishers consume decoded frames and PCM.
2
+
3
+ // Load only when this transport is requested. Importing the package does not
4
+ // start a native session or require Expo's application entry point.
5
+ const native = () => require("expo-modules-core").requireNativeModule("MentraGlassesMediaRelay")
6
+ export const glassesHotspot = Object.freeze({
7
+ join: (id, ssid, password, gateway, purpose = "outgoing-client") =>
8
+ native().joinLocalHotspot(id, ssid, password, gateway ?? null, purpose),
9
+ leave: (id) => native().leaveLocalHotspot(id),
10
+ })
@@ -0,0 +1,5 @@
1
+ /// The traffic needed after joining the verified glasses Wi-Fi network.
2
+ public enum HotspotAccessPurpose: String {
3
+ case outgoingClient = "outgoing-client"
4
+ case incomingServer = "incoming-server"
5
+ }
@@ -0,0 +1,22 @@
1
+ import Foundation
2
+
3
+ /// One process-wide writer across OTA, gallery and native media sessions.
4
+ /// Callers release only after native association and owned cleanup have ended.
5
+ final class HotspotNetworkOwnership {
6
+ static let shared = HotspotNetworkOwnership()
7
+ private let lock = NSLock()
8
+ private var owner: UUID?
9
+
10
+ func acquire() -> UUID? {
11
+ lock.lock(); defer { lock.unlock() }
12
+ guard owner == nil else { return nil }
13
+ let token = UUID()
14
+ owner = token
15
+ return token
16
+ }
17
+
18
+ func release(_ token: UUID) {
19
+ lock.lock(); defer { lock.unlock() }
20
+ if owner == token { owner = nil }
21
+ }
22
+ }
@@ -0,0 +1,161 @@
1
+ import CoreLocation
2
+ import Foundation
3
+ import ObjectiveC
4
+
5
+ /// Blocking public CoreWLAN calls are isolated here; the existing hotspot owner
6
+ /// retains custody until they return, even after cancellation or a timeout.
7
+ final class MacHotspotAssociation {
8
+ private let lock = NSLock()
9
+ private var cancelled = false
10
+ private var interface: MacWifiInterface?
11
+ private var joinedSSID: String?
12
+ private let makeInterface: () throws -> MacWifiInterface
13
+
14
+ init(makeInterface: @escaping () throws -> MacWifiInterface = { try PublicCoreWlanInterface() }) {
15
+ self.makeInterface = makeInterface
16
+ }
17
+
18
+ func associate(ssid: String, password: String) throws -> String {
19
+ try checkCurrent()
20
+ let interface = try makeInterface()
21
+ self.interface = interface
22
+ if try interface.currentSSID() != ssid {
23
+ let network = try interface.network(ssid: ssid)
24
+ try checkCurrent()
25
+ // Record the intended association before this blocking call. A late
26
+ // successful join still belongs to us and must be cleaned up.
27
+ joinedSSID = ssid
28
+ try interface.associate(network: network, password: password)
29
+ }
30
+ try checkCurrent()
31
+ guard try interface.currentSSID() == ssid else {
32
+ throw LocalMediaError("Mac Wi-Fi did not associate with the requested glasses hotspot")
33
+ }
34
+ return try interface.name()
35
+ }
36
+
37
+ func cancel() {
38
+ lock.lock(); cancelled = true; lock.unlock()
39
+ }
40
+
41
+ /// Run on the same worker as association, after its blocking call returns.
42
+ /// A connection the user replaced must never be disconnected by our cleanup.
43
+ func leave() throws {
44
+ if let joinedSSID {
45
+ if try interface?.currentSSID() == joinedSSID {
46
+ interface?.disassociate()
47
+ guard try interface?.currentSSID() != joinedSSID else {
48
+ throw LocalMediaError("Mac Wi-Fi has not released the glasses hotspot")
49
+ }
50
+ }
51
+ }
52
+ interface = nil
53
+ joinedSSID = nil
54
+ }
55
+
56
+ func currentSSID() throws -> String? {
57
+ try interface?.currentSSID()
58
+ }
59
+
60
+ private func checkCurrent() throws {
61
+ lock.lock(); let stopped = cancelled; lock.unlock()
62
+ if stopped { throw CancellationError() }
63
+ }
64
+ }
65
+
66
+ protocol MacWifiInterface: AnyObject {
67
+ func name() throws -> String
68
+ func currentSSID() throws -> String?
69
+ func network(ssid: String) throws -> NSObject
70
+ func associate(network: NSObject, password: String) throws
71
+ func disassociate()
72
+ }
73
+
74
+ enum MacWifiInvocation {
75
+ // Objective-C NSError** parameters use autoreleasing writeback. Strong
76
+ // pointer storage would over-release errors when the native pool drains.
77
+ typealias Scan = @convention(c) (AnyObject, Selector, NSData, AutoreleasingUnsafeMutablePointer<NSError?>) -> Unmanaged<NSSet>?
78
+ typealias Associate = @convention(c) (AnyObject, Selector, AnyObject, NSString, AutoreleasingUnsafeMutablePointer<NSError?>) -> Bool
79
+ }
80
+
81
+ /// CoreWLAN is a public macOS framework. Foundation loads it only in the Mac
82
+ /// process; Apple dyld permits macOS frameworks in iOS-on-Mac. The checked
83
+ /// Objective-C signatures below are the public CWInterface SDK declarations.
84
+ private final class PublicCoreWlanInterface: MacWifiInterface {
85
+ private let bundle: Bundle
86
+ private let interface: NSObject
87
+ private typealias Disassociate = @convention(c) (AnyObject, Selector) -> Void
88
+
89
+ init() throws {
90
+ guard let bundle = Bundle(path: "/System/Library/Frameworks/CoreWLAN.framework") else {
91
+ throw LocalMediaError("Mac Wi-Fi framework is unavailable")
92
+ }
93
+ do { try bundle.loadAndReturnError() }
94
+ catch { throw Self.failure("framework loading", error as NSError) }
95
+ guard let clientClass = NSClassFromString("CWWiFiClient") as? NSObject.Type,
96
+ clientClass.responds(to: NSSelectorFromString("sharedWiFiClient")),
97
+ let client = clientClass.perform(NSSelectorFromString("sharedWiFiClient"))?.takeUnretainedValue() as? NSObject,
98
+ client.responds(to: NSSelectorFromString("interface")),
99
+ let interface = client.perform(NSSelectorFromString("interface"))?.takeUnretainedValue() as? NSObject
100
+ else { throw LocalMediaError("Mac Wi-Fi interface is unavailable") }
101
+ for method in ["interfaceName", "ssidData", "scanForNetworksWithSSID:error:", "associateToNetwork:password:error:", "disassociate"] {
102
+ guard interface.responds(to: NSSelectorFromString(method)) else {
103
+ throw LocalMediaError("Mac Wi-Fi interface does not support hotspot association")
104
+ }
105
+ }
106
+ self.interface = interface
107
+ self.bundle = bundle
108
+ }
109
+
110
+ func name() throws -> String {
111
+ guard let name = interface.perform(NSSelectorFromString("interfaceName"))?.takeUnretainedValue() as? String,
112
+ !name.isEmpty else { throw LocalMediaError("Mac Wi-Fi interface has no identity") }
113
+ return name
114
+ }
115
+
116
+ func currentSSID() throws -> String? {
117
+ let authorization = CLLocationManager.authorizationStatus()
118
+ guard MacHotspotAuthorization.isAuthorized(authorization) else {
119
+ throw LocalMediaError("Location access is required to verify the current Mac Wi-Fi network")
120
+ }
121
+ guard let data = interface.perform(NSSelectorFromString("ssidData"))?.takeUnretainedValue() as? Data else { return nil }
122
+ return String(data: data, encoding: .utf8)
123
+ }
124
+
125
+ func network(ssid: String) throws -> NSObject {
126
+ let selector = NSSelectorFromString("scanForNetworksWithSSID:error:")
127
+ let invoke = unsafeBitCast(interface.method(for: selector), to: MacWifiInvocation.Scan.self)
128
+ var error: NSError?
129
+ guard let networks = invoke(interface, selector, NSData(data: Data(ssid.utf8)), &error)?.takeUnretainedValue() else {
130
+ throw Self.failure("scan", error)
131
+ }
132
+ // A directed scan can include unrelated networks. Match the requested
133
+ // SSID bytes without reading or logging BSSID or other scan identities.
134
+ for case let network as NSObject in networks {
135
+ guard network.responds(to: NSSelectorFromString("ssidData")),
136
+ let data = network.perform(NSSelectorFromString("ssidData"))?.takeUnretainedValue() as? Data,
137
+ data == Data(ssid.utf8) else { continue }
138
+ return network
139
+ }
140
+ throw LocalMediaError("The requested glasses hotspot was not found by Mac Wi-Fi")
141
+ }
142
+
143
+ func associate(network: NSObject, password: String) throws {
144
+ let selector = NSSelectorFromString("associateToNetwork:password:error:")
145
+ let invoke = unsafeBitCast(interface.method(for: selector), to: MacWifiInvocation.Associate.self)
146
+ var error: NSError?
147
+ guard invoke(interface, selector, network, password as NSString, &error) else { throw Self.failure("association", error) }
148
+ }
149
+
150
+ func disassociate() {
151
+ let selector = NSSelectorFromString("disassociate")
152
+ let invoke = unsafeBitCast(interface.method(for: selector), to: Disassociate.self)
153
+ invoke(interface, selector)
154
+ }
155
+
156
+ private static func failure(_ operation: String, _ error: NSError?) -> Error {
157
+ let domain = error?.domain ?? "unavailable"
158
+ let safeDomain = domain.range(of: "^[A-Za-z0-9_.-]{1,128}$", options: .regularExpression) == nil ? "unavailable" : domain
159
+ return LocalMediaError("Mac Wi-Fi \(operation) failed (\(safeDomain), \(error?.code ?? -1))")
160
+ }
161
+ }
@@ -0,0 +1,59 @@
1
+ import CoreLocation
2
+ import Foundation
3
+
4
+ /// CoreWLAN requires Location authorization to identify the requested network.
5
+ /// Use the app's existing system permission, with no separate connection screen.
6
+ final class MacHotspotAuthorization: NSObject, CLLocationManagerDelegate {
7
+ private var manager: CLLocationManager?
8
+ private var completion: ((Result<Void, Error>) -> Void)?
9
+ private var finished = false
10
+
11
+ func start(completion: @escaping (Result<Void, Error>) -> Void) {
12
+ DispatchQueue.main.async {
13
+ guard !self.finished else { completion(.failure(CancellationError())); return }
14
+ self.completion = completion
15
+ let manager = CLLocationManager()
16
+ self.manager = manager
17
+ manager.delegate = self
18
+ self.handle(manager.authorizationStatus)
19
+ if manager.authorizationStatus == .notDetermined { manager.requestWhenInUseAuthorization() }
20
+ }
21
+ }
22
+
23
+ func cancel() {
24
+ DispatchQueue.main.async { self.finish(.failure(CancellationError())) }
25
+ }
26
+
27
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
28
+ handle(manager.authorizationStatus)
29
+ }
30
+
31
+ static func isAuthorized(_ status: CLAuthorizationStatus) -> Bool {
32
+ if status == .authorizedAlways { return true }
33
+ #if !os(macOS)
34
+ if status == .authorizedWhenInUse { return true }
35
+ #endif
36
+ return false
37
+ }
38
+
39
+ private func handle(_ status: CLAuthorizationStatus) {
40
+ if Self.isAuthorized(status) { finish(.success(())); return }
41
+ switch status {
42
+ case .denied, .restricted:
43
+ finish(.failure(LocalMediaError("Location access is required to identify the glasses hotspot on Mac")))
44
+ case .notDetermined: break
45
+ default:
46
+ finish(.failure(LocalMediaError("Mac hotspot Location authorization is unavailable")))
47
+ }
48
+ }
49
+
50
+ private func finish(_ result: Result<Void, Error>) {
51
+ guard !finished else { return }
52
+ finished = true
53
+ manager?.delegate = nil
54
+ manager = nil
55
+ let reply = completion
56
+ completion = nil
57
+ reply?(result)
58
+ }
59
+ }
@@ -4,7 +4,7 @@ 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")
@@ -12,81 +12,57 @@ public final class GlassesHotspotNetwork {
12
12
  private var lastHotspotSSID: String?
13
13
  /// The verified Wi-Fi interface and DHCP address. Every later check uses this exact pair.
14
14
  private var binding: HotspotInterfaceBinding?
15
+ private var accessPurpose: HotspotAccessPurpose = .outgoingClient
15
16
  private var gatewayAddress: String?
16
17
  private var generation = 0
17
18
  private var applying = false
18
19
  private var ownsConfiguration = false
19
20
  private var cancelled = false
20
21
  private var joinReply: ((Result<String, Error>) -> Void)?
21
- private var leaveReplies: [() -> Void] = []
22
+ private var leaveReplies: [(Result<Void, Error>) -> Void] = []
22
23
  private var interfaceMonitors: [NWPathMonitor] = []
23
24
  /// Wi-Fi identity learned since this join started; replaced on every join and cleared on leave.
24
25
  private var wifiIdentity = HotspotWifiIdentity()
25
- private var monitorsReported: [Bool] = []
26
- private var interfaceReportsReady: (() -> Void)?
27
26
  private var localAccess: LocalNetworkAccessRequest?
28
- private var localAddress: String? { binding?.address }
29
- #if MENTRA_E2E
30
- private var testLeaseConsumed = false
31
- #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
32
36
  public var onLost: ((String) -> Void)?
33
37
  public var onPermissionRequired: (() -> Void)?
34
38
  public init() {}
35
39
 
36
- 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) {
37
41
  queue.async {
38
42
  guard self.ssid == nil, !self.applying else { completion(.failure(LocalMediaError("Previous hotspot session has not finished cleaning up"))); return }
39
43
  if let gateway, !LocalMediaPolicy.isPrivate(gateway) {
40
44
  completion(.failure(LocalMediaError("The glasses reported an invalid hotspot gateway"))); return
41
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
42
53
  self.generation += 1
43
54
  let gen = self.generation
44
55
  self.ssid = ssid
45
56
  self.lastHotspotSSID = ssid
46
57
  self.gatewayAddress = gateway
58
+ self.accessPurpose = purpose
47
59
  self.cancelled = false
48
60
  self.applying = false
49
61
  self.ownsConfiguration = false
50
62
  self.joinReply = completion
51
63
  self.startInterfaceMonitors(generation: gen)
52
64
  if ProcessInfo.processInfo.isiOSAppOnMac {
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)
71
- return
72
- }
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
- }
87
- }
88
- }
89
- }
65
+ self.authorizeMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
90
66
  } else {
91
67
  self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
92
68
  }
@@ -95,6 +71,8 @@ public final class GlassesHotspotNetwork {
95
71
  // Its response time is not a hotspot association timeout.
96
72
  guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
97
73
  self.cancelled = true
74
+ self.macAssociation?.cancel()
75
+ self.macAuthorization?.cancel()
98
76
  self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
99
77
  // apply() cannot be cancelled. Retain the reservation until its callback and remove the
100
78
  // late configuration before another call is permitted to acquire the network.
@@ -104,9 +82,11 @@ public final class GlassesHotspotNetwork {
104
82
  }
105
83
  }
106
84
 
107
- public func leave(completion: @escaping () -> Void) {
85
+ public func leave(completion: @escaping (Result<Void, Error>) -> Void) {
108
86
  queue.async {
109
87
  self.cancelled = true
88
+ self.macAssociation?.cancel()
89
+ self.macAuthorization?.cancel()
110
90
  self.leaveReplies.append(completion)
111
91
  self.finishJoin(.failure(LocalMediaError("Hotspot join cancelled")))
112
92
  if let ssid = self.ssid, self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
@@ -119,7 +99,16 @@ public final class GlassesHotspotNetwork {
119
99
  public func verifiedAddress(completion: @escaping (String?) -> Void) {
120
100
  queue.async {
121
101
  guard let binding = self.binding, !self.cancelled, self.isBindingIntact(binding) else { completion(nil); return }
122
- completion(binding.address)
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) }
123
112
  }
124
113
  }
125
114
 
@@ -205,6 +194,72 @@ public final class GlassesHotspotNetwork {
205
194
  }
206
195
  }
207
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
+
208
263
  private func applyConfiguration(ssid: String, passphrase: String, generation gen: Int) {
209
264
  let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
210
265
  config.joinOnce = false
@@ -241,7 +296,7 @@ public final class GlassesHotspotNetwork {
241
296
  // The selection applies the BLE gateway's client check when one was reported.
242
297
  if network?.ssid == ssid, let selected = self.selectBinding() {
243
298
  self.bind(selected)
244
- self.waitForLocalAccess(address: selected.address, generation: gen)
299
+ self.finishAssociation(address: selected.address, generation: gen)
245
300
  } else if remaining > 0 {
246
301
  self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
247
302
  } else {
@@ -264,15 +319,12 @@ public final class GlassesHotspotNetwork {
264
319
  let monitors = [NWPathMonitor(requiredInterfaceType: .wifi), NWPathMonitor()]
265
320
  interfaceMonitors = monitors
266
321
  wifiIdentity = HotspotWifiIdentity()
267
- monitorsReported = Array(repeating: false, count: monitors.count)
268
- for (index, monitor) in monitors.enumerated() {
322
+ for monitor in monitors {
269
323
  monitor.pathUpdateHandler = { [weak self] path in
270
324
  guard let self, gen == self.generation else { return }
271
325
  self.wifiIdentity.observe(path.availableInterfaces.map {
272
326
  HotspotInterfaceReport(name: $0.name, index: $0.index, isWifi: $0.type == .wifi)
273
327
  })
274
- self.monitorsReported[index] = true
275
- if !self.monitorsReported.contains(false) { self.fireInterfaceReportsReady() }
276
328
  // This AP intentionally has no internet. Loss of its default internet path is not
277
329
  // loss of the local link; check the verified interface and address instead.
278
330
  if let binding = self.binding, !self.cancelled, !self.isBindingIntact(binding) {
@@ -283,25 +335,6 @@ public final class GlassesHotspotNetwork {
283
335
  }
284
336
  }
285
337
 
286
- /// Continue once every monitor has reported, or after a short bound with whatever arrived.
287
- private func awaitInterfaceReports(generation gen: Int, then proceed: @escaping () -> Void) {
288
- interfaceReportsReady = { [weak self] in
289
- guard let self, gen == self.generation, !self.cancelled else { return }
290
- proceed()
291
- }
292
- if !monitorsReported.contains(false) { fireInterfaceReportsReady(); return }
293
- queue.asyncAfter(deadline: .now() + 2) { [weak self] in
294
- guard let self, gen == self.generation else { return }
295
- self.fireInterfaceReportsReady()
296
- }
297
- }
298
-
299
- private func fireInterfaceReportsReady() {
300
- let ready = interfaceReportsReady
301
- interfaceReportsReady = nil
302
- ready?()
303
- }
304
-
305
338
  /// Every check samples interfaces first, so a detach or replacement between path updates
306
339
  /// is recorded in the session identity before it is used.
307
340
  private func observeInterfaces() -> HotspotInterfaceSnapshot {
@@ -338,6 +371,17 @@ public final class GlassesHotspotNetwork {
338
371
  "other private IPv4=\(other.isEmpty ? "none" : other.sorted().joined(separator: ","))"
339
372
  }
340
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)
382
+ }
383
+ }
384
+
341
385
  private func waitForLocalAccess(address: String, generation gen: Int) {
342
386
  let gateway = gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
343
387
  let request = LocalNetworkAccessRequest(localAddress: address, gateway: gateway, queue: queue)
@@ -366,21 +410,50 @@ public final class GlassesHotspotNetwork {
366
410
  }
367
411
 
368
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() {
369
440
  generation += 1
441
+ macAuthorization?.cancel(); macAuthorization = nil
370
442
  localAccess?.cancel(); localAccess = nil
371
443
  interfaceMonitors.forEach { $0.cancel() }
372
444
  interfaceMonitors = []
373
445
  wifiIdentity = HotspotWifiIdentity()
374
- monitorsReported = []
375
- interfaceReportsReady = nil
376
446
  if let ssid, ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
377
447
  ownsConfiguration = false
378
448
  ssid = nil
379
449
  binding = nil
380
450
  gatewayAddress = nil
451
+ finishingLeave = false
452
+ if let token = ownershipToken { HotspotNetworkOwnership.shared.release(token) }
453
+ ownershipToken = nil
381
454
  let replies = leaveReplies
382
455
  leaveReplies.removeAll()
383
- replies.forEach { $0() }
456
+ replies.forEach { $0(.success(())) }
384
457
  }
385
458
 
386
459
  /// Every attached interface's kernel index and link type (its AF_LINK row) and every IPv4
@@ -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,47 @@ 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): promise.reject(error)
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ AsyncFunction("leaveLocalHotspot") { (id: String, promise: Promise) in
33
+ self.queue.async {
34
+ guard self.localHotspotOwner == id else { promise.resolve(nil); return }
35
+ self.localHotspot.leave { result in
36
+ self.queue.async {
37
+ switch result {
38
+ case .success:
39
+ if self.localHotspotOwner == id { self.localHotspotOwner = nil }
40
+ promise.resolve(nil)
41
+ case let .failure(error): promise.reject(error)
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+
12
48
  AsyncFunction("prepare") { (options: [String: Any], promise: Promise) in
13
49
  guard let id = options["attemptId"] as? String,
14
50
  let ingest = options["ingestUrl"] as? String, let url = URL(string: ingest), url.scheme == "https",
@@ -38,16 +74,23 @@ public final class GlassesMediaRelayModule: Module {
38
74
  AsyncFunction("stop") { (id: String, promise: Promise) in
39
75
  self.queue.async {
40
76
  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)
77
+ session.stop { result in
78
+ switch result {
79
+ case .success:
80
+ if self.session === session { self.session = nil }
81
+ promise.resolve(nil)
82
+ case let .failure(error): promise.reject(error)
83
+ }
44
84
  }
45
85
  }
46
86
  }
47
87
 
48
88
  OnDestroy {
49
89
  self.queue.async {
50
- self.session?.stop { self.session = nil }
90
+ self.session?.stop { result in
91
+ if case .success = result { self.session = nil }
92
+ }
93
+ self.localHotspot.leave { _ in }
51
94
  }
52
95
  }
53
96
  }
@@ -62,7 +105,7 @@ private final class ManagedRelaySession {
62
105
  private var publisher: PhoneWhipPublisher?
63
106
  private var stopped = false
64
107
  private var stopFinished = false
65
- private var stopReplies: [() -> Void] = []
108
+ private var stopReplies: [(Result<Void, Error>) -> Void] = []
66
109
  private var ready: ((Result<String, Error>) -> Void)?
67
110
 
68
111
  init(id: String, queue: DispatchQueue) {
@@ -116,10 +159,11 @@ private final class ManagedRelaySession {
116
159
  let callback = ready; ready = nil; callback?(result)
117
160
  }
118
161
 
119
- func stop(completion: @escaping () -> Void) {
120
- if stopFinished { completion(); return }
162
+ func stop(completion: @escaping (Result<Void, Error>) -> Void) {
163
+ if stopFinished { completion(.success(())); return }
164
+ let stopping = !stopReplies.isEmpty
121
165
  stopReplies.append(completion)
122
- guard !stopped else { return }
166
+ guard !stopping else { return }
123
167
  stopped = true
124
168
  finish(.failure(LocalMediaError("Relay cancelled")))
125
169
  hotspot.onLost = nil
@@ -131,10 +175,14 @@ private final class ManagedRelaySession {
131
175
  if let publisher {
132
176
  group.enter(); publisher.stop { group.leave() }
133
177
  }
134
- group.enter(); hotspot.leave { group.leave() }
178
+ var cleanupResult: Result<Void, Error> = .success(())
179
+ group.enter(); hotspot.leave { result in
180
+ self.queue.async { cleanupResult = result; group.leave() }
181
+ }
135
182
  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() }
183
+ self.receiver = nil; self.publisher = nil
184
+ if case .success = cleanupResult { self.stopFinished = true }
185
+ let replies = self.stopReplies; self.stopReplies.removeAll(); replies.forEach { $0(cleanupResult) }
138
186
  }
139
187
  }
140
188
  }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.3.0-dev.864",
3
+ "version": "3.3.0-dev.874",
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",
@@ -1,20 +0,0 @@
1
- #if MENTRA_E2E
2
- import Foundation
3
-
4
- /// Test-build dependency input, not a production network discovery mechanism.
5
- /// The Mac harness owns association and verifies the USB fixture, gateway MAC,
6
- /// DHCP address, local health and Ethernet before launching this process.
7
- public struct MacE2EHotspotLease: Decodable {
8
- public let ssid: String
9
- public let gateway: String
10
- public let address: String
11
- public let issuedAt: TimeInterval
12
-
13
- public func matches(ssid: String, gateway: String?, address: String?, now: TimeInterval) -> Bool {
14
- let age = now - issuedAt
15
- return age >= 0 && age <= 300 && self.gateway == gateway && self.address == address &&
16
- LocalMediaPolicy.canReuseHotspot(requestedSSID: ssid, currentSSID: self.ssid,
17
- address: address, gateway: gateway)
18
- }
19
- }
20
- #endif