@mentra/glasses-media 3.2.1-dev.274 → 3.2.1-dev.277
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/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +16 -6
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotInternetPolicy.swift +16 -0
- package/ios/GlassesHotspotNetwork.swift +53 -23
- package/ios/GlassesMediaRelayModule.swift +1 -1
- package/ios/LocalWhipIngestSource.swift +3 -1
- package/package.json +1 -1
|
@@ -104,9 +104,19 @@ class WhipIngestServer(
|
|
|
104
104
|
val bound = WhipIngestProtocol.Endpoint(address.hostAddress ?: "127.0.0.1", socket.localPort)
|
|
105
105
|
server = socket
|
|
106
106
|
endpoint = bound
|
|
107
|
-
|
|
107
|
+
val listenerClosed = CountDownLatch(1)
|
|
108
|
+
closed = listenerClosed
|
|
108
109
|
state = WhipIngestProtocol.State()
|
|
109
|
-
acceptThread = Thread({
|
|
110
|
+
acceptThread = Thread({
|
|
111
|
+
try {
|
|
112
|
+
acceptLoop(socket)
|
|
113
|
+
} finally {
|
|
114
|
+
// close() can return while a blocked accept still owns the native descriptor.
|
|
115
|
+
// Only the accept thread can signal that the listener has really released its port.
|
|
116
|
+
socket.close()
|
|
117
|
+
listenerClosed.countDown()
|
|
118
|
+
}
|
|
119
|
+
}, "whip-ingest-accept").apply {
|
|
110
120
|
isDaemon = true
|
|
111
121
|
start()
|
|
112
122
|
}
|
|
@@ -156,9 +166,11 @@ class WhipIngestServer(
|
|
|
156
166
|
|
|
157
167
|
/** Hard teardown barrier: no request may still create a peer after this returns. */
|
|
158
168
|
fun closeAndAwait(timeoutMs: Long = 15_000): Boolean {
|
|
169
|
+
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
|
|
159
170
|
closeNow()
|
|
160
171
|
connections.shutdownNow()
|
|
161
|
-
|
|
172
|
+
if (!awaitClosed(timeoutMs)) return false
|
|
173
|
+
return connections.awaitTermination((deadline - System.nanoTime()).coerceAtLeast(0), TimeUnit.NANOSECONDS)
|
|
162
174
|
}
|
|
163
175
|
|
|
164
176
|
/**
|
|
@@ -178,9 +190,7 @@ class WhipIngestServer(
|
|
|
178
190
|
endpoint = null
|
|
179
191
|
current
|
|
180
192
|
}
|
|
181
|
-
//
|
|
182
|
-
// latch that nothing will ever open again.
|
|
183
|
-
closed.countDown()
|
|
193
|
+
// The accept thread owns the close barrier; concurrent closes must not open it early.
|
|
184
194
|
if (socket == null) return
|
|
185
195
|
runCatching { socket.close() }
|
|
186
196
|
activeSockets.forEach { runCatching { it.close() } }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/// A usable default route is independent of the glasses' local-only Wi-Fi hotspot.
|
|
2
|
+
/// This classifies network paths; actual server reachability is established by the call.
|
|
3
|
+
public enum HotspotInternetPolicy {
|
|
4
|
+
public static func route(satisfied: Bool, cellular: Bool, ethernet: Bool,
|
|
5
|
+
restoredWifiSSID: String? = nil, glassesSSID: String? = nil) -> String?
|
|
6
|
+
{
|
|
7
|
+
guard satisfied else { return nil }
|
|
8
|
+
if cellular { return "cellular" }
|
|
9
|
+
if ethernet { return "ethernet" }
|
|
10
|
+
// Callers supply a Wi-Fi SSID only after releasing the glasses hotspot.
|
|
11
|
+
if let restoredWifiSSID, !restoredWifiSSID.isEmpty, restoredWifiSSID != glassesSSID {
|
|
12
|
+
return "wifi"
|
|
13
|
+
}
|
|
14
|
+
return nil
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -2,9 +2,11 @@ import Darwin
|
|
|
2
2
|
import Foundation
|
|
3
3
|
import Network
|
|
4
4
|
import NetworkExtension
|
|
5
|
+
import OSLog
|
|
5
6
|
|
|
6
7
|
/// Same persistent hotspot join as gallery (`joinOnce=false`). Only local traffic uses Wi-Fi.
|
|
7
8
|
public final class GlassesHotspotNetwork {
|
|
9
|
+
private let logger = Logger(subsystem: "com.mentra.glassesmedia", category: "hotspot")
|
|
8
10
|
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.hotspot")
|
|
9
11
|
private var ssid: String?
|
|
10
12
|
private var lastHotspotSSID: String?
|
|
@@ -31,21 +33,8 @@ public final class GlassesHotspotNetwork {
|
|
|
31
33
|
self.lastHotspotSSID = ssid
|
|
32
34
|
self.gatewayAddress = gateway
|
|
33
35
|
self.cancelled = false
|
|
34
|
-
self.applying = true
|
|
35
36
|
self.joinReply = completion
|
|
36
|
-
|
|
37
|
-
config.joinOnce = false
|
|
38
|
-
NEHotspotConfigurationManager.shared.apply(config) { error in
|
|
39
|
-
self.queue.async {
|
|
40
|
-
guard gen == self.generation else { return }
|
|
41
|
-
self.applying = false
|
|
42
|
-
if self.cancelled { self.finishLeave(); return }
|
|
43
|
-
if let error, (error as NSError).code != NEHotspotConfigurationError.alreadyAssociated.rawValue {
|
|
44
|
-
self.finishJoin(.failure(error)); self.finishLeave(); return
|
|
45
|
-
}
|
|
46
|
-
self.waitForAddress(ssid: ssid, generation: gen, remaining: 60)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
37
|
+
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
49
38
|
self.queue.asyncAfter(deadline: .now() + 60) {
|
|
50
39
|
guard gen == self.generation, self.joinReply != nil else { return }
|
|
51
40
|
self.cancelled = true
|
|
@@ -84,7 +73,13 @@ public final class GlassesHotspotNetwork {
|
|
|
84
73
|
guard let address = self.localAddress else { completion(false, "No joined hotspot"); return }
|
|
85
74
|
let gateway = self.gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
|
|
86
75
|
let parameters = NWParameters.tcp
|
|
87
|
-
|
|
76
|
+
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
77
|
+
// The Mac route probe rejects the Wi-Fi type constraint on an otherwise
|
|
78
|
+
// usable en0 route. Restrict this connection to the verified source IP.
|
|
79
|
+
parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(address), port: .any)
|
|
80
|
+
} else {
|
|
81
|
+
parameters.requiredInterfaceType = .wifi
|
|
82
|
+
}
|
|
88
83
|
let connection = NWConnection(host: NWEndpoint.Host(gateway), port: 8089, using: parameters)
|
|
89
84
|
var finished = false
|
|
90
85
|
let finish: (Bool, String) -> Void = { reachable, detail in
|
|
@@ -106,7 +101,7 @@ public final class GlassesHotspotNetwork {
|
|
|
106
101
|
}
|
|
107
102
|
}
|
|
108
103
|
|
|
109
|
-
public func awaitInternet(
|
|
104
|
+
public func awaitInternet(allowWifiAfterRelease: Bool = false, completion: @escaping (Bool, String) -> Void) {
|
|
110
105
|
queue.async {
|
|
111
106
|
let monitor = NWPathMonitor()
|
|
112
107
|
var finished = false
|
|
@@ -117,21 +112,56 @@ public final class GlassesHotspotNetwork {
|
|
|
117
112
|
completion(usable, detail)
|
|
118
113
|
}
|
|
119
114
|
monitor.pathUpdateHandler = { path in
|
|
120
|
-
if
|
|
115
|
+
if let route = HotspotInternetPolicy.route(satisfied: path.status == .satisfied,
|
|
116
|
+
cellular: path.usesInterfaceType(.cellular),
|
|
117
|
+
ethernet: path.usesInterfaceType(.wiredEthernet))
|
|
118
|
+
{
|
|
119
|
+
finish(true, route)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
121
122
|
// Once the hotspot is released, a return to the user's Wi-Fi is also valid.
|
|
122
123
|
// Do not mistake the departing glasses AP's local-only path for restored internet.
|
|
123
|
-
if
|
|
124
|
+
if allowWifiAfterRelease, path.status == .satisfied, path.usesInterfaceType(.wifi) {
|
|
124
125
|
NEHotspotNetwork.fetchCurrent { network in
|
|
125
126
|
self.queue.async {
|
|
126
|
-
guard let
|
|
127
|
-
|
|
127
|
+
guard let route = HotspotInternetPolicy.route(satisfied: true, cellular: false, ethernet: false,
|
|
128
|
+
restoredWifiSSID: network?.ssid, glassesSSID: self.lastHotspotSSID)
|
|
129
|
+
else { return }
|
|
130
|
+
finish(true, route)
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
}
|
|
131
134
|
}
|
|
132
135
|
monitor.start(queue: self.queue)
|
|
133
136
|
self.queue.asyncAfter(deadline: .now() + 15) {
|
|
134
|
-
finish(false,
|
|
137
|
+
finish(false, allowWifiAfterRelease ? "Internet did not return after leaving the glasses hotspot" : "Cellular or Ethernet internet did not become the default route")
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private func applyConfiguration(ssid: String, passphrase: String, generation gen: Int) {
|
|
143
|
+
let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
|
|
144
|
+
config.joinOnce = false
|
|
145
|
+
applying = true
|
|
146
|
+
logger.info("HOTSPOT_JOIN apply_start ios_on_mac=\(ProcessInfo.processInfo.isiOSAppOnMac)")
|
|
147
|
+
NEHotspotConfigurationManager.shared.apply(config) { error in
|
|
148
|
+
self.queue.async {
|
|
149
|
+
guard gen == self.generation else { return }
|
|
150
|
+
self.applying = false
|
|
151
|
+
if self.cancelled { self.finishLeave(); return }
|
|
152
|
+
if let error {
|
|
153
|
+
let nativeError = error as NSError
|
|
154
|
+
let alreadyAssociated = nativeError.domain == NEHotspotConfigurationErrorDomain &&
|
|
155
|
+
nativeError.code == NEHotspotConfigurationError.alreadyAssociated.rawValue
|
|
156
|
+
if !alreadyAssociated {
|
|
157
|
+
// Error identifiers are useful without credentials or full userInfo.
|
|
158
|
+
let underlying = nativeError.userInfo[NSUnderlyingErrorKey] as? NSError
|
|
159
|
+
self.logger.error("HOTSPOT_JOIN apply_failed domain=\(nativeError.domain, privacy: .public) code=\(nativeError.code) underlying_domain=\(underlying?.domain ?? "none", privacy: .public) underlying_code=\(underlying.map { String($0.code) } ?? "none", privacy: .public)")
|
|
160
|
+
self.finishJoin(.failure(error)); self.finishLeave(); return
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
self.logger.info("HOTSPOT_JOIN apply_accepted")
|
|
164
|
+
self.waitForAddress(ssid: ssid, generation: gen, remaining: 60)
|
|
135
165
|
}
|
|
136
166
|
}
|
|
137
167
|
}
|
|
@@ -163,10 +193,10 @@ public final class GlassesHotspotNetwork {
|
|
|
163
193
|
let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
|
|
164
194
|
self.monitor = monitor
|
|
165
195
|
monitor.pathUpdateHandler = { [weak self] _ in
|
|
166
|
-
guard let self, gen ==
|
|
196
|
+
guard let self, gen == generation, !self.cancelled else { return }
|
|
167
197
|
// This AP intentionally has no internet. Loss of its default internet path is not
|
|
168
198
|
// loss of the local link; use the actual interface address instead.
|
|
169
|
-
if Self.wifiAddress() !=
|
|
199
|
+
if Self.wifiAddress() != localAddress { onLost?("Glasses hotspot connection was lost") }
|
|
170
200
|
}
|
|
171
201
|
monitor.start(queue: queue)
|
|
172
202
|
}
|
|
@@ -83,7 +83,7 @@ private final class ManagedRelaySession {
|
|
|
83
83
|
self.hotspot.awaitInternet { usable, _ in
|
|
84
84
|
self.queue.async {
|
|
85
85
|
guard !self.stopped else { return }
|
|
86
|
-
guard usable else { self.finish(.failure(LocalMediaError("
|
|
86
|
+
guard usable else { self.finish(.failure(LocalMediaError("Connect cellular or Ethernet internet to stream through the glasses hotspot"))); return }
|
|
87
87
|
let publisher = PhoneWhipPublisher(endpoint: endpoint, captureAudio: captureAudio, bitrate: bitrate, onState: onState)
|
|
88
88
|
self.publisher = publisher
|
|
89
89
|
let receiver = LocalWhipIngestSource()
|
|
@@ -171,7 +171,9 @@ public final class LocalWhipIngestSource: NSObject, DecodedGlassesMediaSource {
|
|
|
171
171
|
publisherFailed: { [weak self] in self?.state == .failed }
|
|
172
172
|
)
|
|
173
173
|
self.server = server
|
|
174
|
-
|
|
174
|
+
// Keep the listener bound to the verified hotspot IP on iOS-on-Mac.
|
|
175
|
+
// Requiring the Wi-Fi interface type rejects that local route on this host.
|
|
176
|
+
server.start(address: address, wifiOnly: !ProcessInfo.processInfo.isiOSAppOnMac) { [weak self] result in
|
|
175
177
|
guard let self else { completion(.failure(LocalMediaError("Receiver released"))); return }
|
|
176
178
|
self.queue.async {
|
|
177
179
|
guard gen == self.generation, !self.stopping else { completion(.failure(LocalMediaError("Receiver cancelled"))); return }
|