@mentra/glasses-media 3.3.0-dev.864 → 3.3.0-dev.870
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/README.md +14 -2
- package/index.d.ts +11 -0
- package/index.js +9 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotAccessPurpose.swift +5 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotNetworkOwnership.swift +22 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAssociation.swift +161 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAuthorization.swift +59 -0
- package/ios/GlassesHotspotNetwork.swift +148 -75
- package/ios/GlassesMedia.podspec +1 -1
- package/ios/GlassesMediaRelayModule.swift +59 -11
- package/package.json +6 -1
- package/ios/CoreKit/Sources/GlassesMediaCore/MacE2EHotspotLease.swift +0 -20
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Native receivers, WHIP publishers and network helpers shared by the Mentra App.
|
|
|
4
4
|
|
|
5
5
|
- Android: the existing `GlassesMediaSource`, local WHIP and WHEP receivers, scoped SoftAP networking, decoded I420/PCM delivery, and transport diagnostics moved here. The ACS session and raw outgoing streams remain in `acs-meeting`.
|
|
6
6
|
- iOS: `DecodedGlassesMediaSource` delivers `CVPixelBuffer` video and interleaved PCM16. `LocalWhipIngestSource` answers the glasses' existing WHIP client; `WhepVideoSource` retains the cloud subscription path.
|
|
7
|
-
- `GlassesHotspotNetwork`
|
|
7
|
+
- `GlassesHotspotNetwork` owns hotspot association for OTA, gallery transfers and media. iPhone uses persistent `NEHotspotConfiguration`; iOS-on-Mac uses public CoreWLAN in the app process and the standard Location permission needed to identify Wi-Fi networks. Wi-Fi carries the glasses link; cellular carries the call. The owner verifies the requested SSID and actual Wi-Fi interface address, retains that interface for loss checks and ACS binding, and serializes network writers across consumers. Cancellation retains custody until blocking native work and cleanup finish. Cleanup preserves a network the user joined afterward; a failed release rejects and retains ownership for a later cleanup attempt.
|
|
8
8
|
|
|
9
9
|
The publisher owns its outgoing encoders, audio policy, and destination. Receivers do not open the phone microphone or play the glasses microphone locally. iOS uses a receive-only audio device to drive decoded PCM delivery without changing the app's audio session.
|
|
10
10
|
|
|
@@ -53,6 +53,18 @@ The iOS internet publisher posts its offer on the first server-reflexive candida
|
|
|
53
53
|
|
|
54
54
|
Android temporarily lifts the cellular process pin while creating the local WHIP listener, then restores it before starting the outgoing publisher. Otherwise the listener inherits the cellular socket mark and cannot reply to the glasses. The outgoing audio callback supplies its own PCM frame clock: WebRTC's external-recording mode does not pace callbacks and reports zero captured bytes, so the callback fills the entire buffer and waits for the next frame interval without opening the phone microphone.
|
|
55
55
|
|
|
56
|
-
An attempt-specific ID isolates late callbacks and glasses status. Reconnect tears down the glasses publisher, both phone peers and hotspot before rebuilding against the same provisioned input. Three retries bound consecutive failures; a minute of stable streaming renews the retry budget. Last-subscriber stop cancels startup/backoff, awaits native cleanup and disables the hotspot. Failed cleanup retains ownership and can be retried by stop. BLE-unreachable stop/hotspot commands are deferred until reconnection. ACS and managed streaming share
|
|
56
|
+
An attempt-specific ID isolates late callbacks and glasses status. Reconnect tears down the glasses publisher, both phone peers and hotspot before rebuilding against the same provisioned input. Three retries bound consecutive failures; a minute of stable streaming renews the retry budget. Last-subscriber stop cancels startup/backoff, awaits native cleanup and disables the hotspot. Failed cleanup retains ownership and can be retried by stop. BLE-unreachable stop/hotspot commands are deferred until reconnection. OTA, Gallery, ACS and managed streaming share one hotspot reservation before BLE AP changes or native association. OTA, Gallery and managed relay retain it until their owned cleanup completes. ACS still has an older retirement policy that releases its JavaScript reservation after reporting an unconfirmed stop; native association custody remains retained on failure. Consolidating that ACS retirement is a separate qualification gate. A refused OTA or Gallery caller makes no AP or association changes. Native media queues keep running when the UI is backgrounded; Android holds a partial wake lock and uses the engine's background timers for retry/readiness.
|
|
57
57
|
|
|
58
58
|
Device qualification for this outgoing pipeline must include Android and iPhone: audio/video playback on the provisioned WHEP URL, mute/video-only capture, last-subscriber stop, cancellation at each setup step, cellular/hotspot/BLE loss and recovery, repeated starts, screen-off/background operation and a sustained thermal/audio-sync run. Unit tests and platform compilation cover lifecycle/format contracts; they do not establish those hardware results.
|
|
59
|
+
|
|
60
|
+
### Incoming OTA server and outgoing media clients
|
|
61
|
+
|
|
62
|
+
Hotspot association verifies the glasses SSID and the Wi-Fi interface/address for
|
|
63
|
+
both uses. Gallery and relay clients also wait for outgoing Local Network access
|
|
64
|
+
by connecting to the glasses HTTP endpoint. OTA selects `incoming-server`: it
|
|
65
|
+
serves already downloaded artifacts to connections initiated by the glasses, and
|
|
66
|
+
its own listener must become ready before the app sends the OTA manifest URL.
|
|
67
|
+
It does not depend on the Gallery HTTP server being available. Apple TN3179 lists
|
|
68
|
+
accepting incoming TCP as an operation that does not require Local Network
|
|
69
|
+
permission. This does not change outgoing Gallery/relay consent or the shared
|
|
70
|
+
network ownership and teardown rules.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Shared native association owner for the local glasses hotspot. */
|
|
2
|
+
export const glassesHotspot: {
|
|
3
|
+
join(
|
|
4
|
+
id: string,
|
|
5
|
+
ssid: string,
|
|
6
|
+
password: string,
|
|
7
|
+
gateway?: string,
|
|
8
|
+
purpose?: "outgoing-client" | "incoming-server",
|
|
9
|
+
): Promise<string>
|
|
10
|
+
leave(id: string): Promise<void>
|
|
11
|
+
}
|
package/index.js
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
1
|
// Native library. Media stays in native code; publishers consume decoded frames and PCM.
|
|
2
|
+
|
|
3
|
+
// Load only when this transport is requested. Importing the package does not
|
|
4
|
+
// start a native session or require Expo's application entry point.
|
|
5
|
+
const native = () => require("expo-modules-core").requireNativeModule("MentraGlassesMediaRelay")
|
|
6
|
+
export const glassesHotspot = Object.freeze({
|
|
7
|
+
join: (id, ssid, password, gateway, purpose = "outgoing-client") =>
|
|
8
|
+
native().joinLocalHotspot(id, ssid, password, gateway ?? null, purpose),
|
|
9
|
+
leave: (id) => native().leaveLocalHotspot(id),
|
|
10
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One process-wide writer across OTA, gallery and native media sessions.
|
|
4
|
+
/// Callers release only after native association and owned cleanup have ended.
|
|
5
|
+
final class HotspotNetworkOwnership {
|
|
6
|
+
static let shared = HotspotNetworkOwnership()
|
|
7
|
+
private let lock = NSLock()
|
|
8
|
+
private var owner: UUID?
|
|
9
|
+
|
|
10
|
+
func acquire() -> UUID? {
|
|
11
|
+
lock.lock(); defer { lock.unlock() }
|
|
12
|
+
guard owner == nil else { return nil }
|
|
13
|
+
let token = UUID()
|
|
14
|
+
owner = token
|
|
15
|
+
return token
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func release(_ token: UUID) {
|
|
19
|
+
lock.lock(); defer { lock.unlock() }
|
|
20
|
+
if owner == token { owner = nil }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
import ObjectiveC
|
|
4
|
+
|
|
5
|
+
/// Blocking public CoreWLAN calls are isolated here; the existing hotspot owner
|
|
6
|
+
/// retains custody until they return, even after cancellation or a timeout.
|
|
7
|
+
final class MacHotspotAssociation {
|
|
8
|
+
private let lock = NSLock()
|
|
9
|
+
private var cancelled = false
|
|
10
|
+
private var interface: MacWifiInterface?
|
|
11
|
+
private var joinedSSID: String?
|
|
12
|
+
private let makeInterface: () throws -> MacWifiInterface
|
|
13
|
+
|
|
14
|
+
init(makeInterface: @escaping () throws -> MacWifiInterface = { try PublicCoreWlanInterface() }) {
|
|
15
|
+
self.makeInterface = makeInterface
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func associate(ssid: String, password: String) throws -> String {
|
|
19
|
+
try checkCurrent()
|
|
20
|
+
let interface = try makeInterface()
|
|
21
|
+
self.interface = interface
|
|
22
|
+
if try interface.currentSSID() != ssid {
|
|
23
|
+
let network = try interface.network(ssid: ssid)
|
|
24
|
+
try checkCurrent()
|
|
25
|
+
// Record the intended association before this blocking call. A late
|
|
26
|
+
// successful join still belongs to us and must be cleaned up.
|
|
27
|
+
joinedSSID = ssid
|
|
28
|
+
try interface.associate(network: network, password: password)
|
|
29
|
+
}
|
|
30
|
+
try checkCurrent()
|
|
31
|
+
guard try interface.currentSSID() == ssid else {
|
|
32
|
+
throw LocalMediaError("Mac Wi-Fi did not associate with the requested glasses hotspot")
|
|
33
|
+
}
|
|
34
|
+
return try interface.name()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func cancel() {
|
|
38
|
+
lock.lock(); cancelled = true; lock.unlock()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Run on the same worker as association, after its blocking call returns.
|
|
42
|
+
/// A connection the user replaced must never be disconnected by our cleanup.
|
|
43
|
+
func leave() throws {
|
|
44
|
+
if let joinedSSID {
|
|
45
|
+
if try interface?.currentSSID() == joinedSSID {
|
|
46
|
+
interface?.disassociate()
|
|
47
|
+
guard try interface?.currentSSID() != joinedSSID else {
|
|
48
|
+
throw LocalMediaError("Mac Wi-Fi has not released the glasses hotspot")
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
interface = nil
|
|
53
|
+
joinedSSID = nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func currentSSID() throws -> String? {
|
|
57
|
+
try interface?.currentSSID()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private func checkCurrent() throws {
|
|
61
|
+
lock.lock(); let stopped = cancelled; lock.unlock()
|
|
62
|
+
if stopped { throw CancellationError() }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
protocol MacWifiInterface: AnyObject {
|
|
67
|
+
func name() throws -> String
|
|
68
|
+
func currentSSID() throws -> String?
|
|
69
|
+
func network(ssid: String) throws -> NSObject
|
|
70
|
+
func associate(network: NSObject, password: String) throws
|
|
71
|
+
func disassociate()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
enum MacWifiInvocation {
|
|
75
|
+
// Objective-C NSError** parameters use autoreleasing writeback. Strong
|
|
76
|
+
// pointer storage would over-release errors when the native pool drains.
|
|
77
|
+
typealias Scan = @convention(c) (AnyObject, Selector, NSData, AutoreleasingUnsafeMutablePointer<NSError?>) -> Unmanaged<NSSet>?
|
|
78
|
+
typealias Associate = @convention(c) (AnyObject, Selector, AnyObject, NSString, AutoreleasingUnsafeMutablePointer<NSError?>) -> Bool
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// CoreWLAN is a public macOS framework. Foundation loads it only in the Mac
|
|
82
|
+
/// process; Apple dyld permits macOS frameworks in iOS-on-Mac. The checked
|
|
83
|
+
/// Objective-C signatures below are the public CWInterface SDK declarations.
|
|
84
|
+
private final class PublicCoreWlanInterface: MacWifiInterface {
|
|
85
|
+
private let bundle: Bundle
|
|
86
|
+
private let interface: NSObject
|
|
87
|
+
private typealias Disassociate = @convention(c) (AnyObject, Selector) -> Void
|
|
88
|
+
|
|
89
|
+
init() throws {
|
|
90
|
+
guard let bundle = Bundle(path: "/System/Library/Frameworks/CoreWLAN.framework") else {
|
|
91
|
+
throw LocalMediaError("Mac Wi-Fi framework is unavailable")
|
|
92
|
+
}
|
|
93
|
+
do { try bundle.loadAndReturnError() }
|
|
94
|
+
catch { throw Self.failure("framework loading", error as NSError) }
|
|
95
|
+
guard let clientClass = NSClassFromString("CWWiFiClient") as? NSObject.Type,
|
|
96
|
+
clientClass.responds(to: NSSelectorFromString("sharedWiFiClient")),
|
|
97
|
+
let client = clientClass.perform(NSSelectorFromString("sharedWiFiClient"))?.takeUnretainedValue() as? NSObject,
|
|
98
|
+
client.responds(to: NSSelectorFromString("interface")),
|
|
99
|
+
let interface = client.perform(NSSelectorFromString("interface"))?.takeUnretainedValue() as? NSObject
|
|
100
|
+
else { throw LocalMediaError("Mac Wi-Fi interface is unavailable") }
|
|
101
|
+
for method in ["interfaceName", "ssidData", "scanForNetworksWithSSID:error:", "associateToNetwork:password:error:", "disassociate"] {
|
|
102
|
+
guard interface.responds(to: NSSelectorFromString(method)) else {
|
|
103
|
+
throw LocalMediaError("Mac Wi-Fi interface does not support hotspot association")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
self.interface = interface
|
|
107
|
+
self.bundle = bundle
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func name() throws -> String {
|
|
111
|
+
guard let name = interface.perform(NSSelectorFromString("interfaceName"))?.takeUnretainedValue() as? String,
|
|
112
|
+
!name.isEmpty else { throw LocalMediaError("Mac Wi-Fi interface has no identity") }
|
|
113
|
+
return name
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func currentSSID() throws -> String? {
|
|
117
|
+
let authorization = CLLocationManager.authorizationStatus()
|
|
118
|
+
guard MacHotspotAuthorization.isAuthorized(authorization) else {
|
|
119
|
+
throw LocalMediaError("Location access is required to verify the current Mac Wi-Fi network")
|
|
120
|
+
}
|
|
121
|
+
guard let data = interface.perform(NSSelectorFromString("ssidData"))?.takeUnretainedValue() as? Data else { return nil }
|
|
122
|
+
return String(data: data, encoding: .utf8)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func network(ssid: String) throws -> NSObject {
|
|
126
|
+
let selector = NSSelectorFromString("scanForNetworksWithSSID:error:")
|
|
127
|
+
let invoke = unsafeBitCast(interface.method(for: selector), to: MacWifiInvocation.Scan.self)
|
|
128
|
+
var error: NSError?
|
|
129
|
+
guard let networks = invoke(interface, selector, NSData(data: Data(ssid.utf8)), &error)?.takeUnretainedValue() else {
|
|
130
|
+
throw Self.failure("scan", error)
|
|
131
|
+
}
|
|
132
|
+
// A directed scan can include unrelated networks. Match the requested
|
|
133
|
+
// SSID bytes without reading or logging BSSID or other scan identities.
|
|
134
|
+
for case let network as NSObject in networks {
|
|
135
|
+
guard network.responds(to: NSSelectorFromString("ssidData")),
|
|
136
|
+
let data = network.perform(NSSelectorFromString("ssidData"))?.takeUnretainedValue() as? Data,
|
|
137
|
+
data == Data(ssid.utf8) else { continue }
|
|
138
|
+
return network
|
|
139
|
+
}
|
|
140
|
+
throw LocalMediaError("The requested glasses hotspot was not found by Mac Wi-Fi")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func associate(network: NSObject, password: String) throws {
|
|
144
|
+
let selector = NSSelectorFromString("associateToNetwork:password:error:")
|
|
145
|
+
let invoke = unsafeBitCast(interface.method(for: selector), to: MacWifiInvocation.Associate.self)
|
|
146
|
+
var error: NSError?
|
|
147
|
+
guard invoke(interface, selector, network, password as NSString, &error) else { throw Self.failure("association", error) }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func disassociate() {
|
|
151
|
+
let selector = NSSelectorFromString("disassociate")
|
|
152
|
+
let invoke = unsafeBitCast(interface.method(for: selector), to: Disassociate.self)
|
|
153
|
+
invoke(interface, selector)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private static func failure(_ operation: String, _ error: NSError?) -> Error {
|
|
157
|
+
let domain = error?.domain ?? "unavailable"
|
|
158
|
+
let safeDomain = domain.range(of: "^[A-Za-z0-9_.-]{1,128}$", options: .regularExpression) == nil ? "unavailable" : domain
|
|
159
|
+
return LocalMediaError("Mac Wi-Fi \(operation) failed (\(safeDomain), \(error?.code ?? -1))")
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// CoreWLAN requires Location authorization to identify the requested network.
|
|
5
|
+
/// Use the app's existing system permission, with no separate connection screen.
|
|
6
|
+
final class MacHotspotAuthorization: NSObject, CLLocationManagerDelegate {
|
|
7
|
+
private var manager: CLLocationManager?
|
|
8
|
+
private var completion: ((Result<Void, Error>) -> Void)?
|
|
9
|
+
private var finished = false
|
|
10
|
+
|
|
11
|
+
func start(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
12
|
+
DispatchQueue.main.async {
|
|
13
|
+
guard !self.finished else { completion(.failure(CancellationError())); return }
|
|
14
|
+
self.completion = completion
|
|
15
|
+
let manager = CLLocationManager()
|
|
16
|
+
self.manager = manager
|
|
17
|
+
manager.delegate = self
|
|
18
|
+
self.handle(manager.authorizationStatus)
|
|
19
|
+
if manager.authorizationStatus == .notDetermined { manager.requestWhenInUseAuthorization() }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func cancel() {
|
|
24
|
+
DispatchQueue.main.async { self.finish(.failure(CancellationError())) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
28
|
+
handle(manager.authorizationStatus)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static func isAuthorized(_ status: CLAuthorizationStatus) -> Bool {
|
|
32
|
+
if status == .authorizedAlways { return true }
|
|
33
|
+
#if !os(macOS)
|
|
34
|
+
if status == .authorizedWhenInUse { return true }
|
|
35
|
+
#endif
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private func handle(_ status: CLAuthorizationStatus) {
|
|
40
|
+
if Self.isAuthorized(status) { finish(.success(())); return }
|
|
41
|
+
switch status {
|
|
42
|
+
case .denied, .restricted:
|
|
43
|
+
finish(.failure(LocalMediaError("Location access is required to identify the glasses hotspot on Mac")))
|
|
44
|
+
case .notDetermined: break
|
|
45
|
+
default:
|
|
46
|
+
finish(.failure(LocalMediaError("Mac hotspot Location authorization is unavailable")))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private func finish(_ result: Result<Void, Error>) {
|
|
51
|
+
guard !finished else { return }
|
|
52
|
+
finished = true
|
|
53
|
+
manager?.delegate = nil
|
|
54
|
+
manager = nil
|
|
55
|
+
let reply = completion
|
|
56
|
+
completion = nil
|
|
57
|
+
reply?(result)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -4,7 +4,7 @@ import Network
|
|
|
4
4
|
import NetworkExtension
|
|
5
5
|
import OSLog
|
|
6
6
|
|
|
7
|
-
///
|
|
7
|
+
/// Shared hotspot association owner. Only local glasses traffic uses Wi-Fi.
|
|
8
8
|
public final class GlassesHotspotNetwork {
|
|
9
9
|
private let logger = Logger(subsystem: "com.mentra.glassesmedia", category: "hotspot")
|
|
10
10
|
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.hotspot")
|
|
@@ -12,81 +12,57 @@ public final class GlassesHotspotNetwork {
|
|
|
12
12
|
private var lastHotspotSSID: String?
|
|
13
13
|
/// The verified Wi-Fi interface and DHCP address. Every later check uses this exact pair.
|
|
14
14
|
private var binding: HotspotInterfaceBinding?
|
|
15
|
+
private var accessPurpose: HotspotAccessPurpose = .outgoingClient
|
|
15
16
|
private var gatewayAddress: String?
|
|
16
17
|
private var generation = 0
|
|
17
18
|
private var applying = false
|
|
18
19
|
private var ownsConfiguration = false
|
|
19
20
|
private var cancelled = false
|
|
20
21
|
private var joinReply: ((Result<String, Error>) -> Void)?
|
|
21
|
-
private var leaveReplies: [() -> Void] = []
|
|
22
|
+
private var leaveReplies: [(Result<Void, Error>) -> Void] = []
|
|
22
23
|
private var interfaceMonitors: [NWPathMonitor] = []
|
|
23
24
|
/// Wi-Fi identity learned since this join started; replaced on every join and cleared on leave.
|
|
24
25
|
private var wifiIdentity = HotspotWifiIdentity()
|
|
25
|
-
private var monitorsReported: [Bool] = []
|
|
26
|
-
private var interfaceReportsReady: (() -> Void)?
|
|
27
26
|
private var localAccess: LocalNetworkAccessRequest?
|
|
28
|
-
private var localAddress: String? {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
private var localAddress: String? {
|
|
28
|
+
binding?.address
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private let macWorker = DispatchQueue(label: "com.mentra.glassesmedia.mac-wifi")
|
|
32
|
+
private var macAssociation: MacHotspotAssociation?
|
|
33
|
+
private var macAuthorization: MacHotspotAuthorization?
|
|
34
|
+
private var ownershipToken: UUID?
|
|
35
|
+
private var finishingLeave = false
|
|
32
36
|
public var onLost: ((String) -> Void)?
|
|
33
37
|
public var onPermissionRequired: (() -> Void)?
|
|
34
38
|
public init() {}
|
|
35
39
|
|
|
36
|
-
public func join(ssid: String, passphrase: String, gateway: String? = nil, completion: @escaping (Result<String, Error>) -> Void) {
|
|
40
|
+
public func join(ssid: String, passphrase: String, gateway: String? = nil, purpose: HotspotAccessPurpose = .outgoingClient, completion: @escaping (Result<String, Error>) -> Void) {
|
|
37
41
|
queue.async {
|
|
38
42
|
guard self.ssid == nil, !self.applying else { completion(.failure(LocalMediaError("Previous hotspot session has not finished cleaning up"))); return }
|
|
39
43
|
if let gateway, !LocalMediaPolicy.isPrivate(gateway) {
|
|
40
44
|
completion(.failure(LocalMediaError("The glasses reported an invalid hotspot gateway"))); return
|
|
41
45
|
}
|
|
46
|
+
if ProcessInfo.processInfo.isiOSAppOnMac, gateway == nil {
|
|
47
|
+
completion(.failure(LocalMediaError("The glasses did not report their hotspot gateway"))); return
|
|
48
|
+
}
|
|
49
|
+
guard let token = HotspotNetworkOwnership.shared.acquire() else {
|
|
50
|
+
completion(.failure(LocalMediaError("The glasses hotspot is already in use"))); return
|
|
51
|
+
}
|
|
52
|
+
self.ownershipToken = token
|
|
42
53
|
self.generation += 1
|
|
43
54
|
let gen = self.generation
|
|
44
55
|
self.ssid = ssid
|
|
45
56
|
self.lastHotspotSSID = ssid
|
|
46
57
|
self.gatewayAddress = gateway
|
|
58
|
+
self.accessPurpose = purpose
|
|
47
59
|
self.cancelled = false
|
|
48
60
|
self.applying = false
|
|
49
61
|
self.ownsConfiguration = false
|
|
50
62
|
self.joinReply = completion
|
|
51
63
|
self.startInterfaceMonitors(generation: gen)
|
|
52
64
|
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
53
|
-
|
|
54
|
-
self.awaitInterfaceReports(generation: gen) {
|
|
55
|
-
#if MENTRA_E2E
|
|
56
|
-
if let raw = ProcessInfo.processInfo.environment["MENTRA_E2E_PREJOINED_HOTSPOT"] {
|
|
57
|
-
let selected = self.selectBinding()
|
|
58
|
-
guard !self.testLeaseConsumed,
|
|
59
|
-
let lease = try? JSONDecoder().decode(MacE2EHotspotLease.self, from: Data(raw.utf8)),
|
|
60
|
-
lease.matches(ssid: ssid, gateway: gateway, address: selected?.address, now: Date().timeIntervalSince1970),
|
|
61
|
-
let selected
|
|
62
|
-
else {
|
|
63
|
-
self.finishJoin(.failure(LocalMediaError("Mac E2E network lease is invalid, expired or already consumed")))
|
|
64
|
-
self.finishLeave()
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
self.testLeaseConsumed = true
|
|
68
|
-
self.bind(selected)
|
|
69
|
-
self.logger.notice("HOTSPOT_JOIN test_harness_connection native_association_untested")
|
|
70
|
-
self.waitForLocalAccess(address: selected.address, generation: gen)
|
|
71
|
-
return
|
|
72
|
-
}
|
|
73
|
-
#endif
|
|
74
|
-
NEHotspotNetwork.fetchCurrent { network in
|
|
75
|
-
self.queue.async {
|
|
76
|
-
guard gen == self.generation, !self.cancelled else { return }
|
|
77
|
-
let selected = self.selectBinding()
|
|
78
|
-
if let selected, LocalMediaPolicy.canReuseHotspot(requestedSSID: ssid, currentSSID: network?.ssid,
|
|
79
|
-
address: selected.address, gateway: gateway)
|
|
80
|
-
{
|
|
81
|
-
self.bind(selected)
|
|
82
|
-
self.logger.info("HOTSPOT_JOIN reuse_verified_macos_connection")
|
|
83
|
-
self.waitForLocalAccess(address: selected.address, generation: gen)
|
|
84
|
-
} else {
|
|
85
|
-
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
65
|
+
self.authorizeMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
90
66
|
} else {
|
|
91
67
|
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
92
68
|
}
|
|
@@ -95,6 +71,8 @@ public final class GlassesHotspotNetwork {
|
|
|
95
71
|
// Its response time is not a hotspot association timeout.
|
|
96
72
|
guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
|
|
97
73
|
self.cancelled = true
|
|
74
|
+
self.macAssociation?.cancel()
|
|
75
|
+
self.macAuthorization?.cancel()
|
|
98
76
|
self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
|
|
99
77
|
// apply() cannot be cancelled. Retain the reservation until its callback and remove the
|
|
100
78
|
// late configuration before another call is permitted to acquire the network.
|
|
@@ -104,9 +82,11 @@ public final class GlassesHotspotNetwork {
|
|
|
104
82
|
}
|
|
105
83
|
}
|
|
106
84
|
|
|
107
|
-
public func leave(completion: @escaping () -> Void) {
|
|
85
|
+
public func leave(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
108
86
|
queue.async {
|
|
109
87
|
self.cancelled = true
|
|
88
|
+
self.macAssociation?.cancel()
|
|
89
|
+
self.macAuthorization?.cancel()
|
|
110
90
|
self.leaveReplies.append(completion)
|
|
111
91
|
self.finishJoin(.failure(LocalMediaError("Hotspot join cancelled")))
|
|
112
92
|
if let ssid = self.ssid, self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
@@ -119,7 +99,16 @@ public final class GlassesHotspotNetwork {
|
|
|
119
99
|
public func verifiedAddress(completion: @escaping (String?) -> Void) {
|
|
120
100
|
queue.async {
|
|
121
101
|
guard let binding = self.binding, !self.cancelled, self.isBindingIntact(binding) else { completion(nil); return }
|
|
122
|
-
|
|
102
|
+
if let association = self.macAssociation {
|
|
103
|
+
let ssid = self.ssid
|
|
104
|
+
let gen = self.generation
|
|
105
|
+
self.macWorker.async {
|
|
106
|
+
let matched = (try? association.currentSSID()) == ssid
|
|
107
|
+
self.queue.async {
|
|
108
|
+
completion(gen == self.generation && !self.cancelled && matched && self.isBindingIntact(binding) ? binding.address : nil)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} else { completion(binding.address) }
|
|
123
112
|
}
|
|
124
113
|
}
|
|
125
114
|
|
|
@@ -205,6 +194,72 @@ public final class GlassesHotspotNetwork {
|
|
|
205
194
|
}
|
|
206
195
|
}
|
|
207
196
|
|
|
197
|
+
private func authorizeMacAssociation(ssid: String, passphrase: String, generation gen: Int) {
|
|
198
|
+
let authorization = MacHotspotAuthorization()
|
|
199
|
+
macAuthorization = authorization
|
|
200
|
+
authorization.start { result in
|
|
201
|
+
self.queue.async {
|
|
202
|
+
guard gen == self.generation, !self.cancelled else { return }
|
|
203
|
+
self.macAuthorization = nil
|
|
204
|
+
switch result {
|
|
205
|
+
case .success: self.applyMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
206
|
+
case let .failure(error):
|
|
207
|
+
self.finishJoin(.failure(error)); self.finishLeave()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private func applyMacAssociation(ssid: String, passphrase: String, generation gen: Int) {
|
|
214
|
+
let association = MacHotspotAssociation()
|
|
215
|
+
macAssociation = association
|
|
216
|
+
applying = true
|
|
217
|
+
macWorker.async {
|
|
218
|
+
let result = Result { try association.associate(ssid: ssid, password: passphrase) }
|
|
219
|
+
self.queue.async {
|
|
220
|
+
guard gen == self.generation else { return }
|
|
221
|
+
self.applying = false
|
|
222
|
+
if self.cancelled { self.finishLeave(); return }
|
|
223
|
+
switch result {
|
|
224
|
+
case let .success(name):
|
|
225
|
+
let index = Int(if_nametoindex(name))
|
|
226
|
+
guard index > 0 else {
|
|
227
|
+
self.finishJoin(.failure(LocalMediaError("Mac Wi-Fi interface disappeared during association")))
|
|
228
|
+
self.finishLeave(); return
|
|
229
|
+
}
|
|
230
|
+
self.wifiIdentity.observe([HotspotInterfaceReport(name: name, index: index, isWifi: true)])
|
|
231
|
+
self.waitForMacAddress(ssid: ssid, association: association, generation: gen, remaining: 60)
|
|
232
|
+
case let .failure(error):
|
|
233
|
+
self.finishJoin(.failure(error)); self.finishLeave()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private func waitForMacAddress(ssid: String, association: MacHotspotAssociation, generation gen: Int, remaining: Int) {
|
|
240
|
+
guard gen == generation, !cancelled else { return }
|
|
241
|
+
macWorker.async {
|
|
242
|
+
let membership = Result { try association.currentSSID() == ssid }
|
|
243
|
+
self.queue.async {
|
|
244
|
+
guard gen == self.generation, !self.cancelled else { return }
|
|
245
|
+
if case let .failure(error) = membership {
|
|
246
|
+
self.finishJoin(.failure(error)); self.finishLeave(); return
|
|
247
|
+
}
|
|
248
|
+
if (try? membership.get()) == true, let selected = self.selectBinding() {
|
|
249
|
+
self.bind(selected)
|
|
250
|
+
self.finishAssociation(address: selected.address, generation: gen)
|
|
251
|
+
} else if remaining > 0 {
|
|
252
|
+
self.queue.asyncAfter(deadline: .now() + 0.5) {
|
|
253
|
+
self.waitForMacAddress(ssid: ssid, association: association, generation: gen, remaining: remaining - 1)
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
self.finishJoin(.failure(LocalMediaError("Mac Wi-Fi has no verified glasses hotspot address")))
|
|
257
|
+
self.finishLeave()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
208
263
|
private func applyConfiguration(ssid: String, passphrase: String, generation gen: Int) {
|
|
209
264
|
let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
|
|
210
265
|
config.joinOnce = false
|
|
@@ -241,7 +296,7 @@ public final class GlassesHotspotNetwork {
|
|
|
241
296
|
// The selection applies the BLE gateway's client check when one was reported.
|
|
242
297
|
if network?.ssid == ssid, let selected = self.selectBinding() {
|
|
243
298
|
self.bind(selected)
|
|
244
|
-
self.
|
|
299
|
+
self.finishAssociation(address: selected.address, generation: gen)
|
|
245
300
|
} else if remaining > 0 {
|
|
246
301
|
self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
|
|
247
302
|
} else {
|
|
@@ -264,15 +319,12 @@ public final class GlassesHotspotNetwork {
|
|
|
264
319
|
let monitors = [NWPathMonitor(requiredInterfaceType: .wifi), NWPathMonitor()]
|
|
265
320
|
interfaceMonitors = monitors
|
|
266
321
|
wifiIdentity = HotspotWifiIdentity()
|
|
267
|
-
|
|
268
|
-
for (index, monitor) in monitors.enumerated() {
|
|
322
|
+
for monitor in monitors {
|
|
269
323
|
monitor.pathUpdateHandler = { [weak self] path in
|
|
270
324
|
guard let self, gen == self.generation else { return }
|
|
271
325
|
self.wifiIdentity.observe(path.availableInterfaces.map {
|
|
272
326
|
HotspotInterfaceReport(name: $0.name, index: $0.index, isWifi: $0.type == .wifi)
|
|
273
327
|
})
|
|
274
|
-
self.monitorsReported[index] = true
|
|
275
|
-
if !self.monitorsReported.contains(false) { self.fireInterfaceReportsReady() }
|
|
276
328
|
// This AP intentionally has no internet. Loss of its default internet path is not
|
|
277
329
|
// loss of the local link; check the verified interface and address instead.
|
|
278
330
|
if let binding = self.binding, !self.cancelled, !self.isBindingIntact(binding) {
|
|
@@ -283,25 +335,6 @@ public final class GlassesHotspotNetwork {
|
|
|
283
335
|
}
|
|
284
336
|
}
|
|
285
337
|
|
|
286
|
-
/// Continue once every monitor has reported, or after a short bound with whatever arrived.
|
|
287
|
-
private func awaitInterfaceReports(generation gen: Int, then proceed: @escaping () -> Void) {
|
|
288
|
-
interfaceReportsReady = { [weak self] in
|
|
289
|
-
guard let self, gen == self.generation, !self.cancelled else { return }
|
|
290
|
-
proceed()
|
|
291
|
-
}
|
|
292
|
-
if !monitorsReported.contains(false) { fireInterfaceReportsReady(); return }
|
|
293
|
-
queue.asyncAfter(deadline: .now() + 2) { [weak self] in
|
|
294
|
-
guard let self, gen == self.generation else { return }
|
|
295
|
-
self.fireInterfaceReportsReady()
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
private func fireInterfaceReportsReady() {
|
|
300
|
-
let ready = interfaceReportsReady
|
|
301
|
-
interfaceReportsReady = nil
|
|
302
|
-
ready?()
|
|
303
|
-
}
|
|
304
|
-
|
|
305
338
|
/// Every check samples interfaces first, so a detach or replacement between path updates
|
|
306
339
|
/// is recorded in the session identity before it is used.
|
|
307
340
|
private func observeInterfaces() -> HotspotInterfaceSnapshot {
|
|
@@ -338,6 +371,17 @@ public final class GlassesHotspotNetwork {
|
|
|
338
371
|
"other private IPv4=\(other.isEmpty ? "none" : other.sorted().joined(separator: ","))"
|
|
339
372
|
}
|
|
340
373
|
|
|
374
|
+
private func finishAssociation(address: String, generation gen: Int) {
|
|
375
|
+
// OTA serves incoming TCP. Its native listener proves server readiness; dialing
|
|
376
|
+
// the glasses' unrelated Gallery server would add a client permission dependency.
|
|
377
|
+
// TN3179: accepting incoming TCP does not require Local Network access.
|
|
378
|
+
if accessPurpose == .incomingServer {
|
|
379
|
+
finishJoin(.success(address))
|
|
380
|
+
} else {
|
|
381
|
+
waitForLocalAccess(address: address, generation: gen)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
341
385
|
private func waitForLocalAccess(address: String, generation gen: Int) {
|
|
342
386
|
let gateway = gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
|
|
343
387
|
let request = LocalNetworkAccessRequest(localAddress: address, gateway: gateway, queue: queue)
|
|
@@ -366,21 +410,50 @@ public final class GlassesHotspotNetwork {
|
|
|
366
410
|
}
|
|
367
411
|
|
|
368
412
|
private func finishLeave() {
|
|
413
|
+
guard !finishingLeave else { return }
|
|
414
|
+
finishingLeave = true
|
|
415
|
+
if let association = macAssociation {
|
|
416
|
+
association.cancel()
|
|
417
|
+
// This queue is also the association queue: cleanup cannot overtake
|
|
418
|
+
// a blocking scan/join, and process-wide ownership remains held.
|
|
419
|
+
macWorker.async {
|
|
420
|
+
let result = Result { try association.leave() }
|
|
421
|
+
self.queue.async {
|
|
422
|
+
switch result {
|
|
423
|
+
case .success:
|
|
424
|
+
self.macAssociation = nil
|
|
425
|
+
self.completeLeave()
|
|
426
|
+
case let .failure(error):
|
|
427
|
+
// Retain the owner when we cannot prove which association
|
|
428
|
+
// cleanup would disconnect. A later leave can retry safely.
|
|
429
|
+
self.finishingLeave = false
|
|
430
|
+
let replies = self.leaveReplies
|
|
431
|
+
self.leaveReplies.removeAll()
|
|
432
|
+
replies.forEach { $0(.failure(error)) }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} else { completeLeave() }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private func completeLeave() {
|
|
369
440
|
generation += 1
|
|
441
|
+
macAuthorization?.cancel(); macAuthorization = nil
|
|
370
442
|
localAccess?.cancel(); localAccess = nil
|
|
371
443
|
interfaceMonitors.forEach { $0.cancel() }
|
|
372
444
|
interfaceMonitors = []
|
|
373
445
|
wifiIdentity = HotspotWifiIdentity()
|
|
374
|
-
monitorsReported = []
|
|
375
|
-
interfaceReportsReady = nil
|
|
376
446
|
if let ssid, ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
377
447
|
ownsConfiguration = false
|
|
378
448
|
ssid = nil
|
|
379
449
|
binding = nil
|
|
380
450
|
gatewayAddress = nil
|
|
451
|
+
finishingLeave = false
|
|
452
|
+
if let token = ownershipToken { HotspotNetworkOwnership.shared.release(token) }
|
|
453
|
+
ownershipToken = nil
|
|
381
454
|
let replies = leaveReplies
|
|
382
455
|
leaveReplies.removeAll()
|
|
383
|
-
replies.forEach { $0() }
|
|
456
|
+
replies.forEach { $0(.success(())) }
|
|
384
457
|
}
|
|
385
458
|
|
|
386
459
|
/// Every attached interface's kernel index and link type (its AF_LINK row) and every IPv4
|
package/ios/GlassesMedia.podspec
CHANGED
|
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
|
|
|
14
14
|
s.dependency 'ExpoModulesCore'
|
|
15
15
|
# Keep in sync with the iOS-only @livekit/react-native-webrtc podspec patch.
|
|
16
16
|
s.dependency 'WebRTC-SDK', '144.7559.15'
|
|
17
|
-
s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'Network', 'NetworkExtension'
|
|
17
|
+
s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'CoreLocation', 'Network', 'NetworkExtension'
|
|
18
18
|
s.source_files = '*.{swift,h,m,mm}', 'CoreKit/Sources/GlassesMediaCore/*.swift'
|
|
19
19
|
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
|
|
20
20
|
end
|
|
@@ -4,11 +4,47 @@ import Foundation
|
|
|
4
4
|
public final class GlassesMediaRelayModule: Module {
|
|
5
5
|
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.relay")
|
|
6
6
|
private var session: ManagedRelaySession?
|
|
7
|
+
private let localHotspot = GlassesHotspotNetwork()
|
|
8
|
+
private var localHotspotOwner: String?
|
|
7
9
|
|
|
8
10
|
public func definition() -> ModuleDefinition {
|
|
9
11
|
Name("MentraGlassesMediaRelay")
|
|
10
12
|
Events("onRelayState")
|
|
11
13
|
|
|
14
|
+
AsyncFunction("joinLocalHotspot") { (id: String, ssid: String, password: String, gateway: String?, purpose: String, promise: Promise) in
|
|
15
|
+
self.queue.async {
|
|
16
|
+
guard self.localHotspotOwner == nil else {
|
|
17
|
+
promise.reject(LocalMediaError("Previous hotspot session has not been released")); return
|
|
18
|
+
}
|
|
19
|
+
guard let accessPurpose = HotspotAccessPurpose(rawValue: purpose) else {
|
|
20
|
+
promise.reject(LocalMediaError("Invalid hotspot access purpose")); return
|
|
21
|
+
}
|
|
22
|
+
self.localHotspotOwner = id
|
|
23
|
+
self.localHotspot.join(ssid: ssid, passphrase: password, gateway: gateway, purpose: accessPurpose) { result in
|
|
24
|
+
switch result {
|
|
25
|
+
case let .success(address): promise.resolve(address)
|
|
26
|
+
case let .failure(error): promise.reject(error)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
AsyncFunction("leaveLocalHotspot") { (id: String, promise: Promise) in
|
|
33
|
+
self.queue.async {
|
|
34
|
+
guard self.localHotspotOwner == id else { promise.resolve(nil); return }
|
|
35
|
+
self.localHotspot.leave { result in
|
|
36
|
+
self.queue.async {
|
|
37
|
+
switch result {
|
|
38
|
+
case .success:
|
|
39
|
+
if self.localHotspotOwner == id { self.localHotspotOwner = nil }
|
|
40
|
+
promise.resolve(nil)
|
|
41
|
+
case let .failure(error): promise.reject(error)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
12
48
|
AsyncFunction("prepare") { (options: [String: Any], promise: Promise) in
|
|
13
49
|
guard let id = options["attemptId"] as? String,
|
|
14
50
|
let ingest = options["ingestUrl"] as? String, let url = URL(string: ingest), url.scheme == "https",
|
|
@@ -38,16 +74,23 @@ public final class GlassesMediaRelayModule: Module {
|
|
|
38
74
|
AsyncFunction("stop") { (id: String, promise: Promise) in
|
|
39
75
|
self.queue.async {
|
|
40
76
|
guard let session = self.session, session.id == id else { promise.resolve(nil); return }
|
|
41
|
-
session.stop {
|
|
42
|
-
|
|
43
|
-
|
|
77
|
+
session.stop { result in
|
|
78
|
+
switch result {
|
|
79
|
+
case .success:
|
|
80
|
+
if self.session === session { self.session = nil }
|
|
81
|
+
promise.resolve(nil)
|
|
82
|
+
case let .failure(error): promise.reject(error)
|
|
83
|
+
}
|
|
44
84
|
}
|
|
45
85
|
}
|
|
46
86
|
}
|
|
47
87
|
|
|
48
88
|
OnDestroy {
|
|
49
89
|
self.queue.async {
|
|
50
|
-
self.session?.stop {
|
|
90
|
+
self.session?.stop { result in
|
|
91
|
+
if case .success = result { self.session = nil }
|
|
92
|
+
}
|
|
93
|
+
self.localHotspot.leave { _ in }
|
|
51
94
|
}
|
|
52
95
|
}
|
|
53
96
|
}
|
|
@@ -62,7 +105,7 @@ private final class ManagedRelaySession {
|
|
|
62
105
|
private var publisher: PhoneWhipPublisher?
|
|
63
106
|
private var stopped = false
|
|
64
107
|
private var stopFinished = false
|
|
65
|
-
private var stopReplies: [() -> Void] = []
|
|
108
|
+
private var stopReplies: [(Result<Void, Error>) -> Void] = []
|
|
66
109
|
private var ready: ((Result<String, Error>) -> Void)?
|
|
67
110
|
|
|
68
111
|
init(id: String, queue: DispatchQueue) {
|
|
@@ -116,10 +159,11 @@ private final class ManagedRelaySession {
|
|
|
116
159
|
let callback = ready; ready = nil; callback?(result)
|
|
117
160
|
}
|
|
118
161
|
|
|
119
|
-
func stop(completion: @escaping () -> Void) {
|
|
120
|
-
if stopFinished { completion(); return }
|
|
162
|
+
func stop(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
163
|
+
if stopFinished { completion(.success(())); return }
|
|
164
|
+
let stopping = !stopReplies.isEmpty
|
|
121
165
|
stopReplies.append(completion)
|
|
122
|
-
guard !
|
|
166
|
+
guard !stopping else { return }
|
|
123
167
|
stopped = true
|
|
124
168
|
finish(.failure(LocalMediaError("Relay cancelled")))
|
|
125
169
|
hotspot.onLost = nil
|
|
@@ -131,10 +175,14 @@ private final class ManagedRelaySession {
|
|
|
131
175
|
if let publisher {
|
|
132
176
|
group.enter(); publisher.stop { group.leave() }
|
|
133
177
|
}
|
|
134
|
-
|
|
178
|
+
var cleanupResult: Result<Void, Error> = .success(())
|
|
179
|
+
group.enter(); hotspot.leave { result in
|
|
180
|
+
self.queue.async { cleanupResult = result; group.leave() }
|
|
181
|
+
}
|
|
135
182
|
group.notify(queue: queue) {
|
|
136
|
-
self.receiver = nil; self.publisher = nil
|
|
137
|
-
|
|
183
|
+
self.receiver = nil; self.publisher = nil
|
|
184
|
+
if case .success = cleanupResult { self.stopFinished = true }
|
|
185
|
+
let replies = self.stopReplies; self.stopReplies.removeAll(); replies.forEach { $0(cleanupResult) }
|
|
138
186
|
}
|
|
139
187
|
}
|
|
140
188
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/glasses-media",
|
|
3
|
-
"version": "3.3.0-dev.
|
|
3
|
+
"version": "3.3.0-dev.870",
|
|
4
4
|
"description": "Shared native glasses media receivers and local network transport",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mentra Community",
|
|
7
7
|
"homepage": "https://github.com/Mentra-Community/MentraOS",
|
|
8
8
|
"main": "index.js",
|
|
9
|
+
"types": "index.d.ts",
|
|
9
10
|
"repository": {
|
|
10
11
|
"type": "git",
|
|
11
12
|
"url": "git+https://github.com/Mentra-Community/MentraOS.git",
|
|
@@ -14,8 +15,12 @@
|
|
|
14
15
|
"publishConfig": {
|
|
15
16
|
"access": "public"
|
|
16
17
|
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"expo-modules-core": "~55.0.25"
|
|
20
|
+
},
|
|
17
21
|
"files": [
|
|
18
22
|
"index.js",
|
|
23
|
+
"index.d.ts",
|
|
19
24
|
"expo-module.config.json",
|
|
20
25
|
"android/build.gradle",
|
|
21
26
|
"android/src/main",
|
|
@@ -1,20 +0,0 @@
|
|
|
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
|