@mentra/glasses-media 3.2.1-dev.293 → 3.2.1-dev.295
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/ios/CoreKit/Sources/GlassesMediaCore/LocalMediaPolicy.swift +8 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/LocalNetworkAccessRequest.swift +134 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacE2EHotspotLease.swift +20 -0
- package/ios/GlassesHotspotNetwork.swift +79 -6
- package/ios/GlassesMediaRelayModule.swift +7 -0
- package/package.json +1 -1
|
@@ -39,6 +39,14 @@ public enum LocalMediaPolicy {
|
|
|
39
39
|
return bytes[3] > 0 && bytes[3] < 255
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/// A Mac may join through its host network controls. Reuse that connection only
|
|
43
|
+
/// after both its SSID and DHCP address match the hotspot advertised over BLE.
|
|
44
|
+
public static func canReuseHotspot(requestedSSID: String, currentSSID: String?, address: String?, gateway: String?) -> Bool {
|
|
45
|
+
guard !requestedSSID.isEmpty, requestedSSID == currentSSID,
|
|
46
|
+
let address, let gateway else { return false }
|
|
47
|
+
return isHotspotClientAddress(address, gateway: gateway)
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
/// Keep only concrete host candidates on the local link. Never rewrite a cellular socket's
|
|
43
51
|
/// advertised address: a candidate must already belong to the interface it claims.
|
|
44
52
|
public static func localSdp(_ sdp: String, address: String, answer: Bool) throws -> String {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#if MENTRA_E2E
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Test-build dependency input, not a production network discovery mechanism.
|
|
5
|
+
/// The Mac harness owns association and verifies the USB fixture, gateway MAC,
|
|
6
|
+
/// DHCP address, local health and Ethernet before launching this process.
|
|
7
|
+
public struct MacE2EHotspotLease: Decodable {
|
|
8
|
+
public let ssid: String
|
|
9
|
+
public let gateway: String
|
|
10
|
+
public let address: String
|
|
11
|
+
public let issuedAt: TimeInterval
|
|
12
|
+
|
|
13
|
+
public func matches(ssid: String, gateway: String?, address: String?, now: TimeInterval) -> Bool {
|
|
14
|
+
let age = now - issuedAt
|
|
15
|
+
return age >= 0 && age <= 300 && self.gateway == gateway && self.address == address &&
|
|
16
|
+
LocalMediaPolicy.canReuseHotspot(requestedSSID: ssid, currentSSID: self.ssid,
|
|
17
|
+
address: address, gateway: gateway)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
#endif
|
|
@@ -14,11 +14,17 @@ public final class GlassesHotspotNetwork {
|
|
|
14
14
|
private var gatewayAddress: String?
|
|
15
15
|
private var generation = 0
|
|
16
16
|
private var applying = false
|
|
17
|
+
private var ownsConfiguration = false
|
|
17
18
|
private var cancelled = false
|
|
18
19
|
private var joinReply: ((Result<String, Error>) -> Void)?
|
|
19
20
|
private var leaveReplies: [() -> Void] = []
|
|
20
21
|
private var monitor: NWPathMonitor?
|
|
22
|
+
private var localAccess: LocalNetworkAccessRequest?
|
|
23
|
+
#if MENTRA_E2E
|
|
24
|
+
private var testLeaseConsumed = false
|
|
25
|
+
#endif
|
|
21
26
|
public var onLost: ((String) -> Void)?
|
|
27
|
+
public var onPermissionRequired: (() -> Void)?
|
|
22
28
|
public init() {}
|
|
23
29
|
|
|
24
30
|
public func join(ssid: String, passphrase: String, gateway: String? = nil, completion: @escaping (Result<String, Error>) -> Void) {
|
|
@@ -33,15 +39,58 @@ public final class GlassesHotspotNetwork {
|
|
|
33
39
|
self.lastHotspotSSID = ssid
|
|
34
40
|
self.gatewayAddress = gateway
|
|
35
41
|
self.cancelled = false
|
|
42
|
+
self.applying = false
|
|
43
|
+
self.ownsConfiguration = false
|
|
36
44
|
self.joinReply = completion
|
|
37
|
-
|
|
45
|
+
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
46
|
+
#if MENTRA_E2E
|
|
47
|
+
if let raw = ProcessInfo.processInfo.environment["MENTRA_E2E_PREJOINED_HOTSPOT"] {
|
|
48
|
+
let address = Self.wifiAddress()
|
|
49
|
+
guard !self.testLeaseConsumed,
|
|
50
|
+
let lease = try? JSONDecoder().decode(MacE2EHotspotLease.self, from: Data(raw.utf8)),
|
|
51
|
+
lease.matches(ssid: ssid, gateway: gateway, address: address, now: Date().timeIntervalSince1970),
|
|
52
|
+
let address
|
|
53
|
+
else {
|
|
54
|
+
self.finishJoin(.failure(LocalMediaError("Mac E2E network lease is invalid, expired or already consumed")))
|
|
55
|
+
self.finishLeave()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
self.testLeaseConsumed = true
|
|
59
|
+
self.localAddress = address
|
|
60
|
+
self.logger.notice("HOTSPOT_JOIN test_harness_connection native_association_untested")
|
|
61
|
+
self.startMonitor(generation: gen)
|
|
62
|
+
self.waitForLocalAccess(address: address, generation: gen)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
#endif
|
|
66
|
+
NEHotspotNetwork.fetchCurrent { network in
|
|
67
|
+
self.queue.async {
|
|
68
|
+
guard gen == self.generation, !self.cancelled else { return }
|
|
69
|
+
let address = Self.wifiAddress()
|
|
70
|
+
if let address, LocalMediaPolicy.canReuseHotspot(requestedSSID: ssid, currentSSID: network?.ssid,
|
|
71
|
+
address: address, gateway: gateway)
|
|
72
|
+
{
|
|
73
|
+
self.localAddress = address
|
|
74
|
+
self.logger.info("HOTSPOT_JOIN reuse_verified_macos_connection")
|
|
75
|
+
self.startMonitor(generation: gen)
|
|
76
|
+
self.waitForLocalAccess(address: address, generation: gen)
|
|
77
|
+
} else {
|
|
78
|
+
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
84
|
+
}
|
|
38
85
|
self.queue.asyncAfter(deadline: .now() + 60) {
|
|
39
|
-
|
|
86
|
+
// Once associated, the user may still be reading the Local Network alert.
|
|
87
|
+
// Its response time is not a hotspot association timeout.
|
|
88
|
+
guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
|
|
40
89
|
self.cancelled = true
|
|
41
90
|
self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
|
|
42
91
|
// apply() cannot be cancelled. Retain the reservation until its callback and remove the
|
|
43
92
|
// late configuration before another call is permitted to acquire the network.
|
|
44
|
-
NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid)
|
|
93
|
+
if self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
45
94
|
if !self.applying { self.finishLeave() }
|
|
46
95
|
}
|
|
47
96
|
}
|
|
@@ -52,7 +101,7 @@ public final class GlassesHotspotNetwork {
|
|
|
52
101
|
self.cancelled = true
|
|
53
102
|
self.leaveReplies.append(completion)
|
|
54
103
|
self.finishJoin(.failure(LocalMediaError("Hotspot join cancelled")))
|
|
55
|
-
if let ssid = self.ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
104
|
+
if let ssid = self.ssid, self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
56
105
|
if !self.applying { self.finishLeave() }
|
|
57
106
|
}
|
|
58
107
|
}
|
|
@@ -143,6 +192,7 @@ public final class GlassesHotspotNetwork {
|
|
|
143
192
|
let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
|
|
144
193
|
config.joinOnce = false
|
|
145
194
|
applying = true
|
|
195
|
+
ownsConfiguration = true
|
|
146
196
|
logger.info("HOTSPOT_JOIN apply_start ios_on_mac=\(ProcessInfo.processInfo.isiOSAppOnMac)")
|
|
147
197
|
NEHotspotConfigurationManager.shared.apply(config) { error in
|
|
148
198
|
self.queue.async {
|
|
@@ -176,7 +226,7 @@ public final class GlassesHotspotNetwork {
|
|
|
176
226
|
{
|
|
177
227
|
self.localAddress = address
|
|
178
228
|
self.startMonitor(generation: gen)
|
|
179
|
-
self.
|
|
229
|
+
self.waitForLocalAccess(address: address, generation: gen)
|
|
180
230
|
} else if remaining > 0 {
|
|
181
231
|
self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
|
|
182
232
|
} else {
|
|
@@ -201,6 +251,27 @@ public final class GlassesHotspotNetwork {
|
|
|
201
251
|
monitor.start(queue: queue)
|
|
202
252
|
}
|
|
203
253
|
|
|
254
|
+
private func waitForLocalAccess(address: String, generation gen: Int) {
|
|
255
|
+
let gateway = gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
|
|
256
|
+
let request = LocalNetworkAccessRequest(localAddress: address, gateway: gateway, queue: queue)
|
|
257
|
+
localAccess = request
|
|
258
|
+
request.start(onPermissionRequired: { [weak self] in
|
|
259
|
+
guard let self, gen == generation, !cancelled else { return }
|
|
260
|
+
NSLog("GLASSES-MEDIA local_network_permission=waiting")
|
|
261
|
+
onPermissionRequired?()
|
|
262
|
+
}) { [weak self] result in
|
|
263
|
+
guard let self, gen == generation, !cancelled else { return }
|
|
264
|
+
switch result {
|
|
265
|
+
case .success:
|
|
266
|
+
NSLog("GLASSES-MEDIA local_network_permission=ready")
|
|
267
|
+
finishJoin(.success(address))
|
|
268
|
+
case let .failure(error):
|
|
269
|
+
finishJoin(.failure(error))
|
|
270
|
+
finishLeave()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
204
275
|
private func finishJoin(_ result: Result<String, Error>) {
|
|
205
276
|
let reply = joinReply
|
|
206
277
|
joinReply = nil
|
|
@@ -209,8 +280,10 @@ public final class GlassesHotspotNetwork {
|
|
|
209
280
|
|
|
210
281
|
private func finishLeave() {
|
|
211
282
|
generation += 1
|
|
283
|
+
localAccess?.cancel(); localAccess = nil
|
|
212
284
|
monitor?.cancel(); monitor = nil
|
|
213
|
-
if let ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
285
|
+
if let ssid, ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
286
|
+
ownsConfiguration = false
|
|
214
287
|
ssid = nil
|
|
215
288
|
localAddress = nil
|
|
216
289
|
gatewayAddress = 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() }
|