@mentra/glasses-media 3.2.1-dev.293 → 3.2.1-dev.295

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.
@@ -39,6 +39,14 @@ public enum LocalMediaPolicy {
39
39
  return bytes[3] > 0 && bytes[3] < 255
40
40
  }
41
41
 
42
+ /// A Mac may join through its host network controls. Reuse that connection only
43
+ /// after both its SSID and DHCP address match the hotspot advertised over BLE.
44
+ public static func canReuseHotspot(requestedSSID: String, currentSSID: String?, address: String?, gateway: String?) -> Bool {
45
+ guard !requestedSSID.isEmpty, requestedSSID == currentSSID,
46
+ let address, let gateway else { return false }
47
+ return isHotspotClientAddress(address, gateway: gateway)
48
+ }
49
+
42
50
  /// Keep only concrete host candidates on the local link. Never rewrite a cellular socket's
43
51
  /// advertised address: a candidate must already belong to the interface it claims.
44
52
  public static func localSdp(_ sdp: String, address: String, answer: Bool) throws -> String {
@@ -0,0 +1,134 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ protocol LocalNetworkAccessConnection: AnyObject {
5
+ var onState: ((NWConnection.State, NWPath.UnsatisfiedReason?) -> Void)? { get set }
6
+ func start(queue: DispatchQueue)
7
+ func cancel()
8
+ }
9
+
10
+ /// Wait for permission to use the verified local route before starting timed media work.
11
+ /// Connect to the glasses' existing gallery HTTP endpoint to prove local access (TN3179).
12
+ /// Network.framework retries when access is granted. A denied path can also mean that the
13
+ /// alert is still open, so it must not be treated as a terminal error or given a media timeout.
14
+ /// Ordinary connection setup has a 30-second budget, paused while permission is required.
15
+ public final class LocalNetworkAccessRequest {
16
+ private let queue: DispatchQueue
17
+ private let connection: LocalNetworkAccessConnection
18
+ private let now: () -> TimeInterval
19
+ private let scheduleTimeout: (TimeInterval, DispatchWorkItem) -> Void
20
+ private var remainingConnectivityTime: TimeInterval = 30
21
+ private var timeoutDeadline: TimeInterval?
22
+ private var timeoutWork: DispatchWorkItem?
23
+ private var completion: ((Result<Void, Error>) -> Void)?
24
+ private var waiting: (() -> Void)?
25
+ private var finished = false
26
+ private var reportedPermission = false
27
+
28
+ public convenience init(localAddress: String, gateway: String, queue: DispatchQueue) {
29
+ self.init(connection: LocalNetworkPermissionConnection(localAddress: localAddress, gateway: gateway), queue: queue)
30
+ }
31
+
32
+ init(connection: LocalNetworkAccessConnection, queue: DispatchQueue,
33
+ now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },
34
+ scheduleTimeout: ((TimeInterval, DispatchWorkItem) -> Void)? = nil)
35
+ {
36
+ self.connection = connection
37
+ self.queue = queue
38
+ self.now = now
39
+ self.scheduleTimeout = scheduleTimeout ?? { delay, work in queue.asyncAfter(deadline: .now() + delay, execute: work) }
40
+ }
41
+
42
+ public func start(onPermissionRequired: @escaping () -> Void, completion: @escaping (Result<Void, Error>) -> Void) {
43
+ queue.async {
44
+ guard !self.finished, self.completion == nil else { return }
45
+ self.completion = completion
46
+ self.waiting = onPermissionRequired
47
+ self.connection.onState = { [weak self] state, reason in
48
+ guard let self, !self.finished else { return }
49
+ switch state {
50
+ case .ready: self.finish(.success(()))
51
+ case .waiting where reason == .localNetworkDenied:
52
+ self.pauseConnectivityTimeout()
53
+ if !self.reportedPermission {
54
+ self.reportedPermission = true
55
+ self.waiting?()
56
+ }
57
+ // Waiting errors are nonfatal; NWConnection retries when its path recovers.
58
+ // A nil path is not proof of denied permission, but must not fail immediately.
59
+ case .setup, .preparing, .waiting: self.resumeConnectivityTimeout()
60
+ case let .failed(error): self.finish(.failure(error))
61
+ case .cancelled: self.finish(.failure(CancellationError()))
62
+ @unknown default: break
63
+ }
64
+ }
65
+ self.resumeConnectivityTimeout()
66
+ self.connection.start(queue: self.queue)
67
+ }
68
+ }
69
+
70
+ public func cancel() {
71
+ queue.async { self.finish(.failure(CancellationError())) }
72
+ }
73
+
74
+ private func resumeConnectivityTimeout() {
75
+ guard timeoutDeadline == nil else { return }
76
+ let deadline = now() + remainingConnectivityTime
77
+ timeoutDeadline = deadline
78
+ let work = DispatchWorkItem { [weak self] in
79
+ guard let self, self.timeoutDeadline == deadline else { return }
80
+ self.finish(.failure(LocalMediaError("Local connection to the glasses timed out")))
81
+ }
82
+ timeoutWork = work
83
+ scheduleTimeout(remainingConnectivityTime, work)
84
+ }
85
+
86
+ private func pauseConnectivityTimeout() {
87
+ if let deadline = timeoutDeadline { remainingConnectivityTime = max(0, deadline - now()) }
88
+ timeoutDeadline = nil
89
+ timeoutWork?.cancel()
90
+ timeoutWork = nil
91
+ }
92
+
93
+ private func finish(_ result: Result<Void, Error>) {
94
+ guard !finished else { return }
95
+ finished = true
96
+ pauseConnectivityTimeout()
97
+ connection.onState = nil
98
+ connection.cancel()
99
+ let reply = completion
100
+ completion = nil
101
+ waiting = nil
102
+ reply?(result)
103
+ }
104
+ }
105
+
106
+ private final class LocalNetworkPermissionConnection: LocalNetworkAccessConnection {
107
+ var onState: ((NWConnection.State, NWPath.UnsatisfiedReason?) -> Void)?
108
+ private let connection: NWConnection
109
+
110
+ init(localAddress: String, gateway: String) {
111
+ let parameters = NWParameters.tcp
112
+ parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(localAddress), port: .any)
113
+ connection = NWConnection(host: NWEndpoint.Host(gateway), port: 8089, using: parameters)
114
+ }
115
+
116
+ func start(queue: DispatchQueue) {
117
+ connection.stateUpdateHandler = { [weak self] state in
118
+ guard let self else { return }
119
+ onState?(state, connection.currentPath?.unsatisfiedReason)
120
+ }
121
+ // The path reason can arrive after a waiting update with no currentPath yet.
122
+ connection.pathUpdateHandler = { [weak self] path in
123
+ guard let self else { return }
124
+ onState?(connection.state, path.unsatisfiedReason)
125
+ }
126
+ connection.start(queue: queue)
127
+ }
128
+
129
+ func cancel() {
130
+ connection.stateUpdateHandler = nil
131
+ connection.pathUpdateHandler = nil
132
+ connection.cancel()
133
+ }
134
+ }
@@ -0,0 +1,20 @@
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
@@ -14,11 +14,17 @@ public final class GlassesHotspotNetwork {
14
14
  private var gatewayAddress: String?
15
15
  private var generation = 0
16
16
  private var applying = false
17
+ private var ownsConfiguration = false
17
18
  private var cancelled = false
18
19
  private var joinReply: ((Result<String, Error>) -> Void)?
19
20
  private var leaveReplies: [() -> Void] = []
20
21
  private var monitor: NWPathMonitor?
22
+ private var localAccess: LocalNetworkAccessRequest?
23
+ #if MENTRA_E2E
24
+ private var testLeaseConsumed = false
25
+ #endif
21
26
  public var onLost: ((String) -> Void)?
27
+ public var onPermissionRequired: (() -> Void)?
22
28
  public init() {}
23
29
 
24
30
  public func join(ssid: String, passphrase: String, gateway: String? = nil, completion: @escaping (Result<String, Error>) -> Void) {
@@ -33,15 +39,58 @@ public final class GlassesHotspotNetwork {
33
39
  self.lastHotspotSSID = ssid
34
40
  self.gatewayAddress = gateway
35
41
  self.cancelled = false
42
+ self.applying = false
43
+ self.ownsConfiguration = false
36
44
  self.joinReply = completion
37
- self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
45
+ 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
+ }
82
+ } else {
83
+ self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
84
+ }
38
85
  self.queue.asyncAfter(deadline: .now() + 60) {
39
- guard gen == self.generation, self.joinReply != nil else { return }
86
+ // Once associated, the user may still be reading the Local Network alert.
87
+ // Its response time is not a hotspot association timeout.
88
+ guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
40
89
  self.cancelled = true
41
90
  self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
42
91
  // apply() cannot be cancelled. Retain the reservation until its callback and remove the
43
92
  // late configuration before another call is permitted to acquire the network.
44
- NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid)
93
+ if self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
45
94
  if !self.applying { self.finishLeave() }
46
95
  }
47
96
  }
@@ -52,7 +101,7 @@ public final class GlassesHotspotNetwork {
52
101
  self.cancelled = true
53
102
  self.leaveReplies.append(completion)
54
103
  self.finishJoin(.failure(LocalMediaError("Hotspot join cancelled")))
55
- if let ssid = self.ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
104
+ if let ssid = self.ssid, self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
56
105
  if !self.applying { self.finishLeave() }
57
106
  }
58
107
  }
@@ -143,6 +192,7 @@ public final class GlassesHotspotNetwork {
143
192
  let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
144
193
  config.joinOnce = false
145
194
  applying = true
195
+ ownsConfiguration = true
146
196
  logger.info("HOTSPOT_JOIN apply_start ios_on_mac=\(ProcessInfo.processInfo.isiOSAppOnMac)")
147
197
  NEHotspotConfigurationManager.shared.apply(config) { error in
148
198
  self.queue.async {
@@ -176,7 +226,7 @@ public final class GlassesHotspotNetwork {
176
226
  {
177
227
  self.localAddress = address
178
228
  self.startMonitor(generation: gen)
179
- self.finishJoin(.success(address))
229
+ self.waitForLocalAccess(address: address, generation: gen)
180
230
  } else if remaining > 0 {
181
231
  self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
182
232
  } else {
@@ -201,6 +251,27 @@ public final class GlassesHotspotNetwork {
201
251
  monitor.start(queue: queue)
202
252
  }
203
253
 
254
+ private func waitForLocalAccess(address: String, generation gen: Int) {
255
+ let gateway = gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
256
+ let request = LocalNetworkAccessRequest(localAddress: address, gateway: gateway, queue: queue)
257
+ localAccess = request
258
+ request.start(onPermissionRequired: { [weak self] in
259
+ guard let self, gen == generation, !cancelled else { return }
260
+ NSLog("GLASSES-MEDIA local_network_permission=waiting")
261
+ onPermissionRequired?()
262
+ }) { [weak self] result in
263
+ guard let self, gen == generation, !cancelled else { return }
264
+ switch result {
265
+ case .success:
266
+ NSLog("GLASSES-MEDIA local_network_permission=ready")
267
+ finishJoin(.success(address))
268
+ case let .failure(error):
269
+ finishJoin(.failure(error))
270
+ finishLeave()
271
+ }
272
+ }
273
+ }
274
+
204
275
  private func finishJoin(_ result: Result<String, Error>) {
205
276
  let reply = joinReply
206
277
  joinReply = nil
@@ -209,8 +280,10 @@ public final class GlassesHotspotNetwork {
209
280
 
210
281
  private func finishLeave() {
211
282
  generation += 1
283
+ localAccess?.cancel(); localAccess = nil
212
284
  monitor?.cancel(); monitor = nil
213
- if let ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
285
+ if let ssid, ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
286
+ ownsConfiguration = false
214
287
  ssid = nil
215
288
  localAddress = nil
216
289
  gatewayAddress = nil
@@ -74,6 +74,12 @@ private final class ManagedRelaySession {
74
74
  {
75
75
  ready = completion
76
76
  hotspot.onLost = { reason in onState("failed", reason) }
77
+ hotspot.onPermissionRequired = { [weak self] in
78
+ self?.queue.async {
79
+ guard let self, !self.stopped else { return }
80
+ onState("permission_required", "Allow Local Network access to connect to your glasses. If you previously denied access, enable it in Settings, or cancel to return home.")
81
+ }
82
+ }
77
83
  hotspot.join(ssid: ssid, passphrase: password, gateway: gateway) { result in
78
84
  self.queue.async {
79
85
  guard !self.stopped else { return }
@@ -117,6 +123,7 @@ private final class ManagedRelaySession {
117
123
  stopped = true
118
124
  finish(.failure(LocalMediaError("Relay cancelled")))
119
125
  hotspot.onLost = nil
126
+ hotspot.onPermissionRequired = nil
120
127
  let group = DispatchGroup()
121
128
  if let receiver {
122
129
  group.enter(); receiver.stop { group.leave() }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.2.1-dev.293",
3
+ "version": "3.2.1-dev.295",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",