@limrun/ui 0.14.1 → 0.14.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "next"
@@ -0,0 +1,34 @@
1
+ // Covers `withAuthenticationToken` only — the rest of <RemoteControl> is
2
+ // integration-tested (see vitest.config.ts).
3
+ //
4
+ // Assertions parse the result and read decoded parameter values, so they
5
+ // don't depend on whether a space serializes as `+` or `%20`.
6
+
7
+ import { describe, expect, test } from 'vitest';
8
+ import { withAuthenticationToken } from './remote-control';
9
+
10
+ describe('withAuthenticationToken', () => {
11
+ test('adds the token alongside query parameters already on the endpoint', () => {
12
+ const result = new URL(
13
+ withAuthenticationToken('wss://example.com/socket?transport=webrtc', 'instance-token'),
14
+ );
15
+
16
+ expect(result.searchParams.get('transport')).toBe('webrtc');
17
+ expect(result.searchParams.get('token')).toBe('instance-token');
18
+ });
19
+
20
+ test('keeps a token containing URL syntax as a single decoded value', () => {
21
+ const token = 'instance token&scope=preview';
22
+ const result = new URL(withAuthenticationToken('wss://example.com/socket?transport=webrtc', token));
23
+
24
+ expect(result.searchParams.get('token')).toBe(token);
25
+ expect(result.searchParams.get('transport')).toBe('webrtc');
26
+ expect(result.searchParams.get('scope')).toBeNull();
27
+ });
28
+
29
+ test('replaces an existing token instead of duplicating it', () => {
30
+ const result = new URL(withAuthenticationToken('wss://example.com/socket?token=stale', 'fresh-token'));
31
+
32
+ expect(result.searchParams.getAll('token')).toEqual(['fresh-token']);
33
+ });
34
+ });
@@ -237,23 +237,20 @@ interface RemoteControlProps {
237
237
  /**
238
238
  * iOS only: when true, capture the user's microphone via
239
239
  * `getUserMedia({ audio: true })` and stream it live into the
240
- * simulator's mock microphone. Unlike the camera (which is
241
- * demand-driven by apps inside the sim), the microphone is
242
- * user-driven: flip this prop from a button in your UI. The
243
- * component handles the browser permission prompt, the WebRTC
244
- * track attach (no SDP renegotiation — the audio slot is
245
- * pre-allocated) and the `microphoneStart`/`microphoneStop`
246
- * signaling to the host. Progress and failures are reported via
247
- * `onMicrophoneStateChange`. Ignored on Android instances.
240
+ * simulator's mock microphone. This is a manual override: iOS apps
241
+ * that actively consume microphone input also trigger capture
242
+ * automatically, like camera demand. The component handles the
243
+ * browser permission prompt, WebRTC track attachment, and
244
+ * `microphoneStart`/`microphoneStop` signaling. Progress and failures
245
+ * are reported via `onMicrophoneStateChange`. Ignored on Android.
248
246
  */
249
247
  microphoneEnabled?: boolean;
250
248
 
251
249
  /**
252
250
  * Fires whenever the live-microphone pipeline changes state:
253
- * after an enable attempt resolves (granted or denied), when the
254
- * host acknowledges or rejects the stream, when the browser
255
- * revokes the track mid-stream, and on disable. Use it to render
256
- * the mic button's on/off/error state.
251
+ * after a manual or guest-demanded attempt resolves, when the host
252
+ * acknowledges or rejects the stream, when the browser revokes the
253
+ * track mid-stream, and when capture stops.
257
254
  */
258
255
  onMicrophoneStateChange?: (state: MicrophoneState) => void;
259
256
  }
@@ -437,6 +434,13 @@ const detectPlatform = (url: string): DevicePlatform => {
437
434
  return 'ios';
438
435
  };
439
436
 
437
+ // The endpoint may already carry query parameters, and the token may contain URL syntax.
438
+ export const withAuthenticationToken = (url: string, token: string): string => {
439
+ const endpoint = new URL(url);
440
+ endpoint.searchParams.set('token', token);
441
+ return endpoint.toString();
442
+ };
443
+
440
444
  type DeviceConfig = {
441
445
  videoBorderRadiusMultiplier: number;
442
446
  loadingLogo: string;
@@ -635,14 +639,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
635
639
  packetsSent?: number;
636
640
  packetsLost?: number;
637
641
  } | null>(null);
638
- // User-driven outbound microphone state (iOS only). Unlike the
639
- // camera, the mic is toggled by the `microphoneEnabled` prop; the
640
- // track rides a pre-allocated sendonly audio transceiver so
641
- // enabling/disabling is just a `replaceTrack`, plus a
642
- // `microphoneStart`/`microphoneStop` WS signal so the host's
643
- // audio state machine switches its source.
642
+ // Outbound microphone state (iOS only). The manual prop and guest-driven
643
+ // microphoneRequest signals share one pre-allocated audio transceiver.
644
644
  const microphoneEnabledRef = useRef(microphoneEnabled);
645
645
  microphoneEnabledRef.current = microphoneEnabled;
646
+ const microphoneDemandActiveRef = useRef(false);
646
647
  const onMicrophoneStateChangeRef = useRef(onMicrophoneStateChange);
647
648
  onMicrophoneStateChangeRef.current = onMicrophoneStateChange;
648
649
  const outboundMicSenderRef = useRef<RTCRtpSender | null>(null);
@@ -1914,6 +1915,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1914
1915
  return next;
1915
1916
  };
1916
1917
 
1918
+ const isMicrophoneWanted = () => microphoneEnabledRef.current || microphoneDemandActiveRef.current;
1919
+
1920
+ const sendMicrophoneStart = (ws: WebSocket) => {
1921
+ const id = `ui-mic-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1922
+ micStartPendingIdRef.current = id;
1923
+ ws.send(JSON.stringify({ type: 'microphoneStart', id }));
1924
+ };
1925
+
1917
1926
  // Capture the user's microphone and stream it to the simulator's
1918
1927
  // mock microphone. Safe to call redundantly: bails when already
1919
1928
  // attached, when the connection isn't up yet (the ICE-connected
@@ -1928,7 +1937,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1928
1937
 
1929
1938
  const attachMicrophoneOp = async () => {
1930
1939
  const generation = micGenerationRef.current;
1931
- const isCurrent = () => generation === micGenerationRef.current && microphoneEnabledRef.current;
1940
+ const isCurrent = () => generation === micGenerationRef.current && isMicrophoneWanted();
1932
1941
  const reportMicError = (error: string) => {
1933
1942
  safeInvoke('onMicrophoneStateChange', onMicrophoneStateChangeRef.current, {
1934
1943
  active: false,
@@ -2000,9 +2009,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2000
2009
  if (outboundMicStreamRef.current !== stream) return;
2001
2010
  void detachMicrophone('microphone capture ended by the browser');
2002
2011
  };
2003
- const id = `ui-mic-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
2004
- micStartPendingIdRef.current = id;
2005
- ws.send(JSON.stringify({ type: 'microphoneStart', id }));
2012
+ sendMicrophoneStart(ws);
2006
2013
  safeInvoke('onMicrophoneStateChange', onMicrophoneStateChangeRef.current, {
2007
2014
  active: true,
2008
2015
  label: audioTrack.label || undefined,
@@ -2196,10 +2203,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2196
2203
  stopCameraStatsPoller();
2197
2204
  stopOutboundLocalStream();
2198
2205
  outboundCameraSenderRef.current = null;
2199
- // Same for the live microphone. No `microphoneStop` signal here —
2200
- // the WS is going away with the connection; if `microphoneEnabled`
2201
- // is still on, the post-reconnect hook re-attaches.
2206
+ // Same for the live microphone. Manual enablement survives reconnect;
2207
+ // guest demand is replayed by the host after it assigns the new session.
2202
2208
  ++micGenerationRef.current;
2209
+ microphoneDemandActiveRef.current = false;
2203
2210
  micStartPendingIdRef.current = null;
2204
2211
  if (outboundMicStreamRef.current) {
2205
2212
  stopMediaStream(outboundMicStreamRef.current);
@@ -2276,8 +2283,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2276
2283
  parsed.protocol === 'wss:' ? 'https:'
2277
2284
  : parsed.protocol === 'ws:' ? 'http:'
2278
2285
  : parsed.protocol;
2279
- parsed.searchParams.set('token', token);
2280
- probeUrl = parsed.toString();
2286
+ probeUrl = withAuthenticationToken(parsed.toString(), token);
2281
2287
  } catch {
2282
2288
  return false;
2283
2289
  }
@@ -2401,7 +2407,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2401
2407
  }, CONNECTION_SUCCESS_TIMEOUT_MS);
2402
2408
 
2403
2409
  try {
2404
- const ws = new WebSocket(`${url}?token=${token}`);
2410
+ const ws = new WebSocket(withAuthenticationToken(url, token));
2405
2411
  wsRef.current = ws;
2406
2412
 
2407
2413
  // Wait for WebSocket to connect
@@ -2764,7 +2770,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2764
2770
  clearIceDisconnectedGrace();
2765
2771
  // Re-attach the live microphone across reconnects (or apply a
2766
2772
  // prop that was flipped on before the connection was up).
2767
- if (microphoneEnabledRef.current && !outboundMicStreamRef.current) {
2773
+ if (isMicrophoneWanted() && !outboundMicStreamRef.current) {
2768
2774
  void attachMicrophone();
2769
2775
  }
2770
2776
  return;
@@ -3164,6 +3170,29 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3164
3170
  startCameraStatsPoller();
3165
3171
  break;
3166
3172
  }
3173
+ case 'microphoneRequest': {
3174
+ if (platform !== 'ios' || !outboundMicSenderRef.current) {
3175
+ break;
3176
+ }
3177
+ const active = message.active === true;
3178
+ microphoneDemandActiveRef.current = active;
3179
+ // eslint-disable-next-line no-console
3180
+ console.info('[RemoteControl] microphoneRequest received, active=', active);
3181
+ if (!active) {
3182
+ if (!microphoneEnabledRef.current) {
3183
+ await detachMicrophone();
3184
+ }
3185
+ break;
3186
+ }
3187
+ if (outboundMicStreamRef.current) {
3188
+ // A manual stream may already be attached. Re-select it on the
3189
+ // host because file playback can have replaced its source.
3190
+ sendMicrophoneStart(ws);
3191
+ break;
3192
+ }
3193
+ await attachMicrophone();
3194
+ break;
3195
+ }
3167
3196
  case 'microphoneStartResult': {
3168
3197
  if (micStartPendingIdRef.current === null || message.id !== micStartPendingIdRef.current) {
3169
3198
  break;
@@ -3306,11 +3335,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3306
3335
  }
3307
3336
  }, [cameraAspect]);
3308
3337
 
3309
- // Drive the live microphone from the `microphoneEnabled` prop.
3310
- // When flipped on before the connection is up, attachMicrophone
3311
- // bails and the ICE-connected handler picks it up instead.
3338
+ // Drive the manual override without interrupting guest-driven demand.
3312
3339
  useEffect(() => {
3313
- if (microphoneEnabled) {
3340
+ if (isMicrophoneWanted()) {
3314
3341
  void attachMicrophone();
3315
3342
  } else {
3316
3343
  void detachMicrophone();
package/vitest.config.ts CHANGED
@@ -12,8 +12,9 @@ import { defineConfig } from 'vitest/config';
12
12
  //
13
13
  // The actual <RemoteControl> component is intentionally NOT under unit
14
14
  // test here — its WebRTC plumbing is integration-tested via the demo +
15
- // staging instance. These tests cover the smaller, pure-logic modules:
16
- // `core/ax-tree.ts` and `core/ax-fetcher.ts`.
15
+ // staging instance. These tests cover the smaller, pure-logic pieces:
16
+ // `core/ax-tree.ts`, `core/ax-fetcher.ts` and the URL helper exported from
17
+ // `components/remote-control.tsx`.
17
18
  export default defineConfig({
18
19
  test: {
19
20
  environment: 'jsdom',