@mentra/glasses-media 3.2.0-dev.265 → 3.2.1-beta.312

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 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 -- decoded video/PCM --> phone WHIP publisher --> Cloudflare
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
- incoming.start(SourceConfig("", SourceKind.SOFTAP, scoped.localIpv4()))
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, bytes, _ ->
82
- if (channels == 1 && sampleRate == 48_000) pcm.read(buffer, bytes)
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
- // WebRTC's external-buffer mode: paced callbacks without AudioRecord/microphone.
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
- closed = CountDownLatch(1)
107
+ val listenerClosed = CountDownLatch(1)
108
+ closed = listenerClosed
108
109
  state = WhipIngestProtocol.State()
109
- acceptThread = Thread({ acceptLoop(socket) }, "whip-ingest-accept").apply {
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
- return connections.awaitTermination(timeoutMs, TimeUnit.MILLISECONDS)
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
- // Counted down even on the early return: a second close must not leave a waiter parked on a
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 = true
42
+ self.applying = false
43
+ self.ownsConfiguration = false
35
44
  self.joinReply = completion
36
- let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
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
+ 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
- guard gen == self.generation, self.joinReply != nil else { return }
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
- parameters.requiredInterfaceType = .wifi
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(requireCellular: Bool = true, completion: @escaping (Bool, String) -> Void) {
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 path.status == .satisfied, path.usesInterfaceType(.cellular) { finish(true, "cellular") }
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 !requireCellular, path.status == .satisfied, path.usesInterfaceType(.wifi) {
173
+ if allowWifiAfterRelease, path.status == .satisfied, path.usesInterfaceType(.wifi) {
124
174
  NEHotspotNetwork.fetchCurrent { network in
125
175
  self.queue.async {
126
- guard let network, !network.ssid.isEmpty, network.ssid != self.lastHotspotSSID else { return }
127
- finish(true, "wifi")
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, requireCellular ? "Cellular internet did not become the default route" : "Internet did not return after leaving the glasses hotspot")
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.finishJoin(.success(address))
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 == self.generation, !self.cancelled else { return }
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() != self.localAddress { self.onLost?("Glasses hotspot connection was lost") }
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("Turn on phone mobile data to stream through the glasses hotspot"))); return }
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
- server.start(address: address) { [weak self] result in
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 full-ICE WHIP negotiation, fed from the shared decoded receiver. One peer per attempt.
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 localSet = false
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.localSet = true; self.maybePost()
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, !posted, localSet, let peer, peer.iceGatheringState == .complete, let offer = peer.localDescription else { return }
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
- self.fail("WHIP server rejected publish (HTTP \(response?.statusCode ?? 0))"); return
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 _: RTCIceGatheringState) {
196
- queue.async { if self.peer === peerConnection { self.maybePost() } }
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.2.0-dev.265",
3
+ "version": "3.2.1-beta.312",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",