@mentra/glasses-media 3.4.0-dev.975 → 3.4.0-dev.978

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.
@@ -13,28 +13,32 @@ final class MacHotspotAssociation {
13
13
  private let now: () -> TimeInterval
14
14
  private let pause: ((TimeInterval) -> Void)?
15
15
  private let makeInterface: () throws -> MacWifiInterface
16
+ private let authorization: MacHotspotAuthorization?
16
17
 
17
18
  init(deadline: TimeInterval = ProcessInfo.processInfo.systemUptime + 60,
18
19
  now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },
19
20
  pause: ((TimeInterval) -> Void)? = nil,
20
21
  makeInterface: (() throws -> MacWifiInterface)? = nil,
21
- authorizationFacts: MacHotspotAuthorizationFacts? = nil)
22
+ authorizationFacts: MacHotspotAuthorizationFacts? = nil,
23
+ authorization: MacHotspotAuthorization? = nil)
22
24
  {
23
25
  self.deadline = deadline
24
26
  self.now = now
25
27
  self.pause = pause
26
- self.makeInterface = makeInterface ?? { try PublicCoreWlanInterface(authorizationFacts: authorizationFacts) }
28
+ self.authorization = authorization
29
+ self.makeInterface = makeInterface ?? { try PublicCoreWlanInterface(authorizationFacts: authorizationFacts, authorization: authorization) }
27
30
  }
28
31
 
29
32
  func associate(ssid: String, password: String) throws -> String {
30
33
  try checkCurrent()
31
34
  let interface = try makeInterface()
32
35
  self.interface = interface
33
- if try interface.currentSSID() != ssid {
36
+ if try readSSID(interface) != ssid {
34
37
  let network = try discoverNetwork(ssid: ssid, interface: interface)
35
38
  try checkDiscoveryCurrent()
36
- if let network, try interface.currentSSID() != ssid {
39
+ if let network, try readSSID(interface) != ssid {
37
40
  try checkDiscoveryCurrent()
41
+ try authorization?.requireCurrentAuthorization()
38
42
  // Record the intended association before this blocking call. A late
39
43
  // successful join still belongs to us and must be cleaned up.
40
44
  joinedSSID = ssid
@@ -42,7 +46,7 @@ final class MacHotspotAssociation {
42
46
  }
43
47
  }
44
48
  try checkCurrent()
45
- guard try interface.currentSSID() == ssid else {
49
+ guard try readSSID(interface) == ssid else {
46
50
  throw LocalMediaError("Mac Wi-Fi did not associate with the requested glasses hotspot")
47
51
  }
48
52
  return try interface.name()
@@ -56,9 +60,9 @@ final class MacHotspotAssociation {
56
60
  /// A connection the user replaced must never be disconnected by our cleanup.
57
61
  func leave() throws {
58
62
  if let joinedSSID {
59
- if try interface?.currentSSID() == joinedSSID {
60
- interface?.disassociate()
61
- guard try interface?.currentSSID() != joinedSSID else {
63
+ if let interface, try readSSID(interface) == joinedSSID {
64
+ interface.disassociate()
65
+ guard try readSSID(interface) != joinedSSID else {
62
66
  throw LocalMediaError("Mac Wi-Fi has not released the glasses hotspot")
63
67
  }
64
68
  }
@@ -68,7 +72,15 @@ final class MacHotspotAssociation {
68
72
  }
69
73
 
70
74
  func currentSSID() throws -> String? {
71
- try interface?.currentSSID()
75
+ guard let interface else { return nil }
76
+ return try readSSID(interface)
77
+ }
78
+
79
+ private func readSSID(_ interface: MacWifiInterface) throws -> String? {
80
+ try authorization?.requireCurrentAuthorization()
81
+ let ssid = try interface.currentSSID()
82
+ try authorization?.requireCurrentAuthorization()
83
+ return ssid
72
84
  }
73
85
 
74
86
  static func observeCurrentSSID() throws -> String? {
@@ -79,10 +91,11 @@ final class MacHotspotAssociation {
79
91
  while true {
80
92
  try checkDiscoveryCurrent()
81
93
  // Recheck Location authorization before and after each blocking scan.
82
- if try interface.currentSSID() == ssid { return nil }
94
+ if try readSSID(interface) == ssid { return nil }
95
+ try authorization?.requireCurrentAuthorization()
83
96
  let network = try interface.network(ssid: ssid)
84
97
  try checkDiscoveryCurrent()
85
- if try interface.currentSSID() == ssid { return nil }
98
+ if try readSSID(interface) == ssid { return nil }
86
99
  if let network { return network }
87
100
  // Successful empty scans remain pending within this same join window.
88
101
  // Native scan errors escape immediately; association is never retried.
@@ -131,10 +144,12 @@ private final class PublicCoreWlanInterface: MacWifiInterface {
131
144
  private let bundle: Bundle
132
145
  private let interface: NSObject
133
146
  private let authorizationFacts: MacHotspotAuthorizationFacts
147
+ private let authorization: MacHotspotAuthorization?
134
148
  private typealias Disassociate = @convention(c) (AnyObject, Selector) -> Void
135
149
 
136
- init(authorizationFacts: MacHotspotAuthorizationFacts? = nil) throws {
150
+ init(authorizationFacts: MacHotspotAuthorizationFacts? = nil, authorization: MacHotspotAuthorization? = nil) throws {
137
151
  self.authorizationFacts = authorizationFacts ?? MacHotspotAuthorizationFacts()
152
+ self.authorization = authorization
138
153
  guard let bundle = Bundle(path: "/System/Library/Frameworks/CoreWLAN.framework") else {
139
154
  throw LocalMediaError("Mac Wi-Fi framework is unavailable")
140
155
  }
@@ -162,16 +177,24 @@ private final class PublicCoreWlanInterface: MacWifiInterface {
162
177
  }
163
178
 
164
179
  func currentSSID() throws -> String? {
180
+ try requireAuthorization()
181
+ guard let data = interface.perform(NSSelectorFromString("ssidData"))?.takeUnretainedValue() as? Data else { return nil }
182
+ return String(data: data, encoding: .utf8)
183
+ }
184
+
185
+ private func requireAuthorization() throws {
186
+ if let authorization { try authorization.requireCurrentAuthorization(); return }
187
+ // Independent internet-return observation has no owned join recipient.
188
+ // Its existing static authorization guard remains unchanged.
165
189
  let authorization = CLLocationManager.authorizationStatus()
166
190
  guard MacHotspotAuthorization.isAuthorized(authorization) else {
167
191
  throw authorizationFacts.failure("Location access is required to verify the current Mac Wi-Fi network", raw: authorization.rawValue,
168
192
  getter: .staticSSID, foreground: MacHotspotAuthorizationFacts.foreground())
169
193
  }
170
- guard let data = interface.perform(NSSelectorFromString("ssidData"))?.takeUnretainedValue() as? Data else { return nil }
171
- return String(data: data, encoding: .utf8)
172
194
  }
173
195
 
174
196
  func network(ssid: String) throws -> NSObject? {
197
+ try requireAuthorization()
175
198
  let selector = NSSelectorFromString("scanForNetworksWithSSID:error:")
176
199
  let invoke = unsafeBitCast(interface.method(for: selector), to: MacWifiInvocation.Scan.self)
177
200
  var error: NSError?
@@ -190,6 +213,7 @@ private final class PublicCoreWlanInterface: MacWifiInterface {
190
213
  }
191
214
 
192
215
  func associate(network: NSObject, password: String) throws {
216
+ try requireAuthorization()
193
217
  let selector = NSSelectorFromString("associateToNetwork:password:error:")
194
218
  let invoke = unsafeBitCast(interface.method(for: selector), to: MacWifiInvocation.Associate.self)
195
219
  var error: NSError?
@@ -6,19 +6,31 @@ import Foundation
6
6
  final class MacHotspotAuthorization: NSObject, CLLocationManagerDelegate {
7
7
  private var manager: CLLocationManager?
8
8
  private var completion: ((Result<Void, Error>) -> Void)?
9
- private var finished = false
9
+ private var started = false
10
+ private var closed = false
11
+ private var ready = false
12
+ private var revoked = false
10
13
  private let facts: MacHotspotAuthorizationFacts
14
+ private let makeManager: () -> CLLocationManager
15
+ private let locationServicesEnabled: () -> Bool
16
+ var onRevoked: ((Error) -> Void)?
17
+ var onRestored: (() -> Void)?
11
18
 
12
- init(facts: MacHotspotAuthorizationFacts = MacHotspotAuthorizationFacts()) {
19
+ init(facts: MacHotspotAuthorizationFacts = MacHotspotAuthorizationFacts(), makeManager: @escaping () -> CLLocationManager = { CLLocationManager() },
20
+ locationServicesEnabled: @escaping () -> Bool = { CLLocationManager.locationServicesEnabled() })
21
+ {
13
22
  self.facts = facts
23
+ self.makeManager = makeManager
24
+ self.locationServicesEnabled = locationServicesEnabled
14
25
  super.init()
15
26
  }
16
27
 
17
28
  func start(completion: @escaping (Result<Void, Error>) -> Void) {
18
29
  DispatchQueue.main.async {
19
- guard !self.finished else { completion(.failure(CancellationError())); return }
30
+ guard !self.closed, !self.started else { completion(.failure(CancellationError())); return }
31
+ self.started = true
20
32
  self.completion = completion
21
- let manager = CLLocationManager()
33
+ let manager = self.makeManager()
22
34
  self.manager = manager
23
35
  manager.delegate = self
24
36
  self.handle(manager.authorizationStatus, getter: .instanceInitial)
@@ -27,10 +39,48 @@ final class MacHotspotAuthorization: NSObject, CLLocationManagerDelegate {
27
39
  }
28
40
 
29
41
  func cancel() {
30
- DispatchQueue.main.async { self.finish(.failure(CancellationError())) }
42
+ DispatchQueue.main.async {
43
+ self.closed = true
44
+ self.ready = false
45
+ self.reply(.failure(CancellationError()))
46
+ self.manager?.delegate = nil
47
+ self.manager = nil
48
+ self.onRevoked = nil
49
+ self.onRestored = nil
50
+ }
51
+ }
52
+
53
+ /// A ready session retains its manager through owned Wi-Fi cleanup. An unanswered
54
+ /// authorization request must still settle when the join is cancelled.
55
+ func cancelPending() {
56
+ DispatchQueue.main.async {
57
+ if self.completion != nil { self.cancel() }
58
+ }
59
+ }
60
+
61
+ /// Query the same initialized recipient on its owning thread for every native read
62
+ /// or write. An earlier success is never an authorization cache.
63
+ func requireCurrentAuthorization() throws {
64
+ let check = {
65
+ guard !self.closed, let manager = self.manager else { throw CancellationError() }
66
+ let status = manager.authorizationStatus
67
+ let foreground = MacHotspotAuthorizationFacts.foreground()
68
+ self.facts.observeInstance(raw: status.rawValue, getter: .instanceCurrent, foreground: foreground)
69
+ guard self.locationServicesEnabled() else {
70
+ throw self.facts.failure("Location services are required to verify the current Mac Wi-Fi network", raw: status.rawValue,
71
+ getter: .instanceCurrent, foreground: foreground)
72
+ }
73
+ guard Self.isAuthorized(status) else {
74
+ throw self.facts.failure("Location access is required to verify the current Mac Wi-Fi network", raw: status.rawValue,
75
+ getter: .instanceCurrent, foreground: foreground)
76
+ }
77
+ }
78
+ if Thread.isMainThread { try check() }
79
+ else { try DispatchQueue.main.sync(execute: check) }
31
80
  }
32
81
 
33
82
  func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
83
+ guard manager === self.manager, !closed else { return }
34
84
  handle(manager.authorizationStatus, getter: .instanceCallback)
35
85
  }
36
86
 
@@ -44,22 +94,31 @@ final class MacHotspotAuthorization: NSObject, CLLocationManagerDelegate {
44
94
 
45
95
  private func handle(_ status: CLAuthorizationStatus, getter: MacHotspotAuthorizationFacts.Getter) {
46
96
  let foreground = MacHotspotAuthorizationFacts.foreground()
47
- if !finished { facts.observeInstance(raw: status.rawValue, getter: getter, foreground: foreground) }
48
- if Self.isAuthorized(status) { finish(.success(())); return }
97
+ guard !closed else { return }
98
+ facts.observeInstance(raw: status.rawValue, getter: getter, foreground: foreground)
99
+ if Self.isAuthorized(status) {
100
+ ready = true
101
+ if revoked { revoked = false; onRestored?() }
102
+ reply(.success(()))
103
+ return
104
+ }
105
+ let error = facts.failure("Location access is required to identify the glasses hotspot on Mac", raw: status.rawValue, getter: getter, foreground: foreground)
106
+ if ready {
107
+ ready = false
108
+ revoked = true
109
+ onRevoked?(error)
110
+ return
111
+ }
49
112
  switch status {
50
113
  case .denied, .restricted:
51
- finish(.failure(facts.failure("Location access is required to identify the glasses hotspot on Mac", raw: status.rawValue, getter: getter, foreground: foreground)))
114
+ reply(.failure(error))
52
115
  case .notDetermined: break
53
116
  default:
54
- finish(.failure(facts.failure("Mac hotspot Location authorization is unavailable", raw: status.rawValue, getter: getter, foreground: foreground)))
117
+ reply(.failure(facts.failure("Mac hotspot Location authorization is unavailable", raw: status.rawValue, getter: getter, foreground: foreground)))
55
118
  }
56
119
  }
57
120
 
58
- private func finish(_ result: Result<Void, Error>) {
59
- guard !finished else { return }
60
- finished = true
61
- manager?.delegate = nil
62
- manager = nil
121
+ private func reply(_ result: Result<Void, Error>) {
63
122
  let reply = completion
64
123
  completion = nil
65
124
  reply?(result)
@@ -7,7 +7,7 @@ import Foundation
7
7
 
8
8
  /// Diagnostic observations only. These facts never authorize a network read or a join.
9
9
  final class MacHotspotAuthorizationFacts {
10
- enum Getter: String { case instanceInitial = "instance-initial", instanceCallback = "instance-callback", staticSSID = "static-ssid" }
10
+ enum Getter: String { case instanceInitial = "instance-initial", instanceCallback = "instance-callback", instanceCurrent = "instance-current", staticSSID = "static-ssid" }
11
11
  enum Foreground: String { case active, inactive, background, unknown }
12
12
  private let lock = NSLock()
13
13
  private var instance: (raw: Int32, getter: Getter, foreground: Foreground)?
@@ -0,0 +1,35 @@
1
+ import Foundation
2
+
3
+ /// Retain a real authorization restoration while this session's cleanup result
4
+ /// is still returning from its worker. All state belongs to the hotspot owner queue.
5
+ final class MacHotspotCleanupContinuation {
6
+ private var restorationPending = false
7
+ private let isCurrentCancelledSession: () -> Bool
8
+ private let requestCleanup: () -> Void
9
+
10
+ init(authorization: MacHotspotAuthorization, queue: DispatchQueue,
11
+ isCurrentCancelledSession: @escaping () -> Bool, requestCleanup: @escaping () -> Void)
12
+ {
13
+ self.isCurrentCancelledSession = isCurrentCancelledSession
14
+ self.requestCleanup = requestCleanup
15
+ authorization.onRestored = { [weak self] in
16
+ queue.async { [weak self] in
17
+ guard let self, self.isCurrentCancelledSession() else { return }
18
+ self.restorationPending = true
19
+ self.requestCleanup()
20
+ }
21
+ }
22
+ }
23
+
24
+ func cleanupStarted() {
25
+ restorationPending = false
26
+ }
27
+
28
+ func cleanupFailed() {
29
+ guard restorationPending, isCurrentCancelledSession() else { return }
30
+ restorationPending = false
31
+ // A current restoration was not consumed by the failed worker attempt.
32
+ // Resume only its owned cleanup, which rechecks live permission and SSID.
33
+ requestCleanup()
34
+ }
35
+ }
@@ -31,6 +31,7 @@ public final class GlassesHotspotNetwork {
31
31
  private let macWorker = DispatchQueue(label: "com.mentra.glassesmedia.mac-wifi")
32
32
  private var macAssociation: MacHotspotAssociation?
33
33
  private var macAuthorization: MacHotspotAuthorization?
34
+ private var macCleanupContinuation: MacHotspotCleanupContinuation?
34
35
  private var ownershipToken: UUID?
35
36
  private var finishingLeave = false
36
37
  private var waitForInternetAfterLeave = false
@@ -75,7 +76,7 @@ public final class GlassesHotspotNetwork {
75
76
  guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
76
77
  self.cancelled = true
77
78
  self.macAssociation?.cancel()
78
- self.macAuthorization?.cancel()
79
+ self.macAuthorization?.cancelPending()
79
80
  self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
80
81
  // apply() cannot be cancelled. Retain the reservation until its callback and remove the
81
82
  // late configuration before another call is permitted to acquire the network.
@@ -91,7 +92,7 @@ public final class GlassesHotspotNetwork {
91
92
  self.waitForInternetAfterLeave = self.waitForInternetAfterLeave || waitForInternet
92
93
  self.cancelled = true
93
94
  self.macAssociation?.cancel()
94
- self.macAuthorization?.cancel()
95
+ self.macAuthorization?.cancelPending()
95
96
  if self.leaveCompletion == nil {
96
97
  self.leaveCompletion = HotspotLeaveCompletion {
97
98
  if let token = self.ownershipToken { HotspotNetworkOwnership.shared.release(token) }
@@ -242,12 +243,30 @@ public final class GlassesHotspotNetwork {
242
243
  let facts = MacHotspotAuthorizationFacts()
243
244
  let authorization = MacHotspotAuthorization(facts: facts)
244
245
  macAuthorization = authorization
246
+ authorization.onRevoked = { [weak self] error in
247
+ guard let self else { return }
248
+ self.queue.async {
249
+ guard gen == self.generation, !self.cancelled else { return }
250
+ self.cancelled = true
251
+ self.macAssociation?.cancel()
252
+ self.finishJoin(.failure(error))
253
+ self.onLost?("Glasses hotspot Location authorization was revoked")
254
+ if !self.applying { self.finishLeave() }
255
+ }
256
+ }
257
+ macCleanupContinuation = MacHotspotCleanupContinuation(authorization: authorization, queue: queue,
258
+ isCurrentCancelledSession: { [weak self] in
259
+ guard let self else { return false }
260
+ return gen == self.generation && self.cancelled
261
+ }, requestCleanup: { [weak self] in
262
+ guard let self, !self.applying else { return }
263
+ self.finishLeave()
264
+ })
245
265
  authorization.start { result in
246
266
  self.queue.async {
247
267
  guard gen == self.generation, !self.cancelled else { return }
248
- self.macAuthorization = nil
249
268
  switch result {
250
- case .success: self.applyMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen, deadline: deadline, authorizationFacts: facts)
269
+ case .success: self.applyMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen, deadline: deadline, authorizationFacts: facts, authorization: authorization)
251
270
  case let .failure(error):
252
271
  self.finishJoin(.failure(error)); self.finishLeave()
253
272
  }
@@ -255,8 +274,8 @@ public final class GlassesHotspotNetwork {
255
274
  }
256
275
  }
257
276
 
258
- private func applyMacAssociation(ssid: String, passphrase: String, generation gen: Int, deadline: TimeInterval, authorizationFacts: MacHotspotAuthorizationFacts) {
259
- let association = MacHotspotAssociation(deadline: deadline, authorizationFacts: authorizationFacts)
277
+ private func applyMacAssociation(ssid: String, passphrase: String, generation gen: Int, deadline: TimeInterval, authorizationFacts: MacHotspotAuthorizationFacts, authorization: MacHotspotAuthorization) {
278
+ let association = MacHotspotAssociation(deadline: deadline, authorizationFacts: authorizationFacts, authorization: authorization)
260
279
  macAssociation = association
261
280
  applying = true
262
281
  macWorker.async {
@@ -457,6 +476,7 @@ public final class GlassesHotspotNetwork {
457
476
  private func finishLeave() {
458
477
  guard !finishingLeave else { return }
459
478
  finishingLeave = true
479
+ macCleanupContinuation?.cleanupStarted()
460
480
  if let association = macAssociation {
461
481
  association.cancel()
462
482
  // This queue is also the association queue: cleanup cannot overtake
@@ -475,6 +495,7 @@ public final class GlassesHotspotNetwork {
475
495
  let completion = self.leaveCompletion
476
496
  self.leaveCompletion = nil
477
497
  completion?.complete(.failure(error))
498
+ self.macCleanupContinuation?.cleanupFailed()
478
499
  }
479
500
  }
480
501
  }
@@ -483,6 +504,7 @@ public final class GlassesHotspotNetwork {
483
504
 
484
505
  private func completeLeave() {
485
506
  internetObservationOwner.cancel()
507
+ macCleanupContinuation = nil
486
508
  generation += 1
487
509
  macAuthorization?.cancel(); macAuthorization = nil
488
510
  localAccess?.cancel(); localAccess = nil
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.4.0-dev.975",
3
+ "version": "3.4.0-dev.978",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",