@mentra/glasses-media 3.2.0-dev.266 → 3.2.1-beta.315
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 +8 -1
- package/android/src/main/java/com/mentra/glassesmedia/GlassesMediaRelayModule.kt +19 -2
- package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +15 -0
- package/android/src/main/java/com/mentra/glassesmedia/publisher/PhoneWhipPublisher.kt +4 -5
- package/android/src/main/java/com/mentra/glassesmedia/publisher/RelayAudioInput.kt +33 -0
- package/android/src/main/java/com/mentra/glassesmedia/publisher/RelayPcmInput.kt +25 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +20 -6
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotInternetPolicy.swift +16 -0
- 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/CoreKit/Sources/GlassesMediaCore/WhipOfferGate.swift +24 -0
- package/ios/GlassesHotspotNetwork.swift +128 -25
- package/ios/GlassesMediaRelayModule.swift +8 -1
- package/ios/LocalWhipIngestSource.swift +3 -1
- package/ios/PhoneWhipPublisher.swift +46 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,15 +37,22 @@ Before merging/releasing, exercise iPhone + glasses: first-use local-network per
|
|
|
37
37
|
`PhoneStreamCoordinator` provisions Cloudflare and owns subscribers for the entire route:
|
|
38
38
|
|
|
39
39
|
```text
|
|
40
|
-
Glasses -- WHIP over SoftAP --> shared receiver
|
|
40
|
+
Glasses -- video-only WHIP over SoftAP --> shared receiver --> phone WHIP publisher --> Cloudflare
|
|
41
|
+
Glasses MCU -- BLE LC3 --> phone PCM decoder ----------------------^
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
Only `ingest: "whip"` selects this route. Default SRT and explicit RTMP continue publishing directly from the glasses. WHIP never falls back to SRT/RTMP when Cloudflare omits its WHIP URL. The phone needs mobile data; the glasses need no internet Wi-Fi credentials. Livestreamer's Stream Here path skips its glasses Wi-Fi setup gate accordingly.
|
|
44
45
|
|
|
45
46
|
`MentraGlassesMediaRelay.prepare` joins the persistent hotspot, prepares both native peers, and returns a local WHIP URL for the BLE start command. Cloudflare credentials stay on the phone. Video queues hold at most one frame; decoded PCM is downmixed/resampled into a bounded 200ms, 48kHz mono queue. WebRTC re-encodes both tracks. No phone microphone is captured. Android uses the WebRTC library's external AudioRecord buffer mode; iOS uses a custom `RTCAudioDevice`.
|
|
46
47
|
|
|
48
|
+
On Android hosts with microphone pinning and native PCM input, managed streams use the same BLE LC3 capture path as ACS SoftAP calls. An engine-owned `livestream` microphone session requests continuous glasses PCM; it leaves the configured gain in force and disables the call-specific loudness gate. The glasses receive `captureAudio: false`, avoiding SoC microphone capture and audio encoding, while the phone publishes video plus LC3-derived audio to Cloudflare. Attempt-scoped callbacks and native PCM gating reject late audio after stop/retry. Explicit `captureAudio: false` disables audio entirely. iOS and older hosts retain WHIP audio, matching ACS capability gating. ACS's measured 170ms audio delay is specific to its outgoing pipeline and is not applied to the Cloudflare publisher without calibration.
|
|
49
|
+
|
|
47
50
|
Android's process-wide network inventory exposes both the scoped hotspot and real cellular handles during a relay. Per-factory masks keep the receiver on Wi-Fi and the publisher off Wi-Fi; signaling also uses the cellular network's socket factory and DNS. ACS retains its existing hotspot-only inventory. iOS uses corresponding per-factory masks after validating the cellular default route.
|
|
48
51
|
|
|
52
|
+
The iOS internet publisher posts its offer on the first server-reflexive candidate, complete ICE gathering, or a 1.5-second gather deadline, matching the glasses publisher's STUN policy. It posts once and ignores late triggers after stop. The local SoftAP receiver still waits for complete host-only gathering. Incident logs record the outgoing negotiation stage, candidate counts, HTTP result and first decoded video frame without SDP or publish credentials.
|
|
53
|
+
|
|
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
|
+
|
|
49
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 a hotspot reservation. 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.
|
|
50
57
|
|
|
51
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.
|
|
@@ -5,8 +5,11 @@ import android.os.Handler
|
|
|
5
5
|
import android.os.Looper
|
|
6
6
|
import android.os.PowerManager
|
|
7
7
|
import android.provider.Settings
|
|
8
|
+
import android.util.Base64
|
|
9
|
+
import android.util.Log
|
|
8
10
|
import com.mentra.glassesmedia.network.*
|
|
9
11
|
import com.mentra.glassesmedia.publisher.PhoneWhipPublisher
|
|
12
|
+
import com.mentra.glassesmedia.publisher.RelayPcmInput
|
|
10
13
|
import com.mentra.glassesmedia.source.*
|
|
11
14
|
import expo.modules.kotlin.Promise
|
|
12
15
|
import expo.modules.kotlin.modules.Module
|
|
@@ -22,6 +25,7 @@ class GlassesMediaRelayModule : Module() {
|
|
|
22
25
|
private var source: LocalWhipIngestSource? = null
|
|
23
26
|
private var publisher: PhoneWhipPublisher? = null
|
|
24
27
|
private var wakeLock: PowerManager.WakeLock? = null
|
|
28
|
+
private val pcmInput = RelayPcmInput()
|
|
25
29
|
|
|
26
30
|
override fun definition() = ModuleDefinition {
|
|
27
31
|
Name("MentraGlassesMediaRelay")
|
|
@@ -64,14 +68,21 @@ class GlassesMediaRelayModule : Module() {
|
|
|
64
68
|
val outgoing = PhoneWhipPublisher(context, endpoint, options["captureAudio"] != false,
|
|
65
69
|
(options["bitrate"] as? Number)?.toInt() ?: 2_000_000) { state, reason -> emit(id, state, reason) }
|
|
66
70
|
publisher = outgoing
|
|
71
|
+
val bleAudio = options["captureAudio"] != false && options["audioTransport"] == "ble-lc3"
|
|
72
|
+
if (bleAudio) pcmInput.attach(id, outgoing::onPcm)
|
|
73
|
+
Log.i("ManagedRelay", "Audio transport=${options["audioTransport"] ?: "whip"} attempt=$id")
|
|
67
74
|
val incoming = LocalWhipIngestSource(context, VideoFrameListener(outgoing::onVideoFrame),
|
|
68
75
|
PcmListener(outgoing::onPcm), scopedNetwork = scoped)
|
|
69
76
|
source = incoming
|
|
70
77
|
incoming.setStateListener { state, reason ->
|
|
71
78
|
if (state == SourceState.FAILED) emit(id, "failed", "Glasses receiver: $reason")
|
|
72
79
|
}
|
|
73
|
-
incoming.setPcmDeliveryEnabled(options["captureAudio"] != false)
|
|
74
|
-
|
|
80
|
+
incoming.setPcmDeliveryEnabled(options["captureAudio"] != false && !bleAudio)
|
|
81
|
+
// A listener created while pinned to cellular cannot reply to the glasses over Wi-Fi.
|
|
82
|
+
// Restore cellular before opening the outgoing publisher, including when the bind fails.
|
|
83
|
+
hold.withProcessUnpinned {
|
|
84
|
+
incoming.start(SourceConfig("", SourceKind.SOFTAP, scoped.localIpv4()))
|
|
85
|
+
}
|
|
75
86
|
wakeLock = context.getSystemService(PowerManager::class.java)
|
|
76
87
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Mentra:managed-relay").apply { acquire() }
|
|
77
88
|
outgoing.start()
|
|
@@ -83,6 +94,11 @@ class GlassesMediaRelayModule : Module() {
|
|
|
83
94
|
}
|
|
84
95
|
}
|
|
85
96
|
|
|
97
|
+
Function("pushOutgoingPcm") { attemptId: String, base64: String, sampleRate: Int, channels: Int ->
|
|
98
|
+
val bytes = try { Base64.decode(base64, Base64.DEFAULT) } catch (_: IllegalArgumentException) { null }
|
|
99
|
+
bytes != null && pcmInput.push(attemptId, bytes, sampleRate, channels)
|
|
100
|
+
}
|
|
101
|
+
|
|
86
102
|
AsyncFunction("stop") { attemptId: String, promise: Promise ->
|
|
87
103
|
worker.execute {
|
|
88
104
|
try {
|
|
@@ -100,6 +116,7 @@ class GlassesMediaRelayModule : Module() {
|
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
private fun cleanup() {
|
|
119
|
+
pcmInput.detach()
|
|
103
120
|
source?.setStateListener(null)
|
|
104
121
|
// Try every cleanup even if one fails. Keep the slot occupied on any failure.
|
|
105
122
|
var failure: Exception? = null
|
|
@@ -239,6 +239,21 @@ class InternetHold(private val context: Context) {
|
|
|
239
239
|
return@synchronized ok
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Create a SoftAP listener without inheriting the cellular socket mark, then restore the pin.
|
|
244
|
+
* Holding the lock prevents a network callback from re-pinning during the bind. The previous
|
|
245
|
+
* routing state is restored even when creating the listener fails.
|
|
246
|
+
*/
|
|
247
|
+
fun <T> withProcessUnpinned(bind: () -> T): T = synchronized(lock) {
|
|
248
|
+
val wasPinned = processPinned
|
|
249
|
+
unbindProcess()
|
|
250
|
+
try {
|
|
251
|
+
bind()
|
|
252
|
+
} finally {
|
|
253
|
+
if (wasPinned) bindProcessToCellular()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
242
257
|
/** Undo [bindProcessToCellular]. Safe to call when nothing is pinned, and safe to call twice. */
|
|
243
258
|
fun unbindProcess() = synchronized(lock) {
|
|
244
259
|
if (closed) return@synchronized
|
|
@@ -40,6 +40,7 @@ class PhoneWhipPublisher(
|
|
|
40
40
|
private val closeDone = CountDownLatch(1)
|
|
41
41
|
@Volatile private var cleanupFailure: Throwable? = null
|
|
42
42
|
private val pcm = RelayPcmBuffer()
|
|
43
|
+
private val audioInput = RelayAudioInput(pcm)
|
|
43
44
|
private var factory: PeerConnectionFactory? = null
|
|
44
45
|
private var egl: EglBase? = null
|
|
45
46
|
private var adm: JavaAudioDeviceModule? = null
|
|
@@ -78,12 +79,10 @@ class PhoneWhipPublisher(
|
|
|
78
79
|
adm = JavaAudioDeviceModule.builder(context)
|
|
79
80
|
.setInputSampleRate(48_000).setUseStereoInput(false)
|
|
80
81
|
.setUseHardwareAcousticEchoCanceler(false).setUseHardwareNoiseSuppressor(false)
|
|
81
|
-
.setAudioBufferCallback { buffer, _, channels, sampleRate,
|
|
82
|
-
|
|
83
|
-
else for (i in 0 until minOf(bytes, buffer.capacity())) buffer.put(i, 0)
|
|
84
|
-
System.nanoTime()
|
|
82
|
+
.setAudioBufferCallback { buffer, _, channels, sampleRate, _, _ ->
|
|
83
|
+
audioInput.read(buffer, channels, sampleRate)
|
|
85
84
|
}.createAudioDeviceModule().also {
|
|
86
|
-
//
|
|
85
|
+
// External mode has no blocking AudioRecord.read; our callback supplies the clock.
|
|
87
86
|
it.setAudioRecordEnabled(false)
|
|
88
87
|
}
|
|
89
88
|
factory = PeerConnectionFactory.builder()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.publisher
|
|
2
|
+
|
|
3
|
+
import java.nio.ByteBuffer
|
|
4
|
+
import java.util.concurrent.TimeUnit
|
|
5
|
+
|
|
6
|
+
/** Supplies the clock normally provided by a blocking AudioRecord.read, without using the mic. */
|
|
7
|
+
class RelayAudioInput(
|
|
8
|
+
private val pcm: RelayPcmBuffer,
|
|
9
|
+
private val nowNs: () -> Long = System::nanoTime,
|
|
10
|
+
private val sleepNs: (Long) -> Unit = { TimeUnit.NANOSECONDS.sleep(it) },
|
|
11
|
+
) {
|
|
12
|
+
private var nextFrameAtNs: Long? = null
|
|
13
|
+
|
|
14
|
+
/** Called only by WebRTC's audio recording thread, with a complete PCM16 frame to fill. */
|
|
15
|
+
fun read(buffer: ByteBuffer, channels: Int, sampleRate: Int): Long {
|
|
16
|
+
require(channels > 0 && sampleRate > 0 && buffer.capacity() >= channels * 2)
|
|
17
|
+
val durationNs = (buffer.capacity() / (channels * 2)).toLong() * 1_000_000_000L / sampleRate
|
|
18
|
+
val deadline = nextFrameAtNs ?: nowNs()
|
|
19
|
+
var now = nowNs()
|
|
20
|
+
while (now < deadline) {
|
|
21
|
+
sleepNs(deadline - now)
|
|
22
|
+
now = nowNs()
|
|
23
|
+
}
|
|
24
|
+
// Preserve cadence across small scheduling delays, but never replay a backlog after a stall.
|
|
25
|
+
nextFrameAtNs = if (now - deadline >= durationNs) now + durationNs else deadline + durationNs
|
|
26
|
+
|
|
27
|
+
// External-recording mode reports bytesRead=0, then sends buffer.capacity() bytes to native.
|
|
28
|
+
// Fill that entire frame; using bytesRead would send only the library's prefilled silence.
|
|
29
|
+
if (channels == 1 && sampleRate == 48_000) pcm.read(buffer, buffer.capacity())
|
|
30
|
+
else for (i in 0 until buffer.capacity()) buffer.put(i, 0)
|
|
31
|
+
return now
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.publisher
|
|
2
|
+
|
|
3
|
+
/** Gates JS microphone callbacks by attempt, without queueing audio behind network operations. */
|
|
4
|
+
class RelayPcmInput {
|
|
5
|
+
private var attemptId: String? = null
|
|
6
|
+
private var sink: ((ByteArray, Int, Int) -> Unit)? = null
|
|
7
|
+
|
|
8
|
+
@Synchronized fun attach(id: String, target: (ByteArray, Int, Int) -> Unit) {
|
|
9
|
+
attemptId = id
|
|
10
|
+
sink = target
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Synchronized fun push(id: String, bytes: ByteArray, sampleRate: Int, channels: Int): Boolean {
|
|
14
|
+
if (id != attemptId || bytes.isEmpty() || sampleRate !in 8_000..192_000 || channels !in 1..8) return false
|
|
15
|
+
val target = sink ?: return false
|
|
16
|
+
target(bytes, sampleRate, channels)
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Wait for any in-flight push before the owner closes the publisher. */
|
|
21
|
+
@Synchronized fun detach() {
|
|
22
|
+
attemptId = null
|
|
23
|
+
sink = null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -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() } }
|
|
@@ -203,6 +213,7 @@ class WhipIngestServer(
|
|
|
203
213
|
}
|
|
204
214
|
if (!registered) { runCatching { connection.close() }; return }
|
|
205
215
|
accepted.incrementAndGet()
|
|
216
|
+
SoftApTrace.stage("whip_socket_accepted")
|
|
206
217
|
// Each connection on its own thread: a negotiation blocks for the length of an ICE gather,
|
|
207
218
|
// and a DELETE arriving during one must not queue behind it.
|
|
208
219
|
try {
|
|
@@ -361,6 +372,7 @@ class WhipIngestServer(
|
|
|
361
372
|
val (method, target) = WhipIngestProtocol.parseRequestLine(
|
|
362
373
|
reader.readLine() ?: return null,
|
|
363
374
|
) ?: return null
|
|
375
|
+
SoftApTrace.stage("whip_request_line_read", "method" to method)
|
|
364
376
|
|
|
365
377
|
val headers = mutableMapOf<String, String>()
|
|
366
378
|
while (true) {
|
|
@@ -373,6 +385,7 @@ class WhipIngestServer(
|
|
|
373
385
|
|
|
374
386
|
val contentType = WhipIngestProtocol.headerValue(headers, "Content-Type")
|
|
375
387
|
val contentLength = WhipIngestProtocol.headerValue(headers, "Content-Length")?.toLongOrNull()
|
|
388
|
+
SoftApTrace.stage("whip_request_headers_read", "contentLength" to contentLength)
|
|
376
389
|
|
|
377
390
|
// Do not read a body we have already decided to refuse. decide() checks the length first for
|
|
378
391
|
// exactly this reason.
|
|
@@ -388,6 +401,7 @@ class WhipIngestServer(
|
|
|
388
401
|
val count = reader.read(body, read, body.size - read)
|
|
389
402
|
if (count < 0) break
|
|
390
403
|
read += count
|
|
404
|
+
SoftApTrace.stage("whip_request_body_read", "read" to read, "expected" to body.size)
|
|
391
405
|
}
|
|
392
406
|
return WhipIngestProtocol.Request(method, target, contentType, contentLength, String(body, 0, read))
|
|
393
407
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/// Internet WHIP uses the same bounded STUN gather as the glasses publisher.
|
|
2
|
+
/// The local, host-only SoftAP receiver must still wait for complete gathering.
|
|
3
|
+
public struct WhipOfferGate {
|
|
4
|
+
public enum Signal { case localDescriptionSet, serverReflexiveCandidate, gatheringComplete, deadline, stop }
|
|
5
|
+
|
|
6
|
+
private var localSet = false
|
|
7
|
+
private var ready = false
|
|
8
|
+
private var posted = false
|
|
9
|
+
private var stopped = false
|
|
10
|
+
|
|
11
|
+
public init() {}
|
|
12
|
+
|
|
13
|
+
/// True once per publisher, after both the description and a POST trigger exist.
|
|
14
|
+
public mutating func observe(_ signal: Signal) -> Bool {
|
|
15
|
+
switch signal {
|
|
16
|
+
case .localDescriptionSet: localSet = true
|
|
17
|
+
case .serverReflexiveCandidate, .gatheringComplete, .deadline: ready = true
|
|
18
|
+
case .stop: stopped = true
|
|
19
|
+
}
|
|
20
|
+
guard localSet, ready, !posted, !stopped else { return false }
|
|
21
|
+
posted = true
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -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?
|
|
@@ -12,11 +14,17 @@ public final class GlassesHotspotNetwork {
|
|
|
12
14
|
private var gatewayAddress: String?
|
|
13
15
|
private var generation = 0
|
|
14
16
|
private var applying = false
|
|
17
|
+
private var ownsConfiguration = false
|
|
15
18
|
private var cancelled = false
|
|
16
19
|
private var joinReply: ((Result<String, Error>) -> Void)?
|
|
17
20
|
private var leaveReplies: [() -> Void] = []
|
|
18
21
|
private var monitor: NWPathMonitor?
|
|
22
|
+
private var localAccess: LocalNetworkAccessRequest?
|
|
23
|
+
#if MENTRA_E2E
|
|
24
|
+
private var testLeaseConsumed = false
|
|
25
|
+
#endif
|
|
19
26
|
public var onLost: ((String) -> Void)?
|
|
27
|
+
public var onPermissionRequired: (() -> Void)?
|
|
20
28
|
public init() {}
|
|
21
29
|
|
|
22
30
|
public func join(ssid: String, passphrase: String, gateway: String? = nil, completion: @escaping (Result<String, Error>) -> Void) {
|
|
@@ -31,28 +39,58 @@ public final class GlassesHotspotNetwork {
|
|
|
31
39
|
self.lastHotspotSSID = ssid
|
|
32
40
|
self.gatewayAddress = gateway
|
|
33
41
|
self.cancelled = false
|
|
34
|
-
self.applying =
|
|
42
|
+
self.applying = false
|
|
43
|
+
self.ownsConfiguration = false
|
|
35
44
|
self.joinReply = completion
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
}
|
|
45
80
|
}
|
|
46
|
-
self.waitForAddress(ssid: ssid, generation: gen, remaining: 60)
|
|
47
81
|
}
|
|
82
|
+
} else {
|
|
83
|
+
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
48
84
|
}
|
|
49
85
|
self.queue.asyncAfter(deadline: .now() + 60) {
|
|
50
|
-
|
|
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 }
|
|
51
89
|
self.cancelled = true
|
|
52
90
|
self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
|
|
53
91
|
// apply() cannot be cancelled. Retain the reservation until its callback and remove the
|
|
54
92
|
// late configuration before another call is permitted to acquire the network.
|
|
55
|
-
NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid)
|
|
93
|
+
if self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
56
94
|
if !self.applying { self.finishLeave() }
|
|
57
95
|
}
|
|
58
96
|
}
|
|
@@ -63,7 +101,7 @@ public final class GlassesHotspotNetwork {
|
|
|
63
101
|
self.cancelled = true
|
|
64
102
|
self.leaveReplies.append(completion)
|
|
65
103
|
self.finishJoin(.failure(LocalMediaError("Hotspot join cancelled")))
|
|
66
|
-
if let ssid = self.ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
104
|
+
if let ssid = self.ssid, self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
67
105
|
if !self.applying { self.finishLeave() }
|
|
68
106
|
}
|
|
69
107
|
}
|
|
@@ -84,7 +122,13 @@ public final class GlassesHotspotNetwork {
|
|
|
84
122
|
guard let address = self.localAddress else { completion(false, "No joined hotspot"); return }
|
|
85
123
|
let gateway = self.gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
|
|
86
124
|
let parameters = NWParameters.tcp
|
|
87
|
-
|
|
125
|
+
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
126
|
+
// The Mac route probe rejects the Wi-Fi type constraint on an otherwise
|
|
127
|
+
// usable en0 route. Restrict this connection to the verified source IP.
|
|
128
|
+
parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(address), port: .any)
|
|
129
|
+
} else {
|
|
130
|
+
parameters.requiredInterfaceType = .wifi
|
|
131
|
+
}
|
|
88
132
|
let connection = NWConnection(host: NWEndpoint.Host(gateway), port: 8089, using: parameters)
|
|
89
133
|
var finished = false
|
|
90
134
|
let finish: (Bool, String) -> Void = { reachable, detail in
|
|
@@ -106,7 +150,7 @@ public final class GlassesHotspotNetwork {
|
|
|
106
150
|
}
|
|
107
151
|
}
|
|
108
152
|
|
|
109
|
-
public func awaitInternet(
|
|
153
|
+
public func awaitInternet(allowWifiAfterRelease: Bool = false, completion: @escaping (Bool, String) -> Void) {
|
|
110
154
|
queue.async {
|
|
111
155
|
let monitor = NWPathMonitor()
|
|
112
156
|
var finished = false
|
|
@@ -117,21 +161,57 @@ public final class GlassesHotspotNetwork {
|
|
|
117
161
|
completion(usable, detail)
|
|
118
162
|
}
|
|
119
163
|
monitor.pathUpdateHandler = { path in
|
|
120
|
-
if
|
|
164
|
+
if let route = HotspotInternetPolicy.route(satisfied: path.status == .satisfied,
|
|
165
|
+
cellular: path.usesInterfaceType(.cellular),
|
|
166
|
+
ethernet: path.usesInterfaceType(.wiredEthernet))
|
|
167
|
+
{
|
|
168
|
+
finish(true, route)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
121
171
|
// Once the hotspot is released, a return to the user's Wi-Fi is also valid.
|
|
122
172
|
// Do not mistake the departing glasses AP's local-only path for restored internet.
|
|
123
|
-
if
|
|
173
|
+
if allowWifiAfterRelease, path.status == .satisfied, path.usesInterfaceType(.wifi) {
|
|
124
174
|
NEHotspotNetwork.fetchCurrent { network in
|
|
125
175
|
self.queue.async {
|
|
126
|
-
guard let
|
|
127
|
-
|
|
176
|
+
guard let route = HotspotInternetPolicy.route(satisfied: true, cellular: false, ethernet: false,
|
|
177
|
+
restoredWifiSSID: network?.ssid, glassesSSID: self.lastHotspotSSID)
|
|
178
|
+
else { return }
|
|
179
|
+
finish(true, route)
|
|
128
180
|
}
|
|
129
181
|
}
|
|
130
182
|
}
|
|
131
183
|
}
|
|
132
184
|
monitor.start(queue: self.queue)
|
|
133
185
|
self.queue.asyncAfter(deadline: .now() + 15) {
|
|
134
|
-
finish(false,
|
|
186
|
+
finish(false, allowWifiAfterRelease ? "Internet did not return after leaving the glasses hotspot" : "Cellular or Ethernet internet did not become the default route")
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private func applyConfiguration(ssid: String, passphrase: String, generation gen: Int) {
|
|
192
|
+
let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
|
|
193
|
+
config.joinOnce = false
|
|
194
|
+
applying = true
|
|
195
|
+
ownsConfiguration = true
|
|
196
|
+
logger.info("HOTSPOT_JOIN apply_start ios_on_mac=\(ProcessInfo.processInfo.isiOSAppOnMac)")
|
|
197
|
+
NEHotspotConfigurationManager.shared.apply(config) { error in
|
|
198
|
+
self.queue.async {
|
|
199
|
+
guard gen == self.generation else { return }
|
|
200
|
+
self.applying = false
|
|
201
|
+
if self.cancelled { self.finishLeave(); return }
|
|
202
|
+
if let error {
|
|
203
|
+
let nativeError = error as NSError
|
|
204
|
+
let alreadyAssociated = nativeError.domain == NEHotspotConfigurationErrorDomain &&
|
|
205
|
+
nativeError.code == NEHotspotConfigurationError.alreadyAssociated.rawValue
|
|
206
|
+
if !alreadyAssociated {
|
|
207
|
+
// Error identifiers are useful without credentials or full userInfo.
|
|
208
|
+
let underlying = nativeError.userInfo[NSUnderlyingErrorKey] as? NSError
|
|
209
|
+
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)")
|
|
210
|
+
self.finishJoin(.failure(error)); self.finishLeave(); return
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
self.logger.info("HOTSPOT_JOIN apply_accepted")
|
|
214
|
+
self.waitForAddress(ssid: ssid, generation: gen, remaining: 60)
|
|
135
215
|
}
|
|
136
216
|
}
|
|
137
217
|
}
|
|
@@ -146,7 +226,7 @@ public final class GlassesHotspotNetwork {
|
|
|
146
226
|
{
|
|
147
227
|
self.localAddress = address
|
|
148
228
|
self.startMonitor(generation: gen)
|
|
149
|
-
self.
|
|
229
|
+
self.waitForLocalAccess(address: address, generation: gen)
|
|
150
230
|
} else if remaining > 0 {
|
|
151
231
|
self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
|
|
152
232
|
} else {
|
|
@@ -163,14 +243,35 @@ public final class GlassesHotspotNetwork {
|
|
|
163
243
|
let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
|
|
164
244
|
self.monitor = monitor
|
|
165
245
|
monitor.pathUpdateHandler = { [weak self] _ in
|
|
166
|
-
guard let self, gen ==
|
|
246
|
+
guard let self, gen == generation, !self.cancelled else { return }
|
|
167
247
|
// This AP intentionally has no internet. Loss of its default internet path is not
|
|
168
248
|
// loss of the local link; use the actual interface address instead.
|
|
169
|
-
if Self.wifiAddress() !=
|
|
249
|
+
if Self.wifiAddress() != localAddress { onLost?("Glasses hotspot connection was lost") }
|
|
170
250
|
}
|
|
171
251
|
monitor.start(queue: queue)
|
|
172
252
|
}
|
|
173
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
|
+
|
|
174
275
|
private func finishJoin(_ result: Result<String, Error>) {
|
|
175
276
|
let reply = joinReply
|
|
176
277
|
joinReply = nil
|
|
@@ -179,8 +280,10 @@ public final class GlassesHotspotNetwork {
|
|
|
179
280
|
|
|
180
281
|
private func finishLeave() {
|
|
181
282
|
generation += 1
|
|
283
|
+
localAccess?.cancel(); localAccess = nil
|
|
182
284
|
monitor?.cancel(); monitor = nil
|
|
183
|
-
if let ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
285
|
+
if let ssid, ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
286
|
+
ownsConfiguration = false
|
|
184
287
|
ssid = nil
|
|
185
288
|
localAddress = nil
|
|
186
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 }
|
|
@@ -83,7 +89,7 @@ private final class ManagedRelaySession {
|
|
|
83
89
|
self.hotspot.awaitInternet { usable, _ in
|
|
84
90
|
self.queue.async {
|
|
85
91
|
guard !self.stopped else { return }
|
|
86
|
-
guard usable else { self.finish(.failure(LocalMediaError("
|
|
92
|
+
guard usable else { self.finish(.failure(LocalMediaError("Connect cellular or Ethernet internet to stream through the glasses hotspot"))); return }
|
|
87
93
|
let publisher = PhoneWhipPublisher(endpoint: endpoint, captureAudio: captureAudio, bitrate: bitrate, onState: onState)
|
|
88
94
|
self.publisher = publisher
|
|
89
95
|
let receiver = LocalWhipIngestSource()
|
|
@@ -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() }
|
|
@@ -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 }
|
|
@@ -2,7 +2,7 @@ import CoreVideo
|
|
|
2
2
|
import Foundation
|
|
3
3
|
import WebRTC
|
|
4
4
|
|
|
5
|
-
/// ASG's
|
|
5
|
+
/// ASG's bounded STUN-gather WHIP negotiation, fed from the shared decoded receiver.
|
|
6
6
|
final class PhoneWhipPublisher: NSObject {
|
|
7
7
|
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.whip.publish")
|
|
8
8
|
private let frameSlot = DispatchSemaphore(value: 1)
|
|
@@ -17,7 +17,10 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
17
17
|
private var capturer: RTCVideoCapturer?
|
|
18
18
|
private var resource: URL?
|
|
19
19
|
private var posted = false
|
|
20
|
-
private var
|
|
20
|
+
private var offerGate = WhipOfferGate()
|
|
21
|
+
private var candidateCount = 0
|
|
22
|
+
private var phase = "creating offer"
|
|
23
|
+
private var firstFrameSeen = false
|
|
21
24
|
private var stopped = false
|
|
22
25
|
private var failed = false
|
|
23
26
|
private var connected = false
|
|
@@ -81,13 +84,19 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
81
84
|
self.queue.async {
|
|
82
85
|
guard !self.stopped else { return }
|
|
83
86
|
guard error == nil else { self.fail("WHIP local description failed"); return }
|
|
84
|
-
self.
|
|
87
|
+
self.phase = "gathering ICE"
|
|
88
|
+
self.maybePost(.localDescriptionSet)
|
|
89
|
+
if peer.iceGatheringState == .complete { self.maybePost(.gatheringComplete) }
|
|
90
|
+
// STUN gathering can stall longer than the entire start request.
|
|
91
|
+
// Match the glasses publisher's 1.5s cap; the remote ICE-lite
|
|
92
|
+
// candidates in the answer let this peer initiate connectivity.
|
|
93
|
+
self.queue.asyncAfter(deadline: .now() + 1.5) { self.maybePost(.deadline) }
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
}
|
|
88
97
|
}
|
|
89
98
|
self.queue.asyncAfter(deadline: .now() + 35) {
|
|
90
|
-
if !self.connected { self.fail("WHIP connection timed out") }
|
|
99
|
+
if !self.connected { self.fail("WHIP connection timed out while \(self.phase) (\(self.candidateCount) candidates)") }
|
|
91
100
|
}
|
|
92
101
|
}
|
|
93
102
|
}
|
|
@@ -97,6 +106,10 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
97
106
|
queue.async {
|
|
98
107
|
defer { self.frameSlot.signal() }
|
|
99
108
|
guard !self.stopped, let source = self.videoSource, let capturer = self.capturer else { return }
|
|
109
|
+
if !self.firstFrameSeen {
|
|
110
|
+
self.firstFrameSeen = true
|
|
111
|
+
self.onState("diagnostic", "First decoded glasses video frame reached the phone publisher")
|
|
112
|
+
}
|
|
100
113
|
self.lastTimestamp = max(Int64(ProcessInfo.processInfo.systemUptime * 1_000_000_000), self.lastTimestamp + 1)
|
|
101
114
|
let frame = RTCVideoFrame(buffer: RTCCVPixelBuffer(pixelBuffer: buffer), rotation: ._0, timeStampNs: self.lastTimestamp)
|
|
102
115
|
source.capturer(capturer, didCapture: frame)
|
|
@@ -107,9 +120,12 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
107
120
|
if captureAudio { audioDevice.pcm.push(data, sampleRate: rate, channels: channels) }
|
|
108
121
|
}
|
|
109
122
|
|
|
110
|
-
private func maybePost() {
|
|
111
|
-
guard !stopped, !
|
|
123
|
+
private func maybePost(_ signal: WhipOfferGate.Signal) {
|
|
124
|
+
guard !stopped, !failed, let peer, offerGate.observe(signal) else { return }
|
|
125
|
+
guard let offer = peer.localDescription else { fail("WHIP local description unavailable"); return }
|
|
112
126
|
posted = true
|
|
127
|
+
phase = "sending offer"
|
|
128
|
+
onState("diagnostic", "Posting WHIP offer (\(candidateCount) candidates)")
|
|
113
129
|
var request = URLRequest(url: endpoint)
|
|
114
130
|
request.httpMethod = "POST"
|
|
115
131
|
request.setValue("application/sdp", forHTTPHeaderField: "Content-Type")
|
|
@@ -128,9 +144,16 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
128
144
|
let data, let answer = String(data: data, encoding: .utf8), !answer.isEmpty
|
|
129
145
|
else {
|
|
130
146
|
if let location { self.delete(location) }
|
|
131
|
-
|
|
147
|
+
if let error = error as NSError? {
|
|
148
|
+
self.fail("WHIP signaling failed (\(error.domain) \(error.code))")
|
|
149
|
+
} else {
|
|
150
|
+
self.fail("WHIP server rejected publish (HTTP \(response?.statusCode ?? 0))")
|
|
151
|
+
}
|
|
152
|
+
return
|
|
132
153
|
}
|
|
133
154
|
self.resource = location
|
|
155
|
+
self.phase = "connecting ICE"
|
|
156
|
+
self.onState("diagnostic", "WHIP server accepted publish (HTTP 201)")
|
|
134
157
|
peer.setRemoteDescription(RTCSessionDescription(type: .answer, sdp: answer)) { error in
|
|
135
158
|
self.queue.async { if error != nil { self.fail("WHIP answer failed") } }
|
|
136
159
|
}
|
|
@@ -154,6 +177,7 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
154
177
|
queue.async {
|
|
155
178
|
if !self.stopped {
|
|
156
179
|
self.stopped = true
|
|
180
|
+
_ = self.offerGate.observe(.stop)
|
|
157
181
|
self.peer?.close()
|
|
158
182
|
// Drain the external audio callback while its native delegate/factory is still alive.
|
|
159
183
|
_ = self.audioDevice.terminateDevice()
|
|
@@ -176,6 +200,7 @@ extension PhoneWhipPublisher: RTCPeerConnectionDelegate {
|
|
|
176
200
|
func peerConnection(_ peerConnection: RTCPeerConnection, didChange state: RTCIceConnectionState) {
|
|
177
201
|
queue.async {
|
|
178
202
|
guard self.peer === peerConnection, !self.stopped else { return }
|
|
203
|
+
self.onState("diagnostic", "WHIP ICE state \(state.rawValue)")
|
|
179
204
|
switch state {
|
|
180
205
|
case .connected, .completed:
|
|
181
206
|
self.connected = true; self.disconnectGeneration += 1
|
|
@@ -192,11 +217,22 @@ extension PhoneWhipPublisher: RTCPeerConnectionDelegate {
|
|
|
192
217
|
}
|
|
193
218
|
}
|
|
194
219
|
|
|
195
|
-
func peerConnection(_ peerConnection: RTCPeerConnection, didChange
|
|
196
|
-
queue.async {
|
|
220
|
+
func peerConnection(_ peerConnection: RTCPeerConnection, didChange state: RTCIceGatheringState) {
|
|
221
|
+
queue.async {
|
|
222
|
+
guard self.peer === peerConnection, !self.stopped else { return }
|
|
223
|
+
self.onState("diagnostic", "WHIP ICE gathering state \(state.rawValue) (\(self.candidateCount) candidates)")
|
|
224
|
+
if state == .complete { self.maybePost(.gatheringComplete) }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {
|
|
229
|
+
queue.async {
|
|
230
|
+
guard self.peer === peerConnection, !self.stopped else { return }
|
|
231
|
+
self.candidateCount += 1
|
|
232
|
+
if candidate.sdp.contains(" typ srflx ") { self.maybePost(.serverReflexiveCandidate) }
|
|
233
|
+
}
|
|
197
234
|
}
|
|
198
235
|
|
|
199
|
-
func peerConnection(_: RTCPeerConnection, didGenerate _: RTCIceCandidate) {}
|
|
200
236
|
func peerConnection(_: RTCPeerConnection, didRemove _: [RTCIceCandidate]) {}
|
|
201
237
|
func peerConnection(_: RTCPeerConnection, didOpen _: RTCDataChannel) {}
|
|
202
238
|
}
|