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

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,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
+ }
@@ -18,7 +18,9 @@ public final class GlassesHotspotNetwork {
18
18
  private var joinReply: ((Result<String, Error>) -> Void)?
19
19
  private var leaveReplies: [() -> Void] = []
20
20
  private var monitor: NWPathMonitor?
21
+ private var localAccess: LocalNetworkAccessRequest?
21
22
  public var onLost: ((String) -> Void)?
23
+ public var onPermissionRequired: (() -> Void)?
22
24
  public init() {}
23
25
 
24
26
  public func join(ssid: String, passphrase: String, gateway: String? = nil, completion: @escaping (Result<String, Error>) -> Void) {
@@ -36,7 +38,9 @@ public final class GlassesHotspotNetwork {
36
38
  self.joinReply = completion
37
39
  self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
38
40
  self.queue.asyncAfter(deadline: .now() + 60) {
39
- guard gen == self.generation, self.joinReply != nil else { return }
41
+ // Once associated, the user may still be reading the Local Network alert.
42
+ // Its response time is not a hotspot association timeout.
43
+ guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
40
44
  self.cancelled = true
41
45
  self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
42
46
  // apply() cannot be cancelled. Retain the reservation until its callback and remove the
@@ -176,7 +180,7 @@ public final class GlassesHotspotNetwork {
176
180
  {
177
181
  self.localAddress = address
178
182
  self.startMonitor(generation: gen)
179
- self.finishJoin(.success(address))
183
+ self.waitForLocalAccess(address: address, generation: gen)
180
184
  } else if remaining > 0 {
181
185
  self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
182
186
  } else {
@@ -201,6 +205,27 @@ public final class GlassesHotspotNetwork {
201
205
  monitor.start(queue: queue)
202
206
  }
203
207
 
208
+ private func waitForLocalAccess(address: String, generation gen: Int) {
209
+ let gateway = gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
210
+ let request = LocalNetworkAccessRequest(localAddress: address, gateway: gateway, queue: queue)
211
+ localAccess = request
212
+ request.start(onPermissionRequired: { [weak self] in
213
+ guard let self, gen == generation, !cancelled else { return }
214
+ NSLog("GLASSES-MEDIA local_network_permission=waiting")
215
+ onPermissionRequired?()
216
+ }) { [weak self] result in
217
+ guard let self, gen == generation, !cancelled else { return }
218
+ switch result {
219
+ case .success:
220
+ NSLog("GLASSES-MEDIA local_network_permission=ready")
221
+ finishJoin(.success(address))
222
+ case let .failure(error):
223
+ finishJoin(.failure(error))
224
+ finishLeave()
225
+ }
226
+ }
227
+ }
228
+
204
229
  private func finishJoin(_ result: Result<String, Error>) {
205
230
  let reply = joinReply
206
231
  joinReply = nil
@@ -209,6 +234,7 @@ public final class GlassesHotspotNetwork {
209
234
 
210
235
  private func finishLeave() {
211
236
  generation += 1
237
+ localAccess?.cancel(); localAccess = nil
212
238
  monitor?.cancel(); monitor = nil
213
239
  if let ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
214
240
  ssid = 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.294",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",