@mentra/glasses-media 3.3.0 → 3.4.0-beta.1241
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 +1 -1
- package/index.js +1 -1
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotInternetPolicy.swift +98 -1
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotReapplyPolicy.swift +26 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAssociation.swift +98 -25
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAuthorization.swift +82 -15
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAuthorizationFacts.swift +51 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotCleanupContinuation.swift +35 -0
- package/ios/GlassesHotspotNetwork.swift +148 -56
- package/ios/GlassesMediaRelayModule.swift +2 -2
- package/package.json +1 -1
package/index.d.ts
CHANGED
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
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
23
|
-
let network = try
|
|
24
|
-
try
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
46
|
-
interface
|
|
47
|
-
guard try interface
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
30
|
+
guard !self.closed, !self.started else { completion(.failure(CancellationError())); return }
|
|
31
|
+
self.started = true
|
|
14
32
|
self.completion = completion
|
|
15
|
-
let manager =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
reply(.failure(error))
|
|
44
115
|
case .notDetermined: break
|
|
45
116
|
default:
|
|
46
|
-
|
|
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
|
|
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
|
|
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() +
|
|
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?.
|
|
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?.
|
|
90
|
-
self.
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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) {
|
|
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
|
|
431
|
-
self.
|
|
432
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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:
|