@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/dist/components/remote-control.d.ts +10 -12
- package/dist/index.cjs +1 -1
- package/dist/index.js +591 -572
- package/package.json +1 -1
- package/src/components/remote-control.test.ts +34 -0
- package/src/components/remote-control.tsx +60 -33
- package/vitest.config.ts +3 -2
package/package.json
CHANGED
|
@@ -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.
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
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
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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
|
-
//
|
|
639
|
-
//
|
|
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 &&
|
|
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
|
-
|
|
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.
|
|
2200
|
-
//
|
|
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.
|
|
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(
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
16
|
-
// `core/ax-tree.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',
|