@mentra/glasses-media 3.2.1-dev.292 → 3.2.1-dev.294
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.
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Network
|
|
3
|
+
|
|
4
|
+
protocol LocalNetworkAccessConnection: AnyObject {
|
|
5
|
+
var onState: ((NWConnection.State, NWPath.UnsatisfiedReason?) -> Void)? { get set }
|
|
6
|
+
func start(queue: DispatchQueue)
|
|
7
|
+
func cancel()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// Wait for permission to use the verified local route before starting timed media work.
|
|
11
|
+
/// Connect to the glasses' existing gallery HTTP endpoint to prove local access (TN3179).
|
|
12
|
+
/// Network.framework retries when access is granted. A denied path can also mean that the
|
|
13
|
+
/// alert is still open, so it must not be treated as a terminal error or given a media timeout.
|
|
14
|
+
/// Ordinary connection setup has a 30-second budget, paused while permission is required.
|
|
15
|
+
public final class LocalNetworkAccessRequest {
|
|
16
|
+
private let queue: DispatchQueue
|
|
17
|
+
private let connection: LocalNetworkAccessConnection
|
|
18
|
+
private let now: () -> TimeInterval
|
|
19
|
+
private let scheduleTimeout: (TimeInterval, DispatchWorkItem) -> Void
|
|
20
|
+
private var remainingConnectivityTime: TimeInterval = 30
|
|
21
|
+
private var timeoutDeadline: TimeInterval?
|
|
22
|
+
private var timeoutWork: DispatchWorkItem?
|
|
23
|
+
private var completion: ((Result<Void, Error>) -> Void)?
|
|
24
|
+
private var waiting: (() -> Void)?
|
|
25
|
+
private var finished = false
|
|
26
|
+
private var reportedPermission = false
|
|
27
|
+
|
|
28
|
+
public convenience init(localAddress: String, gateway: String, queue: DispatchQueue) {
|
|
29
|
+
self.init(connection: LocalNetworkPermissionConnection(localAddress: localAddress, gateway: gateway), queue: queue)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
init(connection: LocalNetworkAccessConnection, queue: DispatchQueue,
|
|
33
|
+
now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },
|
|
34
|
+
scheduleTimeout: ((TimeInterval, DispatchWorkItem) -> Void)? = nil)
|
|
35
|
+
{
|
|
36
|
+
self.connection = connection
|
|
37
|
+
self.queue = queue
|
|
38
|
+
self.now = now
|
|
39
|
+
self.scheduleTimeout = scheduleTimeout ?? { delay, work in queue.asyncAfter(deadline: .now() + delay, execute: work) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public func start(onPermissionRequired: @escaping () -> Void, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
43
|
+
queue.async {
|
|
44
|
+
guard !self.finished, self.completion == nil else { return }
|
|
45
|
+
self.completion = completion
|
|
46
|
+
self.waiting = onPermissionRequired
|
|
47
|
+
self.connection.onState = { [weak self] state, reason in
|
|
48
|
+
guard let self, !self.finished else { return }
|
|
49
|
+
switch state {
|
|
50
|
+
case .ready: self.finish(.success(()))
|
|
51
|
+
case .waiting where reason == .localNetworkDenied:
|
|
52
|
+
self.pauseConnectivityTimeout()
|
|
53
|
+
if !self.reportedPermission {
|
|
54
|
+
self.reportedPermission = true
|
|
55
|
+
self.waiting?()
|
|
56
|
+
}
|
|
57
|
+
// Waiting errors are nonfatal; NWConnection retries when its path recovers.
|
|
58
|
+
// A nil path is not proof of denied permission, but must not fail immediately.
|
|
59
|
+
case .setup, .preparing, .waiting: self.resumeConnectivityTimeout()
|
|
60
|
+
case let .failed(error): self.finish(.failure(error))
|
|
61
|
+
case .cancelled: self.finish(.failure(CancellationError()))
|
|
62
|
+
@unknown default: break
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
self.resumeConnectivityTimeout()
|
|
66
|
+
self.connection.start(queue: self.queue)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public func cancel() {
|
|
71
|
+
queue.async { self.finish(.failure(CancellationError())) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private func resumeConnectivityTimeout() {
|
|
75
|
+
guard timeoutDeadline == nil else { return }
|
|
76
|
+
let deadline = now() + remainingConnectivityTime
|
|
77
|
+
timeoutDeadline = deadline
|
|
78
|
+
let work = DispatchWorkItem { [weak self] in
|
|
79
|
+
guard let self, self.timeoutDeadline == deadline else { return }
|
|
80
|
+
self.finish(.failure(LocalMediaError("Local connection to the glasses timed out")))
|
|
81
|
+
}
|
|
82
|
+
timeoutWork = work
|
|
83
|
+
scheduleTimeout(remainingConnectivityTime, work)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func pauseConnectivityTimeout() {
|
|
87
|
+
if let deadline = timeoutDeadline { remainingConnectivityTime = max(0, deadline - now()) }
|
|
88
|
+
timeoutDeadline = nil
|
|
89
|
+
timeoutWork?.cancel()
|
|
90
|
+
timeoutWork = nil
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private func finish(_ result: Result<Void, Error>) {
|
|
94
|
+
guard !finished else { return }
|
|
95
|
+
finished = true
|
|
96
|
+
pauseConnectivityTimeout()
|
|
97
|
+
connection.onState = nil
|
|
98
|
+
connection.cancel()
|
|
99
|
+
let reply = completion
|
|
100
|
+
completion = nil
|
|
101
|
+
waiting = nil
|
|
102
|
+
reply?(result)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private final class LocalNetworkPermissionConnection: LocalNetworkAccessConnection {
|
|
107
|
+
var onState: ((NWConnection.State, NWPath.UnsatisfiedReason?) -> Void)?
|
|
108
|
+
private let connection: NWConnection
|
|
109
|
+
|
|
110
|
+
init(localAddress: String, gateway: String) {
|
|
111
|
+
let parameters = NWParameters.tcp
|
|
112
|
+
parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(localAddress), port: .any)
|
|
113
|
+
connection = NWConnection(host: NWEndpoint.Host(gateway), port: 8089, using: parameters)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func start(queue: DispatchQueue) {
|
|
117
|
+
connection.stateUpdateHandler = { [weak self] state in
|
|
118
|
+
guard let self else { return }
|
|
119
|
+
onState?(state, connection.currentPath?.unsatisfiedReason)
|
|
120
|
+
}
|
|
121
|
+
// The path reason can arrive after a waiting update with no currentPath yet.
|
|
122
|
+
connection.pathUpdateHandler = { [weak self] path in
|
|
123
|
+
guard let self else { return }
|
|
124
|
+
onState?(connection.state, path.unsatisfiedReason)
|
|
125
|
+
}
|
|
126
|
+
connection.start(queue: queue)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func cancel() {
|
|
130
|
+
connection.stateUpdateHandler = nil
|
|
131
|
+
connection.pathUpdateHandler = nil
|
|
132
|
+
connection.cancel()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -18,7 +18,9 @@ public final class GlassesHotspotNetwork {
|
|
|
18
18
|
private var joinReply: ((Result<String, Error>) -> Void)?
|
|
19
19
|
private var leaveReplies: [() -> Void] = []
|
|
20
20
|
private var monitor: NWPathMonitor?
|
|
21
|
+
private var localAccess: LocalNetworkAccessRequest?
|
|
21
22
|
public var onLost: ((String) -> Void)?
|
|
23
|
+
public var onPermissionRequired: (() -> Void)?
|
|
22
24
|
public init() {}
|
|
23
25
|
|
|
24
26
|
public func join(ssid: String, passphrase: String, gateway: String? = nil, completion: @escaping (Result<String, Error>) -> Void) {
|
|
@@ -36,7 +38,9 @@ public final class GlassesHotspotNetwork {
|
|
|
36
38
|
self.joinReply = completion
|
|
37
39
|
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
38
40
|
self.queue.asyncAfter(deadline: .now() + 60) {
|
|
39
|
-
|
|
41
|
+
// Once associated, the user may still be reading the Local Network alert.
|
|
42
|
+
// Its response time is not a hotspot association timeout.
|
|
43
|
+
guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
|
|
40
44
|
self.cancelled = true
|
|
41
45
|
self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
|
|
42
46
|
// apply() cannot be cancelled. Retain the reservation until its callback and remove the
|
|
@@ -176,7 +180,7 @@ public final class GlassesHotspotNetwork {
|
|
|
176
180
|
{
|
|
177
181
|
self.localAddress = address
|
|
178
182
|
self.startMonitor(generation: gen)
|
|
179
|
-
self.
|
|
183
|
+
self.waitForLocalAccess(address: address, generation: gen)
|
|
180
184
|
} else if remaining > 0 {
|
|
181
185
|
self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
|
|
182
186
|
} else {
|
|
@@ -201,6 +205,27 @@ public final class GlassesHotspotNetwork {
|
|
|
201
205
|
monitor.start(queue: queue)
|
|
202
206
|
}
|
|
203
207
|
|
|
208
|
+
private func waitForLocalAccess(address: String, generation gen: Int) {
|
|
209
|
+
let gateway = gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
|
|
210
|
+
let request = LocalNetworkAccessRequest(localAddress: address, gateway: gateway, queue: queue)
|
|
211
|
+
localAccess = request
|
|
212
|
+
request.start(onPermissionRequired: { [weak self] in
|
|
213
|
+
guard let self, gen == generation, !cancelled else { return }
|
|
214
|
+
NSLog("GLASSES-MEDIA local_network_permission=waiting")
|
|
215
|
+
onPermissionRequired?()
|
|
216
|
+
}) { [weak self] result in
|
|
217
|
+
guard let self, gen == generation, !cancelled else { return }
|
|
218
|
+
switch result {
|
|
219
|
+
case .success:
|
|
220
|
+
NSLog("GLASSES-MEDIA local_network_permission=ready")
|
|
221
|
+
finishJoin(.success(address))
|
|
222
|
+
case let .failure(error):
|
|
223
|
+
finishJoin(.failure(error))
|
|
224
|
+
finishLeave()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
204
229
|
private func finishJoin(_ result: Result<String, Error>) {
|
|
205
230
|
let reply = joinReply
|
|
206
231
|
joinReply = nil
|
|
@@ -209,6 +234,7 @@ public final class GlassesHotspotNetwork {
|
|
|
209
234
|
|
|
210
235
|
private func finishLeave() {
|
|
211
236
|
generation += 1
|
|
237
|
+
localAccess?.cancel(); localAccess = nil
|
|
212
238
|
monitor?.cancel(); monitor = nil
|
|
213
239
|
if let ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
214
240
|
ssid = nil
|
|
@@ -74,6 +74,12 @@ private final class ManagedRelaySession {
|
|
|
74
74
|
{
|
|
75
75
|
ready = completion
|
|
76
76
|
hotspot.onLost = { reason in onState("failed", reason) }
|
|
77
|
+
hotspot.onPermissionRequired = { [weak self] in
|
|
78
|
+
self?.queue.async {
|
|
79
|
+
guard let self, !self.stopped else { return }
|
|
80
|
+
onState("permission_required", "Allow Local Network access to connect to your glasses. If you previously denied access, enable it in Settings, or cancel to return home.")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
77
83
|
hotspot.join(ssid: ssid, passphrase: password, gateway: gateway) { result in
|
|
78
84
|
self.queue.async {
|
|
79
85
|
guard !self.stopped else { return }
|
|
@@ -117,6 +123,7 @@ private final class ManagedRelaySession {
|
|
|
117
123
|
stopped = true
|
|
118
124
|
finish(.failure(LocalMediaError("Relay cancelled")))
|
|
119
125
|
hotspot.onLost = nil
|
|
126
|
+
hotspot.onPermissionRequired = nil
|
|
120
127
|
let group = DispatchGroup()
|
|
121
128
|
if let receiver {
|
|
122
129
|
group.enter(); receiver.stop { group.leave() }
|