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

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
+ }
@@ -0,0 +1,26 @@
1
+ import Foundation
2
+
3
+ /// When to ask iOS again to join the glasses hotspot.
4
+ ///
5
+ /// iOS accepts a hotspot configuration and then makes one short directed scan for it. When that
6
+ /// scan misses the glasses' beacon, iOS shows "Unable to join the network", stays on the current
7
+ /// Wi-Fi and still reports the configuration as applied. Only the phone's current network shows
8
+ /// that the join failed, so a request that has not put the phone on the hotspot after a few
9
+ /// seconds is made again, a bounded number of times, within the join's overall deadline.
10
+ public struct HotspotReapplyPolicy {
11
+ /// Polls of the current network, half a second apart, before a request counts as missed.
12
+ public let pollsBeforeReapply: Int
13
+ /// Requests in one join, the first included.
14
+ public let maxApplies: Int
15
+
16
+ public init(pollsBeforeReapply: Int = 16, maxApplies: Int = 3) {
17
+ self.pollsBeforeReapply = pollsBeforeReapply
18
+ self.maxApplies = maxApplies
19
+ }
20
+
21
+ /// Whether to request the join again, with the phone not on the hotspot after `pollsSinceApply`
22
+ /// polls of the `applies`-th request.
23
+ public func shouldReapply(applies: Int, pollsSinceApply: Int) -> Bool {
24
+ applies < maxApplies && pollsSinceApply >= pollsBeforeReapply
25
+ }
26
+ }
@@ -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
+ }
@@ -17,9 +17,11 @@ public final class GlassesHotspotNetwork {
17
17
  private var generation = 0
18
18
  private var applying = false
19
19
  private var ownsConfiguration = false
20
+ private var applies = 0
21
+ private let reapplyPolicy = HotspotReapplyPolicy()
20
22
  private var cancelled = false
21
23
  private var joinReply: ((Result<String, Error>) -> Void)?
22
- private var leaveReplies: [(Result<Void, Error>) -> Void] = []
24
+ private var leaveCompletion: HotspotLeaveCompletion?
23
25
  private var interfaceMonitors: [NWPathMonitor] = []
24
26
  /// Wi-Fi identity learned since this join started; replaced on every join and cleared on leave.
25
27
  private var wifiIdentity = HotspotWifiIdentity()
@@ -31,8 +33,11 @@ public final class GlassesHotspotNetwork {
31
33
  private let macWorker = DispatchQueue(label: "com.mentra.glassesmedia.mac-wifi")
32
34
  private var macAssociation: MacHotspotAssociation?
33
35
  private var macAuthorization: MacHotspotAuthorization?
36
+ private var macCleanupContinuation: MacHotspotCleanupContinuation?
34
37
  private var ownershipToken: UUID?
35
38
  private var finishingLeave = false
39
+ private var waitForInternetAfterLeave = false
40
+ private let internetObservationOwner = HotspotInternetObservationOwner()
36
41
  public var onLost: ((String) -> Void)?
37
42
  public var onPermissionRequired: (() -> Void)?
38
43
  public init() {}
@@ -52,6 +57,7 @@ public final class GlassesHotspotNetwork {
52
57
  self.ownershipToken = token
53
58
  self.generation += 1
54
59
  let gen = self.generation
60
+ let deadline = ProcessInfo.processInfo.systemUptime + 60
55
61
  self.ssid = ssid
56
62
  self.lastHotspotSSID = ssid
57
63
  self.gatewayAddress = gateway
@@ -59,20 +65,21 @@ public final class GlassesHotspotNetwork {
59
65
  self.cancelled = false
60
66
  self.applying = false
61
67
  self.ownsConfiguration = false
68
+ self.applies = 0
62
69
  self.joinReply = completion
63
70
  self.startInterfaceMonitors(generation: gen)
64
71
  if ProcessInfo.processInfo.isiOSAppOnMac {
65
- self.authorizeMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
72
+ self.authorizeMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen, deadline: deadline)
66
73
  } else {
67
74
  self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
68
75
  }
69
- self.queue.asyncAfter(deadline: .now() + 60) {
76
+ self.queue.asyncAfter(deadline: .now() + max(0, deadline - ProcessInfo.processInfo.systemUptime)) {
70
77
  // Once associated, the user may still be reading the Local Network alert.
71
78
  // Its response time is not a hotspot association timeout.
72
79
  guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
73
80
  self.cancelled = true
74
81
  self.macAssociation?.cancel()
75
- self.macAuthorization?.cancel()
82
+ self.macAuthorization?.cancelPending()
76
83
  self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
77
84
  // apply() cannot be cancelled. Retain the reservation until its callback and remove the
78
85
  // late configuration before another call is permitted to acquire the network.
@@ -82,12 +89,20 @@ public final class GlassesHotspotNetwork {
82
89
  }
83
90
  }
84
91
 
85
- public func leave(completion: @escaping (Result<Void, Error>) -> Void) {
92
+ public func leave(waitForInternet: Bool = false, completion: @escaping (Result<Void, Error>) -> Void) {
86
93
  queue.async {
94
+ if !waitForInternet { self.internetObservationOwner.cancel() }
95
+ self.waitForInternetAfterLeave = self.waitForInternetAfterLeave || waitForInternet
87
96
  self.cancelled = true
88
97
  self.macAssociation?.cancel()
89
- self.macAuthorization?.cancel()
90
- self.leaveReplies.append(completion)
98
+ self.macAuthorization?.cancelPending()
99
+ if self.leaveCompletion == nil {
100
+ self.leaveCompletion = HotspotLeaveCompletion {
101
+ if let token = self.ownershipToken { HotspotNetworkOwnership.shared.release(token) }
102
+ self.ownershipToken = nil
103
+ }
104
+ }
105
+ self.leaveCompletion?.append(completion)
91
106
  self.finishJoin(.failure(LocalMediaError("Hotspot join cancelled")))
92
107
  if let ssid = self.ssid, self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
93
108
  if !self.applying { self.finishLeave() }
@@ -157,52 +172,104 @@ public final class GlassesHotspotNetwork {
157
172
  }
158
173
 
159
174
  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()
175
+ queue.async { self.startInternetObservation(allowWifiAfterRelease: allowWifiAfterRelease, completion: completion) }
176
+ }
177
+
178
+ /// Called on the owner queue, including directly from completeLeave. A queued
179
+ /// cancellation therefore cannot overtake observation initialization.
180
+ private func startInternetObservation(allowWifiAfterRelease: Bool, completion: @escaping (Bool, String) -> Void) {
181
+ let gen = generation
182
+ let observation = HotspotInternetObservation(generation: gen, glassesSSID: lastHotspotSSID)
183
+ let monitor = NWPathMonitor()
184
+ let sampleTimer = DispatchSource.makeTimerSource(queue: queue)
185
+ var currentPath: Network.NWPath?
186
+ let finish: (Bool, String) -> Void = { usable, detail in
187
+ guard observation.settle() else { return }
188
+ monitor.pathUpdateHandler = nil
189
+ monitor.cancel()
190
+ sampleTimer.setEventHandler {}
191
+ sampleTimer.cancel()
192
+ currentPath = nil
193
+ let reply = {
194
+ self.internetObservationOwner.clear(observation)
167
195
  completion(usable, detail)
168
196
  }
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
- {
197
+ // Even a timed-out/cancelled observation joins its dispatched
198
+ // CoreWLAN read before releasing this session's owner.
199
+ observation.afterSSIDRead(reply)
200
+ }
201
+ internetObservationOwner.install(observation) { finish(false, "Hotspot internet observation cancelled") }
202
+ var currentPathRevision = 0
203
+ let sample = {
204
+ guard let path = currentPath else { return }
205
+ if let route = HotspotInternetPolicy.route(satisfied: path.status == .satisfied,
206
+ cellular: path.usesInterfaceType(.cellular),
207
+ ethernet: path.usesInterfaceType(.wiredEthernet))
208
+ {
209
+ finish(true, route)
210
+ return
211
+ }
212
+ // Sample returned membership while the route remains on Wi-Fi;
213
+ // NWPath need not report a new interface when the SSID changes.
214
+ guard allowWifiAfterRelease, path.status == .satisfied,
215
+ path.usesInterfaceType(.wifi), observation.beginSSIDRead() else { return }
216
+ let pathRevision = currentPathRevision
217
+ let observed: (String?) -> Void = { ssid in
218
+ self.queue.async {
219
+ observation.completeSSIDRead()
220
+ guard gen == self.generation else { finish(false, "Hotspot session changed"); return }
221
+ guard let route = observation.restoredWifiRoute(ssid: ssid, pathRevision: pathRevision, currentGeneration: self.generation)
222
+ else { return }
174
223
  finish(true, route)
175
- return
176
224
  }
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
225
  }
226
+ if ProcessInfo.processInfo.isiOSAppOnMac {
227
+ self.macWorker.async { observed(try? MacHotspotAssociation.observeCurrentSSID()) }
228
+ } else { NEHotspotNetwork.fetchCurrent { observed($0?.ssid) } }
229
+ }
230
+ monitor.pathUpdateHandler = { path in
231
+ guard let pathRevision = observation.beginPath(currentGeneration: self.generation) else { finish(false, "Hotspot session changed"); return }
232
+ currentPath = path
233
+ currentPathRevision = pathRevision
234
+ sample()
235
+ }
236
+ sampleTimer.setEventHandler(handler: sample)
237
+ sampleTimer.schedule(deadline: .now() + 0.5, repeating: 0.5)
238
+ sampleTimer.resume()
239
+ monitor.start(queue: queue)
240
+ queue.asyncAfter(deadline: .now() + 15) {
241
+ finish(false, allowWifiAfterRelease ? "Internet did not return after leaving the glasses hotspot" : "Cellular or Ethernet internet did not become the default route")
194
242
  }
195
243
  }
196
244
 
197
- private func authorizeMacAssociation(ssid: String, passphrase: String, generation gen: Int) {
198
- let authorization = MacHotspotAuthorization()
245
+ private func authorizeMacAssociation(ssid: String, passphrase: String, generation gen: Int, deadline: TimeInterval) {
246
+ let facts = MacHotspotAuthorizationFacts()
247
+ let authorization = MacHotspotAuthorization(facts: facts)
199
248
  macAuthorization = authorization
249
+ authorization.onRevoked = { [weak self] error in
250
+ guard let self else { return }
251
+ self.queue.async {
252
+ guard gen == self.generation, !self.cancelled else { return }
253
+ self.cancelled = true
254
+ self.macAssociation?.cancel()
255
+ self.finishJoin(.failure(error))
256
+ self.onLost?("Glasses hotspot Location authorization was revoked")
257
+ if !self.applying { self.finishLeave() }
258
+ }
259
+ }
260
+ macCleanupContinuation = MacHotspotCleanupContinuation(authorization: authorization, queue: queue,
261
+ isCurrentCancelledSession: { [weak self] in
262
+ guard let self else { return false }
263
+ return gen == self.generation && self.cancelled
264
+ }, requestCleanup: { [weak self] in
265
+ guard let self, !self.applying else { return }
266
+ self.finishLeave()
267
+ })
200
268
  authorization.start { result in
201
269
  self.queue.async {
202
270
  guard gen == self.generation, !self.cancelled else { return }
203
- self.macAuthorization = nil
204
271
  switch result {
205
- case .success: self.applyMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
272
+ case .success: self.applyMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen, deadline: deadline, authorizationFacts: facts, authorization: authorization)
206
273
  case let .failure(error):
207
274
  self.finishJoin(.failure(error)); self.finishLeave()
208
275
  }
@@ -210,8 +277,8 @@ public final class GlassesHotspotNetwork {
210
277
  }
211
278
  }
212
279
 
213
- private func applyMacAssociation(ssid: String, passphrase: String, generation gen: Int) {
214
- let association = MacHotspotAssociation()
280
+ private func applyMacAssociation(ssid: String, passphrase: String, generation gen: Int, deadline: TimeInterval, authorizationFacts: MacHotspotAuthorizationFacts, authorization: MacHotspotAuthorization) {
281
+ let association = MacHotspotAssociation(deadline: deadline, authorizationFacts: authorizationFacts, authorization: authorization)
215
282
  macAssociation = association
216
283
  applying = true
217
284
  macWorker.async {
@@ -260,12 +327,13 @@ public final class GlassesHotspotNetwork {
260
327
  }
261
328
  }
262
329
 
263
- private func applyConfiguration(ssid: String, passphrase: String, generation gen: Int) {
330
+ private func applyConfiguration(ssid: String, passphrase: String, generation gen: Int, remaining: Int = 60) {
264
331
  let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
265
332
  config.joinOnce = false
266
333
  applying = true
267
334
  ownsConfiguration = true
268
- logger.info("HOTSPOT_JOIN apply_start ios_on_mac=\(ProcessInfo.processInfo.isiOSAppOnMac)")
335
+ applies += 1
336
+ logger.info("HOTSPOT_JOIN apply_start attempt=\(self.applies) ios_on_mac=\(ProcessInfo.processInfo.isiOSAppOnMac)")
269
337
  NEHotspotConfigurationManager.shared.apply(config) { error in
270
338
  self.queue.async {
271
339
  guard gen == self.generation else { return }
@@ -283,12 +351,12 @@ public final class GlassesHotspotNetwork {
283
351
  }
284
352
  }
285
353
  self.logger.info("HOTSPOT_JOIN apply_accepted")
286
- self.waitForAddress(ssid: ssid, generation: gen, remaining: 60)
354
+ self.waitForAddress(ssid: ssid, passphrase: passphrase, generation: gen, remaining: remaining, pollsSinceApply: 0)
287
355
  }
288
356
  }
289
357
  }
290
358
 
291
- private func waitForAddress(ssid: String, generation gen: Int, remaining: Int) {
359
+ private func waitForAddress(ssid: String, passphrase: String, generation gen: Int, remaining: Int, pollsSinceApply: Int) {
292
360
  guard gen == generation, !cancelled else { return }
293
361
  NEHotspotNetwork.fetchCurrent { [weak self] network in
294
362
  self?.queue.async {
@@ -297,8 +365,15 @@ public final class GlassesHotspotNetwork {
297
365
  if network?.ssid == ssid, let selected = self.selectBinding() {
298
366
  self.bind(selected)
299
367
  self.finishAssociation(address: selected.address, generation: gen)
368
+ } else if remaining > 0, network?.ssid != ssid,
369
+ self.reapplyPolicy.shouldReapply(applies: self.applies, pollsSinceApply: pollsSinceApply) {
370
+ // iOS reports the request applied even when its one scan missed the hotspot.
371
+ self.logger.info("HOTSPOT_JOIN reapply after_polls=\(pollsSinceApply)")
372
+ self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen, remaining: remaining)
300
373
  } else if remaining > 0 {
301
- self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
374
+ self.queue.asyncAfter(deadline: .now() + 0.5) {
375
+ self.waitForAddress(ssid: ssid, passphrase: passphrase, generation: gen, remaining: remaining - 1, pollsSinceApply: pollsSinceApply + 1)
376
+ }
302
377
  } else {
303
378
  let association = network == nil ? "unavailable" : (network?.ssid == ssid ? "matched" : "different")
304
379
  self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address (SSID=\(association), \(self.wifiDiagnostic()))")))
@@ -412,6 +487,7 @@ public final class GlassesHotspotNetwork {
412
487
  private func finishLeave() {
413
488
  guard !finishingLeave else { return }
414
489
  finishingLeave = true
490
+ macCleanupContinuation?.cleanupStarted()
415
491
  if let association = macAssociation {
416
492
  association.cancel()
417
493
  // This queue is also the association queue: cleanup cannot overtake
@@ -427,9 +503,10 @@ public final class GlassesHotspotNetwork {
427
503
  // Retain the owner when we cannot prove which association
428
504
  // cleanup would disconnect. A later leave can retry safely.
429
505
  self.finishingLeave = false
430
- let replies = self.leaveReplies
431
- self.leaveReplies.removeAll()
432
- replies.forEach { $0(.failure(error)) }
506
+ let completion = self.leaveCompletion
507
+ self.leaveCompletion = nil
508
+ completion?.complete(.failure(error))
509
+ self.macCleanupContinuation?.cleanupFailed()
433
510
  }
434
511
  }
435
512
  }
@@ -437,6 +514,8 @@ public final class GlassesHotspotNetwork {
437
514
  }
438
515
 
439
516
  private func completeLeave() {
517
+ internetObservationOwner.cancel()
518
+ macCleanupContinuation = nil
440
519
  generation += 1
441
520
  macAuthorization?.cancel(); macAuthorization = nil
442
521
  localAccess?.cancel(); localAccess = nil
@@ -448,12 +527,25 @@ public final class GlassesHotspotNetwork {
448
527
  ssid = nil
449
528
  binding = nil
450
529
  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(())) }
530
+ let finish = {
531
+ self.waitForInternetAfterLeave = false
532
+ self.finishingLeave = false
533
+ let completion = self.leaveCompletion
534
+ self.leaveCompletion = nil
535
+ if let completion { completion.complete(.success(())) } else {
536
+ if let token = self.ownershipToken { HotspotNetworkOwnership.shared.release(token) }
537
+ self.ownershipToken = nil
538
+ }
539
+ }
540
+ if waitForInternetAfterLeave, ProcessInfo.processInfo.isiOSAppOnMac {
541
+ // Keep this owner until the default route returns or the existing
542
+ // bounded observation ends. Route readiness is not HTTP success;
543
+ // the following ordinary update check still owns that result.
544
+ startInternetObservation(allowWifiAfterRelease: true) { ready, reason in
545
+ self.logger.info("HOTSPOT_LEAVE internet_recovery ready=\(ready) reason=\(reason, privacy: .public)")
546
+ finish()
547
+ }
548
+ } else { finish() }
457
549
  }
458
550
 
459
551
  /// 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-beta.1237",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",