@mentra/glasses-media 3.2.1-dev.276 → 3.2.1-dev.278

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.
@@ -104,9 +104,19 @@ class WhipIngestServer(
104
104
  val bound = WhipIngestProtocol.Endpoint(address.hostAddress ?: "127.0.0.1", socket.localPort)
105
105
  server = socket
106
106
  endpoint = bound
107
- closed = CountDownLatch(1)
107
+ val listenerClosed = CountDownLatch(1)
108
+ closed = listenerClosed
108
109
  state = WhipIngestProtocol.State()
109
- acceptThread = Thread({ acceptLoop(socket) }, "whip-ingest-accept").apply {
110
+ acceptThread = Thread({
111
+ try {
112
+ acceptLoop(socket)
113
+ } finally {
114
+ // close() can return while a blocked accept still owns the native descriptor.
115
+ // Only the accept thread can signal that the listener has really released its port.
116
+ socket.close()
117
+ listenerClosed.countDown()
118
+ }
119
+ }, "whip-ingest-accept").apply {
110
120
  isDaemon = true
111
121
  start()
112
122
  }
@@ -156,9 +166,11 @@ class WhipIngestServer(
156
166
 
157
167
  /** Hard teardown barrier: no request may still create a peer after this returns. */
158
168
  fun closeAndAwait(timeoutMs: Long = 15_000): Boolean {
169
+ val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
159
170
  closeNow()
160
171
  connections.shutdownNow()
161
- return connections.awaitTermination(timeoutMs, TimeUnit.MILLISECONDS)
172
+ if (!awaitClosed(timeoutMs)) return false
173
+ return connections.awaitTermination((deadline - System.nanoTime()).coerceAtLeast(0), TimeUnit.NANOSECONDS)
162
174
  }
163
175
 
164
176
  /**
@@ -178,9 +190,7 @@ class WhipIngestServer(
178
190
  endpoint = null
179
191
  current
180
192
  }
181
- // Counted down even on the early return: a second close must not leave a waiter parked on a
182
- // latch that nothing will ever open again.
183
- closed.countDown()
193
+ // The accept thread owns the close barrier; concurrent closes must not open it early.
184
194
  if (socket == null) return
185
195
  runCatching { socket.close() }
186
196
  activeSockets.forEach { runCatching { it.close() } }
@@ -0,0 +1,16 @@
1
+ /// A usable default route is independent of the glasses' local-only Wi-Fi hotspot.
2
+ /// This classifies network paths; actual server reachability is established by the call.
3
+ public enum HotspotInternetPolicy {
4
+ public static func route(satisfied: Bool, cellular: Bool, ethernet: Bool,
5
+ restoredWifiSSID: String? = nil, glassesSSID: String? = nil) -> String?
6
+ {
7
+ guard satisfied else { return nil }
8
+ if cellular { return "cellular" }
9
+ if ethernet { return "ethernet" }
10
+ // Callers supply a Wi-Fi SSID only after releasing the glasses hotspot.
11
+ if let restoredWifiSSID, !restoredWifiSSID.isEmpty, restoredWifiSSID != glassesSSID {
12
+ return "wifi"
13
+ }
14
+ return nil
15
+ }
16
+ }
@@ -2,9 +2,11 @@ import Darwin
2
2
  import Foundation
3
3
  import Network
4
4
  import NetworkExtension
5
+ import OSLog
5
6
 
6
7
  /// Same persistent hotspot join as gallery (`joinOnce=false`). Only local traffic uses Wi-Fi.
7
8
  public final class GlassesHotspotNetwork {
9
+ private let logger = Logger(subsystem: "com.mentra.glassesmedia", category: "hotspot")
8
10
  private let queue = DispatchQueue(label: "com.mentra.glassesmedia.hotspot")
9
11
  private var ssid: String?
10
12
  private var lastHotspotSSID: String?
@@ -31,21 +33,8 @@ public final class GlassesHotspotNetwork {
31
33
  self.lastHotspotSSID = ssid
32
34
  self.gatewayAddress = gateway
33
35
  self.cancelled = false
34
- self.applying = true
35
36
  self.joinReply = completion
36
- let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
37
- config.joinOnce = false
38
- NEHotspotConfigurationManager.shared.apply(config) { error in
39
- self.queue.async {
40
- guard gen == self.generation else { return }
41
- self.applying = false
42
- if self.cancelled { self.finishLeave(); return }
43
- if let error, (error as NSError).code != NEHotspotConfigurationError.alreadyAssociated.rawValue {
44
- self.finishJoin(.failure(error)); self.finishLeave(); return
45
- }
46
- self.waitForAddress(ssid: ssid, generation: gen, remaining: 60)
47
- }
48
- }
37
+ self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
49
38
  self.queue.asyncAfter(deadline: .now() + 60) {
50
39
  guard gen == self.generation, self.joinReply != nil else { return }
51
40
  self.cancelled = true
@@ -84,7 +73,13 @@ public final class GlassesHotspotNetwork {
84
73
  guard let address = self.localAddress else { completion(false, "No joined hotspot"); return }
85
74
  let gateway = self.gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
86
75
  let parameters = NWParameters.tcp
87
- parameters.requiredInterfaceType = .wifi
76
+ if ProcessInfo.processInfo.isiOSAppOnMac {
77
+ // The Mac route probe rejects the Wi-Fi type constraint on an otherwise
78
+ // usable en0 route. Restrict this connection to the verified source IP.
79
+ parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(address), port: .any)
80
+ } else {
81
+ parameters.requiredInterfaceType = .wifi
82
+ }
88
83
  let connection = NWConnection(host: NWEndpoint.Host(gateway), port: 8089, using: parameters)
89
84
  var finished = false
90
85
  let finish: (Bool, String) -> Void = { reachable, detail in
@@ -106,7 +101,7 @@ public final class GlassesHotspotNetwork {
106
101
  }
107
102
  }
108
103
 
109
- public func awaitInternet(requireCellular: Bool = true, completion: @escaping (Bool, String) -> Void) {
104
+ public func awaitInternet(allowWifiAfterRelease: Bool = false, completion: @escaping (Bool, String) -> Void) {
110
105
  queue.async {
111
106
  let monitor = NWPathMonitor()
112
107
  var finished = false
@@ -117,21 +112,56 @@ public final class GlassesHotspotNetwork {
117
112
  completion(usable, detail)
118
113
  }
119
114
  monitor.pathUpdateHandler = { path in
120
- if path.status == .satisfied, path.usesInterfaceType(.cellular) { finish(true, "cellular") }
115
+ if let route = HotspotInternetPolicy.route(satisfied: path.status == .satisfied,
116
+ cellular: path.usesInterfaceType(.cellular),
117
+ ethernet: path.usesInterfaceType(.wiredEthernet))
118
+ {
119
+ finish(true, route)
120
+ return
121
+ }
121
122
  // Once the hotspot is released, a return to the user's Wi-Fi is also valid.
122
123
  // Do not mistake the departing glasses AP's local-only path for restored internet.
123
- if !requireCellular, path.status == .satisfied, path.usesInterfaceType(.wifi) {
124
+ if allowWifiAfterRelease, path.status == .satisfied, path.usesInterfaceType(.wifi) {
124
125
  NEHotspotNetwork.fetchCurrent { network in
125
126
  self.queue.async {
126
- guard let network, !network.ssid.isEmpty, network.ssid != self.lastHotspotSSID else { return }
127
- finish(true, "wifi")
127
+ guard let route = HotspotInternetPolicy.route(satisfied: true, cellular: false, ethernet: false,
128
+ restoredWifiSSID: network?.ssid, glassesSSID: self.lastHotspotSSID)
129
+ else { return }
130
+ finish(true, route)
128
131
  }
129
132
  }
130
133
  }
131
134
  }
132
135
  monitor.start(queue: self.queue)
133
136
  self.queue.asyncAfter(deadline: .now() + 15) {
134
- finish(false, requireCellular ? "Cellular internet did not become the default route" : "Internet did not return after leaving the glasses hotspot")
137
+ finish(false, allowWifiAfterRelease ? "Internet did not return after leaving the glasses hotspot" : "Cellular or Ethernet internet did not become the default route")
138
+ }
139
+ }
140
+ }
141
+
142
+ private func applyConfiguration(ssid: String, passphrase: String, generation gen: Int) {
143
+ let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
144
+ config.joinOnce = false
145
+ applying = true
146
+ logger.info("HOTSPOT_JOIN apply_start ios_on_mac=\(ProcessInfo.processInfo.isiOSAppOnMac)")
147
+ NEHotspotConfigurationManager.shared.apply(config) { error in
148
+ self.queue.async {
149
+ guard gen == self.generation else { return }
150
+ self.applying = false
151
+ if self.cancelled { self.finishLeave(); return }
152
+ if let error {
153
+ let nativeError = error as NSError
154
+ let alreadyAssociated = nativeError.domain == NEHotspotConfigurationErrorDomain &&
155
+ nativeError.code == NEHotspotConfigurationError.alreadyAssociated.rawValue
156
+ if !alreadyAssociated {
157
+ // Error identifiers are useful without credentials or full userInfo.
158
+ let underlying = nativeError.userInfo[NSUnderlyingErrorKey] as? NSError
159
+ self.logger.error("HOTSPOT_JOIN apply_failed domain=\(nativeError.domain, privacy: .public) code=\(nativeError.code) underlying_domain=\(underlying?.domain ?? "none", privacy: .public) underlying_code=\(underlying.map { String($0.code) } ?? "none", privacy: .public)")
160
+ self.finishJoin(.failure(error)); self.finishLeave(); return
161
+ }
162
+ }
163
+ self.logger.info("HOTSPOT_JOIN apply_accepted")
164
+ self.waitForAddress(ssid: ssid, generation: gen, remaining: 60)
135
165
  }
136
166
  }
137
167
  }
@@ -163,10 +193,10 @@ public final class GlassesHotspotNetwork {
163
193
  let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
164
194
  self.monitor = monitor
165
195
  monitor.pathUpdateHandler = { [weak self] _ in
166
- guard let self, gen == self.generation, !self.cancelled else { return }
196
+ guard let self, gen == generation, !self.cancelled else { return }
167
197
  // This AP intentionally has no internet. Loss of its default internet path is not
168
198
  // loss of the local link; use the actual interface address instead.
169
- if Self.wifiAddress() != self.localAddress { self.onLost?("Glasses hotspot connection was lost") }
199
+ if Self.wifiAddress() != localAddress { onLost?("Glasses hotspot connection was lost") }
170
200
  }
171
201
  monitor.start(queue: queue)
172
202
  }
@@ -83,7 +83,7 @@ private final class ManagedRelaySession {
83
83
  self.hotspot.awaitInternet { usable, _ in
84
84
  self.queue.async {
85
85
  guard !self.stopped else { return }
86
- guard usable else { self.finish(.failure(LocalMediaError("Turn on phone mobile data to stream through the glasses hotspot"))); return }
86
+ guard usable else { self.finish(.failure(LocalMediaError("Connect cellular or Ethernet internet to stream through the glasses hotspot"))); return }
87
87
  let publisher = PhoneWhipPublisher(endpoint: endpoint, captureAudio: captureAudio, bitrate: bitrate, onState: onState)
88
88
  self.publisher = publisher
89
89
  let receiver = LocalWhipIngestSource()
@@ -171,7 +171,9 @@ public final class LocalWhipIngestSource: NSObject, DecodedGlassesMediaSource {
171
171
  publisherFailed: { [weak self] in self?.state == .failed }
172
172
  )
173
173
  self.server = server
174
- server.start(address: address) { [weak self] result in
174
+ // Keep the listener bound to the verified hotspot IP on iOS-on-Mac.
175
+ // Requiring the Wi-Fi interface type rejects that local route on this host.
176
+ server.start(address: address, wifiOnly: !ProcessInfo.processInfo.isiOSAppOnMac) { [weak self] result in
175
177
  guard let self else { completion(.failure(LocalMediaError("Receiver released"))); return }
176
178
  self.queue.async {
177
179
  guard gen == self.generation, !self.stopping else { completion(.failure(LocalMediaError("Receiver cancelled"))); return }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.2.1-dev.276",
3
+ "version": "3.2.1-dev.278",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",