@mentra/glasses-media 3.3.0-dev.944 → 3.4.0-dev.1003
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/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 +132 -51
- 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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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() +
|
|
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?.
|
|
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?.
|
|
90
|
-
self.
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
|
431
|
-
self.
|
|
432
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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:
|