@mentra/glasses-media 3.3.0-dev.944 → 3.4.0-dev.1004

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/index.d.ts CHANGED
@@ -7,5 +7,5 @@ export const glassesHotspot: {
7
7
  gateway?: string,
8
8
  purpose?: "outgoing-client" | "incoming-server",
9
9
  ): Promise<string>
10
- leave(id: string): Promise<void>
10
+ leave(id: string, waitForInternet?: boolean): Promise<void>
11
11
  }
package/index.js CHANGED
@@ -6,5 +6,5 @@ const native = () => require("expo-modules-core").requireNativeModule("MentraGla
6
6
  export const glassesHotspot = Object.freeze({
7
7
  join: (id, ssid, password, gateway, purpose = "outgoing-client") =>
8
8
  native().joinLocalHotspot(id, ssid, password, gateway ?? null, purpose),
9
- leave: (id) => native().leaveLocalHotspot(id),
9
+ leave: (id, waitForInternet = false) => native().leaveLocalHotspot(id, waitForInternet),
10
10
  })
@@ -8,9 +8,106 @@ public enum HotspotInternetPolicy {
8
8
  if cellular { return "cellular" }
9
9
  if ethernet { return "ethernet" }
10
10
  // Callers supply a Wi-Fi SSID only after releasing the glasses hotspot.
11
- if let restoredWifiSSID, !restoredWifiSSID.isEmpty, restoredWifiSSID != glassesSSID {
11
+ if let restoredWifiSSID, let glassesSSID, !glassesSSID.isEmpty, !restoredWifiSSID.isEmpty, restoredWifiSSID != glassesSSID {
12
12
  return "wifi"
13
13
  }
14
14
  return nil
15
15
  }
16
16
  }
17
+
18
+ /// One queue-confined route observation. Late SSID callbacks cannot settle a
19
+ /// newer path or session, and timeout/cancellation settle this observation once.
20
+ public final class HotspotInternetObservation {
21
+ private let generation: Int
22
+ private let glassesSSID: String?
23
+ private var pathRevision = 0
24
+ private var finished = false
25
+ private var readingSSID = false
26
+ private var settledReply: (() -> Void)?
27
+
28
+ public init(generation: Int, glassesSSID: String?) {
29
+ self.generation = generation
30
+ self.glassesSSID = glassesSSID
31
+ }
32
+
33
+ public func beginPath(currentGeneration: Int) -> Int? {
34
+ guard !finished, currentGeneration == generation else { return nil }
35
+ pathRevision += 1
36
+ return pathRevision
37
+ }
38
+
39
+ public func restoredWifiRoute(ssid: String?, pathRevision: Int, currentGeneration: Int) -> String? {
40
+ guard !finished, currentGeneration == generation, pathRevision == self.pathRevision else { return nil }
41
+ return HotspotInternetPolicy.route(satisfied: true, cellular: false, ethernet: false,
42
+ restoredWifiSSID: ssid, glassesSSID: glassesSSID)
43
+ }
44
+
45
+ public func beginSSIDRead() -> Bool {
46
+ guard !finished, !readingSSID else { return false }
47
+ readingSSID = true
48
+ return true
49
+ }
50
+
51
+ public func completeSSIDRead() {
52
+ readingSSID = false
53
+ let reply = settledReply
54
+ settledReply = nil
55
+ reply?()
56
+ }
57
+
58
+ public func afterSSIDRead(_ reply: @escaping () -> Void) {
59
+ if readingSSID { settledReply = reply } else { reply() }
60
+ }
61
+
62
+ public func settle() -> Bool {
63
+ guard !finished else { return false }
64
+ finished = true
65
+ return true
66
+ }
67
+ }
68
+
69
+ /// Queue-confined cancellation owner. An old observation may finish joining its
70
+ /// native read after its replacement starts, but cannot clear that replacement.
71
+ public final class HotspotInternetObservationOwner {
72
+ private var current: (observation: HotspotInternetObservation, cancel: () -> Void)?
73
+
74
+ public init() {}
75
+
76
+ public func install(_ observation: HotspotInternetObservation, cancel: @escaping () -> Void) {
77
+ current?.cancel()
78
+ current = (observation, cancel)
79
+ }
80
+
81
+ public func clear(_ observation: HotspotInternetObservation) {
82
+ if current?.observation === observation { current = nil }
83
+ }
84
+
85
+ public func cancel() {
86
+ current?.cancel()
87
+ }
88
+ }
89
+
90
+ /// Queue-confined completion of one leave. Concurrent callers share cleanup;
91
+ /// the original ownership token is released before any caller can start again.
92
+ public final class HotspotLeaveCompletion {
93
+ private var replies: [(Result<Void, Error>) -> Void] = []
94
+ private var pending = true
95
+ private let release: () -> Void
96
+
97
+ public init(release: @escaping () -> Void) {
98
+ self.release = release
99
+ }
100
+
101
+ public func append(_ reply: @escaping (Result<Void, Error>) -> Void) {
102
+ replies.append(reply)
103
+ }
104
+
105
+ public func complete(_ result: Result<Void, Error>) {
106
+ guard pending else { return }
107
+ pending = false
108
+ if case .success = result { release() }
109
+ let current = replies
110
+ replies.removeAll()
111
+ current.forEach { $0(result) }
112
+ }
113
+ }
@@ -5,46 +5,64 @@ import ObjectiveC
5
5
  /// Blocking public CoreWLAN calls are isolated here; the existing hotspot owner
6
6
  /// retains custody until they return, even after cancellation or a timeout.
7
7
  final class MacHotspotAssociation {
8
- private let lock = NSLock()
8
+ private let condition = NSCondition()
9
9
  private var cancelled = false
10
10
  private var interface: MacWifiInterface?
11
11
  private var joinedSSID: String?
12
+ private let deadline: TimeInterval
13
+ private let now: () -> TimeInterval
14
+ private let pause: ((TimeInterval) -> Void)?
12
15
  private let makeInterface: () throws -> MacWifiInterface
13
-
14
- init(makeInterface: @escaping () throws -> MacWifiInterface = { try PublicCoreWlanInterface() }) {
15
- self.makeInterface = makeInterface
16
+ private let authorization: MacHotspotAuthorization?
17
+
18
+ init(deadline: TimeInterval = ProcessInfo.processInfo.systemUptime + 60,
19
+ now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },
20
+ pause: ((TimeInterval) -> Void)? = nil,
21
+ makeInterface: (() throws -> MacWifiInterface)? = nil,
22
+ authorizationFacts: MacHotspotAuthorizationFacts? = nil,
23
+ authorization: MacHotspotAuthorization? = nil)
24
+ {
25
+ self.deadline = deadline
26
+ self.now = now
27
+ self.pause = pause
28
+ self.authorization = authorization
29
+ self.makeInterface = makeInterface ?? { try PublicCoreWlanInterface(authorizationFacts: authorizationFacts, authorization: authorization) }
16
30
  }
17
31
 
18
32
  func associate(ssid: String, password: String) throws -> String {
19
33
  try checkCurrent()
20
34
  let interface = try makeInterface()
21
35
  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)
36
+ if try readSSID(interface) != ssid {
37
+ let network = try discoverNetwork(ssid: ssid, interface: interface)
38
+ try checkDiscoveryCurrent()
39
+ if let network, try readSSID(interface) != ssid {
40
+ try checkDiscoveryCurrent()
41
+ try authorization?.requireCurrentAuthorization()
42
+ // Record the intended association before this blocking call. A late
43
+ // successful join still belongs to us and must be cleaned up.
44
+ joinedSSID = ssid
45
+ try interface.associate(network: network, password: password)
46
+ }
29
47
  }
30
48
  try checkCurrent()
31
- guard try interface.currentSSID() == ssid else {
49
+ guard try readSSID(interface) == ssid else {
32
50
  throw LocalMediaError("Mac Wi-Fi did not associate with the requested glasses hotspot")
33
51
  }
34
52
  return try interface.name()
35
53
  }
36
54
 
37
55
  func cancel() {
38
- lock.lock(); cancelled = true; lock.unlock()
56
+ condition.lock(); cancelled = true; condition.broadcast(); condition.unlock()
39
57
  }
40
58
 
41
59
  /// Run on the same worker as association, after its blocking call returns.
42
60
  /// A connection the user replaced must never be disconnected by our cleanup.
43
61
  func leave() throws {
44
62
  if let joinedSSID {
45
- if try interface?.currentSSID() == joinedSSID {
46
- interface?.disassociate()
47
- guard try interface?.currentSSID() != joinedSSID else {
63
+ if let interface, try readSSID(interface) == joinedSSID {
64
+ interface.disassociate()
65
+ guard try readSSID(interface) != joinedSSID else {
48
66
  throw LocalMediaError("Mac Wi-Fi has not released the glasses hotspot")
49
67
  }
50
68
  }
@@ -54,11 +72,52 @@ final class MacHotspotAssociation {
54
72
  }
55
73
 
56
74
  func currentSSID() throws -> String? {
57
- 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
84
+ }
85
+
86
+ static func observeCurrentSSID() throws -> String? {
87
+ try PublicCoreWlanInterface().currentSSID()
88
+ }
89
+
90
+ private func discoverNetwork(ssid: String, interface: MacWifiInterface) throws -> NSObject? {
91
+ while true {
92
+ try checkDiscoveryCurrent()
93
+ // Recheck Location authorization before and after each blocking scan.
94
+ if try readSSID(interface) == ssid { return nil }
95
+ try authorization?.requireCurrentAuthorization()
96
+ let network = try interface.network(ssid: ssid)
97
+ try checkDiscoveryCurrent()
98
+ if try readSSID(interface) == ssid { return nil }
99
+ if let network { return network }
100
+ // Successful empty scans remain pending within this same join window.
101
+ // Native scan errors escape immediately; association is never retried.
102
+ let remaining = max(0, deadline - now())
103
+ if let pause { pause(min(1, remaining)) }
104
+ else {
105
+ condition.lock()
106
+ if !cancelled { _ = condition.wait(until: Date(timeIntervalSinceNow: min(1, remaining))) }
107
+ condition.unlock()
108
+ }
109
+ }
110
+ }
111
+
112
+ private func checkDiscoveryCurrent() throws {
113
+ try checkCurrent()
114
+ guard now() < deadline else {
115
+ throw LocalMediaError("The requested glasses hotspot was not found by Mac Wi-Fi before the hotspot join timed out")
116
+ }
58
117
  }
59
118
 
60
119
  private func checkCurrent() throws {
61
- lock.lock(); let stopped = cancelled; lock.unlock()
120
+ condition.lock(); let stopped = cancelled; condition.unlock()
62
121
  if stopped { throw CancellationError() }
63
122
  }
64
123
  }
@@ -66,7 +125,7 @@ final class MacHotspotAssociation {
66
125
  protocol MacWifiInterface: AnyObject {
67
126
  func name() throws -> String
68
127
  func currentSSID() throws -> String?
69
- func network(ssid: String) throws -> NSObject
128
+ func network(ssid: String) throws -> NSObject?
70
129
  func associate(network: NSObject, password: String) throws
71
130
  func disassociate()
72
131
  }
@@ -84,9 +143,13 @@ enum MacWifiInvocation {
84
143
  private final class PublicCoreWlanInterface: MacWifiInterface {
85
144
  private let bundle: Bundle
86
145
  private let interface: NSObject
146
+ private let authorizationFacts: MacHotspotAuthorizationFacts
147
+ private let authorization: MacHotspotAuthorization?
87
148
  private typealias Disassociate = @convention(c) (AnyObject, Selector) -> Void
88
149
 
89
- init() throws {
150
+ init(authorizationFacts: MacHotspotAuthorizationFacts? = nil, authorization: MacHotspotAuthorization? = nil) throws {
151
+ self.authorizationFacts = authorizationFacts ?? MacHotspotAuthorizationFacts()
152
+ self.authorization = authorization
90
153
  guard let bundle = Bundle(path: "/System/Library/Frameworks/CoreWLAN.framework") else {
91
154
  throw LocalMediaError("Mac Wi-Fi framework is unavailable")
92
155
  }
@@ -114,15 +177,24 @@ private final class PublicCoreWlanInterface: MacWifiInterface {
114
177
  }
115
178
 
116
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.
117
189
  let authorization = CLLocationManager.authorizationStatus()
118
190
  guard MacHotspotAuthorization.isAuthorized(authorization) else {
119
- throw LocalMediaError("Location access is required to verify the current Mac Wi-Fi network")
191
+ throw authorizationFacts.failure("Location access is required to verify the current Mac Wi-Fi network", raw: authorization.rawValue,
192
+ getter: .staticSSID, foreground: MacHotspotAuthorizationFacts.foreground())
120
193
  }
121
- guard let data = interface.perform(NSSelectorFromString("ssidData"))?.takeUnretainedValue() as? Data else { return nil }
122
- return String(data: data, encoding: .utf8)
123
194
  }
124
195
 
125
- func network(ssid: String) throws -> NSObject {
196
+ func network(ssid: String) throws -> NSObject? {
197
+ try requireAuthorization()
126
198
  let selector = NSSelectorFromString("scanForNetworksWithSSID:error:")
127
199
  let invoke = unsafeBitCast(interface.method(for: selector), to: MacWifiInvocation.Scan.self)
128
200
  var error: NSError?
@@ -137,10 +209,11 @@ private final class PublicCoreWlanInterface: MacWifiInterface {
137
209
  data == Data(ssid.utf8) else { continue }
138
210
  return network
139
211
  }
140
- throw LocalMediaError("The requested glasses hotspot was not found by Mac Wi-Fi")
212
+ return nil
141
213
  }
142
214
 
143
215
  func associate(network: NSObject, password: String) throws {
216
+ try requireAuthorization()
144
217
  let selector = NSSelectorFromString("associateToNetwork:password:error:")
145
218
  let invoke = unsafeBitCast(interface.method(for: selector), to: MacWifiInvocation.Associate.self)
146
219
  var error: NSError?
@@ -6,26 +6,82 @@ 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
13
+ private let facts: MacHotspotAuthorizationFacts
14
+ private let makeManager: () -> CLLocationManager
15
+ private let locationServicesEnabled: () -> Bool
16
+ var onRevoked: ((Error) -> Void)?
17
+ var onRestored: (() -> Void)?
18
+
19
+ init(facts: MacHotspotAuthorizationFacts = MacHotspotAuthorizationFacts(), makeManager: @escaping () -> CLLocationManager = { CLLocationManager() },
20
+ locationServicesEnabled: @escaping () -> Bool = { CLLocationManager.locationServicesEnabled() })
21
+ {
22
+ self.facts = facts
23
+ self.makeManager = makeManager
24
+ self.locationServicesEnabled = locationServicesEnabled
25
+ super.init()
26
+ }
10
27
 
11
28
  func start(completion: @escaping (Result<Void, Error>) -> Void) {
12
29
  DispatchQueue.main.async {
13
- guard !self.finished else { completion(.failure(CancellationError())); return }
30
+ guard !self.closed, !self.started else { completion(.failure(CancellationError())); return }
31
+ self.started = true
14
32
  self.completion = completion
15
- let manager = CLLocationManager()
33
+ let manager = self.makeManager()
16
34
  self.manager = manager
17
35
  manager.delegate = self
18
- self.handle(manager.authorizationStatus)
36
+ self.handle(manager.authorizationStatus, getter: .instanceInitial)
19
37
  if manager.authorizationStatus == .notDetermined { manager.requestWhenInUseAuthorization() }
20
38
  }
21
39
  }
22
40
 
23
41
  func cancel() {
24
- 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) }
25
80
  }
26
81
 
27
82
  func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
28
- handle(manager.authorizationStatus)
83
+ guard manager === self.manager, !closed else { return }
84
+ handle(manager.authorizationStatus, getter: .instanceCallback)
29
85
  }
30
86
 
31
87
  static func isAuthorized(_ status: CLAuthorizationStatus) -> Bool {
@@ -36,22 +92,33 @@ final class MacHotspotAuthorization: NSObject, CLLocationManagerDelegate {
36
92
  return false
37
93
  }
38
94
 
39
- private func handle(_ status: CLAuthorizationStatus) {
40
- if Self.isAuthorized(status) { finish(.success(())); return }
95
+ private func handle(_ status: CLAuthorizationStatus, getter: MacHotspotAuthorizationFacts.Getter) {
96
+ let foreground = MacHotspotAuthorizationFacts.foreground()
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
+ }
41
112
  switch status {
42
113
  case .denied, .restricted:
43
- finish(.failure(LocalMediaError("Location access is required to identify the glasses hotspot on Mac")))
114
+ reply(.failure(error))
44
115
  case .notDetermined: break
45
116
  default:
46
- finish(.failure(LocalMediaError("Mac hotspot Location authorization is unavailable")))
117
+ reply(.failure(facts.failure("Mac hotspot Location authorization is unavailable", raw: status.rawValue, getter: getter, foreground: foreground)))
47
118
  }
48
119
  }
49
120
 
50
- private func finish(_ result: Result<Void, Error>) {
51
- guard !finished else { return }
52
- finished = true
53
- manager?.delegate = nil
54
- manager = nil
121
+ private func reply(_ result: Result<Void, Error>) {
55
122
  let reply = completion
56
123
  completion = nil
57
124
  reply?(result)
@@ -0,0 +1,51 @@
1
+ import Foundation
2
+ #if canImport(UIKit)
3
+ import UIKit
4
+ #elseif canImport(AppKit)
5
+ import AppKit
6
+ #endif
7
+
8
+ /// Diagnostic observations only. These facts never authorize a network read or a join.
9
+ final class MacHotspotAuthorizationFacts {
10
+ enum Getter: String { case instanceInitial = "instance-initial", instanceCallback = "instance-callback", instanceCurrent = "instance-current", staticSSID = "static-ssid" }
11
+ enum Foreground: String { case active, inactive, background, unknown }
12
+ private let lock = NSLock()
13
+ private var instance: (raw: Int32, getter: Getter, foreground: Foreground)?
14
+ private var initialCallbackObserved = false
15
+ private let pid = ProcessInfo.processInfo.processIdentifier
16
+ private let bundleID = Bundle.main.bundleIdentifier.flatMap { value in
17
+ value.range(of: "^[A-Za-z0-9_.-]{1,128}$", options: .regularExpression) == nil ? nil : value
18
+ } ?? "unknown"
19
+
20
+ /// UIKit/AppKit state is sampled only on its owning thread. A worker read stays unknown.
21
+ static func foreground() -> Foreground {
22
+ guard Thread.isMainThread else { return .unknown }
23
+ #if canImport(UIKit)
24
+ switch UIApplication.shared.applicationState {
25
+ case .active: return .active
26
+ case .inactive: return .inactive
27
+ case .background: return .background
28
+ @unknown default: return .unknown
29
+ }
30
+ #elseif canImport(AppKit)
31
+ return NSApplication.shared.isActive ? .active : .inactive
32
+ #else
33
+ return .unknown
34
+ #endif
35
+ }
36
+
37
+ func observeInstance(raw: Int32, getter: Getter, foreground: Foreground) {
38
+ lock.lock(); defer { lock.unlock() }
39
+ instance = (raw, getter, foreground)
40
+ if getter == .instanceCallback { initialCallbackObserved = true }
41
+ }
42
+
43
+ /// Only enums and this process's identity cross the existing bounded error bridge.
44
+ func failure(_ message: String, raw: Int32, getter: Getter, foreground: Foreground) -> LocalMediaError {
45
+ lock.lock(); defer { lock.unlock() }
46
+ let instanceRaw = instance.map { String($0.raw) } ?? "unknown"
47
+ let instanceGetter = instance?.getter.rawValue ?? "unknown"
48
+ let instanceForeground = instance?.foreground.rawValue ?? "unknown"
49
+ return LocalMediaError("\(message) [location raw=\(raw) getter=\(getter.rawValue) foreground=\(foreground.rawValue) instanceRaw=\(instanceRaw) instanceGetter=\(instanceGetter) instanceForeground=\(instanceForeground) initialCallbackObserved=\(initialCallbackObserved) pid=\(pid) bundle=\(bundleID)]")
50
+ }
51
+ }
@@ -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
+ }
@@ -19,7 +19,7 @@ public final class GlassesHotspotNetwork {
19
19
  private var ownsConfiguration = false
20
20
  private var cancelled = false
21
21
  private var joinReply: ((Result<String, Error>) -> Void)?
22
- private var leaveReplies: [(Result<Void, Error>) -> Void] = []
22
+ private var leaveCompletion: HotspotLeaveCompletion?
23
23
  private var interfaceMonitors: [NWPathMonitor] = []
24
24
  /// Wi-Fi identity learned since this join started; replaced on every join and cleared on leave.
25
25
  private var wifiIdentity = HotspotWifiIdentity()
@@ -31,8 +31,11 @@ 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
37
+ private var waitForInternetAfterLeave = false
38
+ private let internetObservationOwner = HotspotInternetObservationOwner()
36
39
  public var onLost: ((String) -> Void)?
37
40
  public var onPermissionRequired: (() -> Void)?
38
41
  public init() {}
@@ -52,6 +55,7 @@ public final class GlassesHotspotNetwork {
52
55
  self.ownershipToken = token
53
56
  self.generation += 1
54
57
  let gen = self.generation
58
+ let deadline = ProcessInfo.processInfo.systemUptime + 60
55
59
  self.ssid = ssid
56
60
  self.lastHotspotSSID = ssid
57
61
  self.gatewayAddress = gateway
@@ -62,17 +66,17 @@ public final class GlassesHotspotNetwork {
62
66
  self.joinReply = completion
63
67
  self.startInterfaceMonitors(generation: gen)
64
68
  if ProcessInfo.processInfo.isiOSAppOnMac {
65
- self.authorizeMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
69
+ self.authorizeMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen, deadline: deadline)
66
70
  } else {
67
71
  self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
68
72
  }
69
- self.queue.asyncAfter(deadline: .now() + 60) {
73
+ self.queue.asyncAfter(deadline: .now() + max(0, deadline - ProcessInfo.processInfo.systemUptime)) {
70
74
  // Once associated, the user may still be reading the Local Network alert.
71
75
  // Its response time is not a hotspot association timeout.
72
76
  guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
73
77
  self.cancelled = true
74
78
  self.macAssociation?.cancel()
75
- self.macAuthorization?.cancel()
79
+ self.macAuthorization?.cancelPending()
76
80
  self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
77
81
  // apply() cannot be cancelled. Retain the reservation until its callback and remove the
78
82
  // late configuration before another call is permitted to acquire the network.
@@ -82,12 +86,20 @@ public final class GlassesHotspotNetwork {
82
86
  }
83
87
  }
84
88
 
85
- public func leave(completion: @escaping (Result<Void, Error>) -> Void) {
89
+ public func leave(waitForInternet: Bool = false, completion: @escaping (Result<Void, Error>) -> Void) {
86
90
  queue.async {
91
+ if !waitForInternet { self.internetObservationOwner.cancel() }
92
+ self.waitForInternetAfterLeave = self.waitForInternetAfterLeave || waitForInternet
87
93
  self.cancelled = true
88
94
  self.macAssociation?.cancel()
89
- self.macAuthorization?.cancel()
90
- self.leaveReplies.append(completion)
95
+ self.macAuthorization?.cancelPending()
96
+ if self.leaveCompletion == nil {
97
+ self.leaveCompletion = HotspotLeaveCompletion {
98
+ if let token = self.ownershipToken { HotspotNetworkOwnership.shared.release(token) }
99
+ self.ownershipToken = nil
100
+ }
101
+ }
102
+ self.leaveCompletion?.append(completion)
91
103
  self.finishJoin(.failure(LocalMediaError("Hotspot join cancelled")))
92
104
  if let ssid = self.ssid, self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
93
105
  if !self.applying { self.finishLeave() }
@@ -157,52 +169,104 @@ public final class GlassesHotspotNetwork {
157
169
  }
158
170
 
159
171
  public func awaitInternet(allowWifiAfterRelease: Bool = false, completion: @escaping (Bool, String) -> Void) {
160
- queue.async {
161
- let monitor = NWPathMonitor()
162
- var finished = false
163
- let finish: (Bool, String) -> Void = { usable, detail in
164
- guard !finished else { return }
165
- finished = true
166
- monitor.cancel()
172
+ queue.async { self.startInternetObservation(allowWifiAfterRelease: allowWifiAfterRelease, completion: completion) }
173
+ }
174
+
175
+ /// Called on the owner queue, including directly from completeLeave. A queued
176
+ /// cancellation therefore cannot overtake observation initialization.
177
+ private func startInternetObservation(allowWifiAfterRelease: Bool, completion: @escaping (Bool, String) -> Void) {
178
+ let gen = generation
179
+ let observation = HotspotInternetObservation(generation: gen, glassesSSID: lastHotspotSSID)
180
+ let monitor = NWPathMonitor()
181
+ let sampleTimer = DispatchSource.makeTimerSource(queue: queue)
182
+ var currentPath: Network.NWPath?
183
+ let finish: (Bool, String) -> Void = { usable, detail in
184
+ guard observation.settle() else { return }
185
+ monitor.pathUpdateHandler = nil
186
+ monitor.cancel()
187
+ sampleTimer.setEventHandler {}
188
+ sampleTimer.cancel()
189
+ currentPath = nil
190
+ let reply = {
191
+ self.internetObservationOwner.clear(observation)
167
192
  completion(usable, detail)
168
193
  }
169
- monitor.pathUpdateHandler = { path in
170
- if let route = HotspotInternetPolicy.route(satisfied: path.status == .satisfied,
171
- cellular: path.usesInterfaceType(.cellular),
172
- ethernet: path.usesInterfaceType(.wiredEthernet))
173
- {
194
+ // Even a timed-out/cancelled observation joins its dispatched
195
+ // CoreWLAN read before releasing this session's owner.
196
+ observation.afterSSIDRead(reply)
197
+ }
198
+ internetObservationOwner.install(observation) { finish(false, "Hotspot internet observation cancelled") }
199
+ var currentPathRevision = 0
200
+ let sample = {
201
+ guard let path = currentPath else { return }
202
+ if let route = HotspotInternetPolicy.route(satisfied: path.status == .satisfied,
203
+ cellular: path.usesInterfaceType(.cellular),
204
+ ethernet: path.usesInterfaceType(.wiredEthernet))
205
+ {
206
+ finish(true, route)
207
+ return
208
+ }
209
+ // Sample returned membership while the route remains on Wi-Fi;
210
+ // NWPath need not report a new interface when the SSID changes.
211
+ guard allowWifiAfterRelease, path.status == .satisfied,
212
+ path.usesInterfaceType(.wifi), observation.beginSSIDRead() else { return }
213
+ let pathRevision = currentPathRevision
214
+ let observed: (String?) -> Void = { ssid in
215
+ self.queue.async {
216
+ observation.completeSSIDRead()
217
+ guard gen == self.generation else { finish(false, "Hotspot session changed"); return }
218
+ guard let route = observation.restoredWifiRoute(ssid: ssid, pathRevision: pathRevision, currentGeneration: self.generation)
219
+ else { return }
174
220
  finish(true, route)
175
- return
176
221
  }
177
- // Once the hotspot is released, a return to the user's Wi-Fi is also valid.
178
- // Do not mistake the departing glasses AP's local-only path for restored internet.
179
- if allowWifiAfterRelease, path.status == .satisfied, path.usesInterfaceType(.wifi) {
180
- NEHotspotNetwork.fetchCurrent { network in
181
- self.queue.async {
182
- guard let route = HotspotInternetPolicy.route(satisfied: true, cellular: false, ethernet: false,
183
- restoredWifiSSID: network?.ssid, glassesSSID: self.lastHotspotSSID)
184
- else { return }
185
- finish(true, route)
186
- }
187
- }
188
- }
189
- }
190
- monitor.start(queue: self.queue)
191
- self.queue.asyncAfter(deadline: .now() + 15) {
192
- finish(false, allowWifiAfterRelease ? "Internet did not return after leaving the glasses hotspot" : "Cellular or Ethernet internet did not become the default route")
193
222
  }
223
+ if ProcessInfo.processInfo.isiOSAppOnMac {
224
+ self.macWorker.async { observed(try? MacHotspotAssociation.observeCurrentSSID()) }
225
+ } else { NEHotspotNetwork.fetchCurrent { observed($0?.ssid) } }
226
+ }
227
+ monitor.pathUpdateHandler = { path in
228
+ guard let pathRevision = observation.beginPath(currentGeneration: self.generation) else { finish(false, "Hotspot session changed"); return }
229
+ currentPath = path
230
+ currentPathRevision = pathRevision
231
+ sample()
232
+ }
233
+ sampleTimer.setEventHandler(handler: sample)
234
+ sampleTimer.schedule(deadline: .now() + 0.5, repeating: 0.5)
235
+ sampleTimer.resume()
236
+ monitor.start(queue: queue)
237
+ queue.asyncAfter(deadline: .now() + 15) {
238
+ finish(false, allowWifiAfterRelease ? "Internet did not return after leaving the glasses hotspot" : "Cellular or Ethernet internet did not become the default route")
194
239
  }
195
240
  }
196
241
 
197
- private func authorizeMacAssociation(ssid: String, passphrase: String, generation gen: Int) {
198
- let authorization = MacHotspotAuthorization()
242
+ private func authorizeMacAssociation(ssid: String, passphrase: String, generation gen: Int, deadline: TimeInterval) {
243
+ let facts = MacHotspotAuthorizationFacts()
244
+ let authorization = MacHotspotAuthorization(facts: facts)
199
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
+ })
200
265
  authorization.start { result in
201
266
  self.queue.async {
202
267
  guard gen == self.generation, !self.cancelled else { return }
203
- self.macAuthorization = nil
204
268
  switch result {
205
- case .success: self.applyMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
269
+ case .success: self.applyMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen, deadline: deadline, authorizationFacts: facts, authorization: authorization)
206
270
  case let .failure(error):
207
271
  self.finishJoin(.failure(error)); self.finishLeave()
208
272
  }
@@ -210,8 +274,8 @@ public final class GlassesHotspotNetwork {
210
274
  }
211
275
  }
212
276
 
213
- private func applyMacAssociation(ssid: String, passphrase: String, generation gen: Int) {
214
- let association = MacHotspotAssociation()
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)
215
279
  macAssociation = association
216
280
  applying = true
217
281
  macWorker.async {
@@ -412,6 +476,7 @@ public final class GlassesHotspotNetwork {
412
476
  private func finishLeave() {
413
477
  guard !finishingLeave else { return }
414
478
  finishingLeave = true
479
+ macCleanupContinuation?.cleanupStarted()
415
480
  if let association = macAssociation {
416
481
  association.cancel()
417
482
  // This queue is also the association queue: cleanup cannot overtake
@@ -427,9 +492,10 @@ public final class GlassesHotspotNetwork {
427
492
  // Retain the owner when we cannot prove which association
428
493
  // cleanup would disconnect. A later leave can retry safely.
429
494
  self.finishingLeave = false
430
- let replies = self.leaveReplies
431
- self.leaveReplies.removeAll()
432
- replies.forEach { $0(.failure(error)) }
495
+ let completion = self.leaveCompletion
496
+ self.leaveCompletion = nil
497
+ completion?.complete(.failure(error))
498
+ self.macCleanupContinuation?.cleanupFailed()
433
499
  }
434
500
  }
435
501
  }
@@ -437,6 +503,8 @@ public final class GlassesHotspotNetwork {
437
503
  }
438
504
 
439
505
  private func completeLeave() {
506
+ internetObservationOwner.cancel()
507
+ macCleanupContinuation = nil
440
508
  generation += 1
441
509
  macAuthorization?.cancel(); macAuthorization = nil
442
510
  localAccess?.cancel(); localAccess = nil
@@ -448,12 +516,25 @@ public final class GlassesHotspotNetwork {
448
516
  ssid = nil
449
517
  binding = nil
450
518
  gatewayAddress = nil
451
- finishingLeave = false
452
- if let token = ownershipToken { HotspotNetworkOwnership.shared.release(token) }
453
- ownershipToken = nil
454
- let replies = leaveReplies
455
- leaveReplies.removeAll()
456
- replies.forEach { $0(.success(())) }
519
+ let finish = {
520
+ self.waitForInternetAfterLeave = false
521
+ self.finishingLeave = false
522
+ let completion = self.leaveCompletion
523
+ self.leaveCompletion = nil
524
+ if let completion { completion.complete(.success(())) } else {
525
+ if let token = self.ownershipToken { HotspotNetworkOwnership.shared.release(token) }
526
+ self.ownershipToken = nil
527
+ }
528
+ }
529
+ if waitForInternetAfterLeave, ProcessInfo.processInfo.isiOSAppOnMac {
530
+ // Keep this owner until the default route returns or the existing
531
+ // bounded observation ends. Route readiness is not HTTP success;
532
+ // the following ordinary update check still owns that result.
533
+ startInternetObservation(allowWifiAfterRelease: true) { ready, reason in
534
+ self.logger.info("HOTSPOT_LEAVE internet_recovery ready=\(ready) reason=\(reason, privacy: .public)")
535
+ finish()
536
+ }
537
+ } else { finish() }
457
538
  }
458
539
 
459
540
  /// Every attached interface's kernel index and link type (its AF_LINK row) and every IPv4
@@ -31,10 +31,10 @@ public final class GlassesMediaRelayModule: Module {
31
31
  }
32
32
  }
33
33
 
34
- AsyncFunction("leaveLocalHotspot") { (id: String, promise: Promise) in
34
+ AsyncFunction("leaveLocalHotspot") { (id: String, waitForInternet: Bool, promise: Promise) in
35
35
  self.queue.async {
36
36
  guard self.localHotspotOwner == id else { promise.resolve(nil); return }
37
- self.localHotspot.leave { result in
37
+ self.localHotspot.leave(waitForInternet: waitForInternet) { result in
38
38
  self.queue.async {
39
39
  switch result {
40
40
  case .success:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.3.0-dev.944",
3
+ "version": "3.4.0-dev.1004",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",