@limrun/ui 0.15.1 → 0.17.0-rc.1

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.
Files changed (66) hide show
  1. package/README.md +59 -0
  2. package/dist/GLTFLoader-BKnsBFsj.js +1 -0
  3. package/dist/GLTFLoader-DW6wpvyR.mjs +1679 -0
  4. package/dist/components/duo-button-hinge.d.ts +16 -0
  5. package/dist/components/duo-controls.d.ts +21 -0
  6. package/dist/components/duo-flat-frame.d.ts +7 -0
  7. package/dist/components/duo-flat-geometry.d.ts +52 -0
  8. package/dist/components/duo-fold-motion.d.ts +27 -0
  9. package/dist/components/duo-fold-renderer.d.ts +19 -0
  10. package/dist/components/duo-fold-timing.d.ts +1 -0
  11. package/dist/components/duo-frame.d.ts +16 -0
  12. package/dist/components/duo-hardware.d.ts +20 -0
  13. package/dist/components/duo-model.d.ts +19 -0
  14. package/dist/components/duo-screen-handoff.d.ts +4 -0
  15. package/dist/components/duo-view.d.ts +4 -0
  16. package/dist/components/remote-control-framed.d.ts +2 -0
  17. package/dist/components/remote-control.d.ts +24 -1
  18. package/dist/core/duo.d.ts +63 -0
  19. package/dist/duo-fold-renderer-BXejNHcY.mjs +257 -0
  20. package/dist/duo-fold-renderer-DdltAnF7.js +53 -0
  21. package/dist/duo-frame-BzyYljix.mjs +26767 -0
  22. package/dist/duo-frame-DjfA4gG-.js +4059 -0
  23. package/dist/duo-view-COZg8ieG.js +1 -0
  24. package/dist/duo-view-o1yxIzxu.mjs +654 -0
  25. package/dist/frame-assets.d.ts +2 -0
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.css +1 -1
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +25 -2495
  30. package/dist/lite.cjs +1 -0
  31. package/dist/lite.d.ts +6 -0
  32. package/dist/lite.js +13 -0
  33. package/dist/remote-control-CQjSEVDF.mjs +2673 -0
  34. package/dist/remote-control-DVRXfCR2.js +1 -0
  35. package/package.json +13 -3
  36. package/src/components/duo-button-hinge.test.tsx +138 -0
  37. package/src/components/duo-button-hinge.ts +75 -0
  38. package/src/components/duo-controls.tsx +142 -0
  39. package/src/components/duo-flat-frame.tsx +145 -0
  40. package/src/components/duo-flat-geometry.test.ts +63 -0
  41. package/src/components/duo-flat-geometry.ts +97 -0
  42. package/src/components/duo-fold-motion.test.tsx +245 -0
  43. package/src/components/duo-fold-motion.tsx +192 -0
  44. package/src/components/duo-fold-renderer.test.ts +29 -0
  45. package/src/components/duo-fold-renderer.ts +397 -0
  46. package/src/components/duo-fold-timing.test.ts +44 -0
  47. package/src/components/duo-fold-timing.ts +9 -0
  48. package/src/components/duo-frame.tsx +579 -0
  49. package/src/components/duo-hardware.test.ts +61 -0
  50. package/src/components/duo-hardware.ts +61 -0
  51. package/src/components/duo-model.test.ts +243 -0
  52. package/src/components/duo-model.ts +607 -0
  53. package/src/components/duo-screen-handoff.ts +52 -0
  54. package/src/components/duo-view.test.tsx +104 -0
  55. package/src/components/duo-view.tsx +315 -0
  56. package/src/components/remote-control-framed.tsx +14 -0
  57. package/src/components/remote-control.css +310 -0
  58. package/src/components/remote-control.tsx +259 -59
  59. package/src/core/duo.test.ts +142 -0
  60. package/src/core/duo.ts +156 -0
  61. package/src/demo.tsx +16 -1
  62. package/src/frame-assets.ts +22 -0
  63. package/src/index.ts +5 -1
  64. package/src/lite.test.ts +30 -0
  65. package/src/lite.ts +30 -0
  66. package/vite.config.ts +5 -0
@@ -1,17 +1,21 @@
1
- import React, { useEffect, useRef, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
1
+ import React, {
2
+ useEffect,
3
+ useRef,
4
+ useMemo,
5
+ useState,
6
+ forwardRef,
7
+ useImperativeHandle,
8
+ lazy,
9
+ Suspense,
10
+ } from 'react';
2
11
  import { clsx } from 'clsx';
3
12
  import './remote-control.css';
4
13
 
14
+ import { createDisplayTouchMessage, type DuoState, type DuoOrientation } from '../core/duo';
15
+ const DuoView = lazy(() => import('./duo-view'));
16
+
5
17
  import { ANDROID_KEYS, AMOTION_EVENT, codeMap } from '../core/constants';
6
18
 
7
- import iphoneFrameImage from '../assets/iphone16pro_black_bg.webp';
8
- import pixelFrameImage from '../assets/pixel9_black.webp';
9
- import pixelFrameImageLandscape from '../assets/pixel9_black_landscape.webp';
10
- import pixelTabletFrameImage from '../assets/pixel_tablet_portrait.webp';
11
- import pixelTabletFrameImageLandscape from '../assets/pixel_tablet_landscape.webp';
12
- import iphoneFrameImageLandscape from '../assets/iphone16pro_black_landscape_bg.webp';
13
- import appleLogoSvg from '../assets/Apple_logo_white.svg';
14
- import androidBootImage from '../assets/android_boot.webp';
15
19
  import {
16
20
  createTouchControlMessage,
17
21
  createInjectKeycodeMessage,
@@ -66,6 +70,12 @@ export interface RemoteControlProps {
66
70
  // around the video. Defaults to true.
67
71
  showFrame?: boolean;
68
72
 
73
+ /** Enables the official iPhone Duo's native displays and interactive folding frame. */
74
+ deviceModel?: 'iphone-duo';
75
+
76
+ /** Optional prepared Duo GLB, supplied and licensed by the host application. */
77
+ duoModelUrl?: string;
78
+
69
79
  // When true, drops after a working session auto-reconnect instead of
70
80
  // surfacing the manual "Retry" button. Defaults to false.
71
81
  autoReconnect?: boolean;
@@ -82,6 +92,15 @@ export interface RemoteControlProps {
82
92
  * etc.). It is not called for ordinary network drops.
83
93
  */
84
94
  onTerminated?: () => void;
95
+ /**
96
+ * Device frame and boot-logo images. The default export bundles Limrun's
97
+ * frames; `@limrun/ui/lite` omits them so a frameless embed stays small.
98
+ */
99
+ assets?: RemoteControlAssets;
100
+ /**
101
+ * Coarse connection lifecycle, for status UI outside the component.
102
+ */
103
+ onConnectionStateChange?: (state: RemoteControlConnectionState) => void;
85
104
 
86
105
  /**
87
106
  * Enable the inspect overlay. When set, the component starts polling the
@@ -305,7 +324,7 @@ export interface ImperativeKeyboardEvent {
305
324
  export interface RemoteControlHandle {
306
325
  openUrl: (url: string) => void;
307
326
  sendKeyEvent: (event: ImperativeKeyboardEvent) => void;
308
- screenshot: () => Promise<ScreenshotData>;
327
+ screenshot: (display?: 'outer' | 'inner') => Promise<ScreenshotData>;
309
328
  terminateApp: (bundleId: string) => Promise<void>;
310
329
  reconnect: () => void;
311
330
 
@@ -388,18 +407,30 @@ export const withAuthenticationToken = (url: string, token: string): string => {
388
407
  return endpoint.toString();
389
408
  };
390
409
 
410
+ export type RemoteControlConnectionState =
411
+ | 'connecting'
412
+ | 'connected'
413
+ | 'reconnecting'
414
+ | 'failed'
415
+ | 'terminated';
416
+
417
+ export type RemoteControlPlatformAssets = {
418
+ frame?: string;
419
+ frameLandscape?: string;
420
+ tabletFrame?: string;
421
+ tabletFrameLandscape?: string;
422
+ loadingLogo?: string;
423
+ };
424
+
425
+ export type RemoteControlAssets = Partial<Record<DevicePlatform, RemoteControlPlatformAssets>>;
426
+
391
427
  type DeviceConfig = {
392
428
  videoBorderRadiusMultiplier: number;
393
- loadingLogo: string;
394
429
  loadingLogoSize: string;
395
430
  videoPosition: {
396
431
  portrait: { heightMultiplier?: number; widthMultiplier?: number };
397
432
  landscape: { heightMultiplier?: number; widthMultiplier?: number };
398
433
  };
399
- frame: {
400
- image: string;
401
- imageLandscape: string;
402
- };
403
434
  };
404
435
 
405
436
  const ANDROID_TABLET_VIDEO_WIDTH = 1920;
@@ -420,12 +451,7 @@ const isAndroidTabletVideo = (width: number, height: number): boolean =>
420
451
  // Video position percentages are relative to the frame image dimensions
421
452
  const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
422
453
  ios: {
423
- frame: {
424
- image: iphoneFrameImage,
425
- imageLandscape: iphoneFrameImageLandscape,
426
- },
427
454
  videoBorderRadiusMultiplier: 0.15,
428
- loadingLogo: appleLogoSvg,
429
455
  loadingLogoSize: '20%',
430
456
  // Video position as percentage of frame dimensions
431
457
  videoPosition: {
@@ -434,12 +460,7 @@ const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
434
460
  },
435
461
  },
436
462
  android: {
437
- frame: {
438
- image: pixelFrameImage,
439
- imageLandscape: pixelFrameImageLandscape,
440
- },
441
463
  videoBorderRadiusMultiplier: 0.13,
442
- loadingLogo: androidBootImage,
443
464
  loadingLogoSize: '40%',
444
465
  // Video position as percentage of frame dimensions
445
466
  videoPosition: {
@@ -488,8 +509,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
488
509
  sessionId: propSessionId,
489
510
  openUrl,
490
511
  showFrame = true,
512
+ deviceModel,
513
+ duoModelUrl,
491
514
  autoReconnect = false,
492
515
  onTerminated,
516
+ assets,
517
+ onConnectionStateChange,
493
518
  inspectMode,
494
519
  onAxSnapshotChange,
495
520
  onInspectSelectionChange,
@@ -507,6 +532,32 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
507
532
  const containerRef = useRef<HTMLDivElement>(null);
508
533
  const videoRef = useRef<HTMLVideoElement>(null);
509
534
  const frameRef = useRef<HTMLImageElement>(null);
535
+ const [detectedDuo, setDetectedDuo] = useState(false);
536
+ const isDuo = deviceModel === 'iphone-duo' || detectedDuo;
537
+ const innerVideoRef = useRef<HTMLVideoElement>(null);
538
+ const [duoState, setDuoState] = useState<DuoState | null>(null);
539
+ const [duoError, setDuoError] = useState<string>();
540
+ const duoRequests = useRef(
541
+ new Map<
542
+ string,
543
+ { resolve: () => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
544
+ >(),
545
+ );
546
+ const sendDuoControl = (type: string, parameters: object): Promise<void> =>
547
+ new Promise((resolve, reject) => {
548
+ const socket = wsRef.current;
549
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
550
+ reject(new Error('Simulator disconnected'));
551
+ return;
552
+ }
553
+ const id = crypto.randomUUID();
554
+ const timer = setTimeout(() => {
555
+ duoRequests.current.delete(id);
556
+ reject(new Error('Device control timed out'));
557
+ }, 5000);
558
+ duoRequests.current.set(id, { resolve, reject, timer });
559
+ socket.send(JSON.stringify({ type, id, ...parameters }));
560
+ });
510
561
  const [videoLoaded, setVideoLoaded] = useState(false);
511
562
  const [retryExhausted, setRetryExhausted] = useState(false);
512
563
  // Set once we've concluded the instance is permanently gone (its
@@ -702,6 +753,16 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
702
753
  onAxStatusChangeRef.current = onAxStatusChange;
703
754
  const onTerminatedRef = useRef(onTerminated);
704
755
  onTerminatedRef.current = onTerminated;
756
+ const onConnectionStateChangeRef = useRef(onConnectionStateChange);
757
+ onConnectionStateChangeRef.current = onConnectionStateChange;
758
+ const connectionStateRef = useRef<RemoteControlConnectionState | null>(null);
759
+ // Reports each transition once; callers get status text without tracking
760
+ // the retry machinery below.
761
+ const reportConnectionState = (next: RemoteControlConnectionState) => {
762
+ if (connectionStateRef.current === next) return;
763
+ connectionStateRef.current = next;
764
+ safeInvoke('onConnectionStateChange', onConnectionStateChangeRef.current, next);
765
+ };
705
766
 
706
767
  const inspectActive = inspectMode === true || inspectMode === 'hover-only';
707
768
  const inspectModeResolved: InspectMode = inspectMode === 'hover-only' ? 'hover-only' : 'select';
@@ -715,6 +776,17 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
715
776
 
716
777
  const platform = useMemo(() => detectPlatform(url), [url]);
717
778
  const config = deviceConfig[platform];
779
+ const platformAssets = assets?.[platform];
780
+ const frameImageSrc =
781
+ platform === 'android' && useAndroidTabletFrame ?
782
+ isLandscape ? platformAssets?.tabletFrameLandscape
783
+ : platformAssets?.tabletFrame
784
+ : isLandscape ? platformAssets?.frameLandscape
785
+ : platformAssets?.frame;
786
+ const loadingLogo = platformAssets?.loadingLogo;
787
+ // Without a frame image (the lite entry) the video lays out frameless
788
+ // whatever showFrame says.
789
+ const frameVisible = !isDuo && showFrame && !!frameImageSrc;
718
790
 
719
791
  const updateStatus = (message: string) => {
720
792
  // Use the wrapper for conditional logging
@@ -1213,6 +1285,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1213
1285
 
1214
1286
  // Unified handler for both mouse and touch interactions
1215
1287
  const handleInteraction = (event: React.MouseEvent | React.TouchEvent) => {
1288
+ if (isDuo) return;
1216
1289
  event.preventDefault();
1217
1290
  event.stopPropagation();
1218
1291
 
@@ -1662,6 +1735,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1662
1735
  const handleKeyboard = (event: React.KeyboardEvent) => {
1663
1736
  event.preventDefault();
1664
1737
  event.stopPropagation();
1738
+ const inputFocused =
1739
+ document.activeElement === videoRef.current ||
1740
+ (isDuo && document.activeElement === event.currentTarget);
1665
1741
  // Use the wrapper for conditional logging
1666
1742
  debugLog('Keyboard event:', {
1667
1743
  type: event.type,
@@ -1669,12 +1745,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1669
1745
  keyCode: event.keyCode,
1670
1746
  code: event.code,
1671
1747
  target: (event.target as HTMLElement).tagName,
1672
- focused: document.activeElement === videoRef.current,
1748
+ focused: inputFocused,
1673
1749
  });
1674
1750
 
1675
- if (document.activeElement !== videoRef.current) {
1751
+ if (!inputFocused) {
1676
1752
  // Use the wrapper for conditional warning
1677
- debugWarn('Video element not focused, skipping keyboard event');
1753
+ debugWarn('Simulator input not focused, skipping keyboard event');
1678
1754
  return;
1679
1755
  }
1680
1756
 
@@ -1809,6 +1885,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1809
1885
  firstFrameShownRef.current = true;
1810
1886
  stopRequestFrameLoop();
1811
1887
  setVideoLoaded(true);
1888
+ reportConnectionState('connected');
1812
1889
  };
1813
1890
 
1814
1891
  // Stop every track on a MediaStream and release the device handle.
@@ -2327,6 +2404,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2327
2404
  };
2328
2405
 
2329
2406
  const teardownConnection = () => {
2407
+ for (const request of duoRequests.current.values()) {
2408
+ clearTimeout(request.timer);
2409
+ request.reject(new Error('Simulator disconnected'));
2410
+ }
2411
+ duoRequests.current.clear();
2412
+ setDuoState(null);
2413
+ if (innerVideoRef.current) innerVideoRef.current.srcObject = null;
2330
2414
  clearConnectionSuccessTimeout();
2331
2415
  clearIceDisconnectedGrace();
2332
2416
  stopRequestFrameLoop();
@@ -2454,6 +2538,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2454
2538
  clearIceDisconnectedGrace();
2455
2539
  teardownConnection();
2456
2540
  updateStatus('Instance terminated');
2541
+ reportConnectionState('terminated');
2457
2542
  safeInvoke('onTerminated', onTerminatedRef.current);
2458
2543
  };
2459
2544
 
@@ -2489,6 +2574,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2489
2574
  if (!autoReconnectRef.current) {
2490
2575
  updateStatus(`Connection failed after it was established: ${reason}`);
2491
2576
  setRetryExhausted(true);
2577
+ reportConnectionState('failed');
2492
2578
  teardownConnection();
2493
2579
  return;
2494
2580
  }
@@ -2504,11 +2590,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2504
2590
  if (nextAttempt >= MAX_CONNECTION_ATTEMPTS) {
2505
2591
  updateStatus(`Connection failed after ${MAX_CONNECTION_ATTEMPTS} attempts: ${reason}`);
2506
2592
  setRetryExhausted(true);
2593
+ reportConnectionState('failed');
2507
2594
  teardownConnection();
2508
2595
  return;
2509
2596
  }
2510
2597
 
2511
2598
  updateStatus(`Retrying connection (${nextAttempt + 1}/${MAX_CONNECTION_ATTEMPTS})`);
2599
+ reportConnectionState('reconnecting');
2512
2600
  teardownConnection();
2513
2601
  retryTimeoutRef.current = window.setTimeout(() => {
2514
2602
  retryTimeoutRef.current = undefined;
@@ -2528,6 +2616,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2528
2616
  connectionAttemptRef.current = attemptNumber;
2529
2617
  controlChannelOpenedRef.current = false;
2530
2618
  setRetryExhausted(false);
2619
+ // A drop after an established session restarts the attempt budget at 0
2620
+ // while staying 'reconnecting'; only a fresh start reports 'connecting'.
2621
+ if (attemptNumber === 0 && connectionStateRef.current !== 'reconnecting')
2622
+ reportConnectionState('connecting');
2531
2623
  clearScheduledRetry();
2532
2624
  clearConnectionSuccessTimeout();
2533
2625
  stopRequestFrameLoop();
@@ -2609,25 +2701,46 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2609
2701
  const rtcConfigPromise = new Promise<{
2610
2702
  rtcConfiguration: RTCConfiguration;
2611
2703
  cameraSupported: boolean;
2704
+ foldSupported: boolean;
2612
2705
  }>((resolve, reject) => {
2613
- const timeoutId = window.setTimeout(() => reject(new Error('RTCConfiguration timeout')), 30000);
2706
+ const settle = (callback: () => void) => {
2707
+ window.clearTimeout(timeoutId);
2708
+ ws.removeEventListener('message', messageHandler);
2709
+ callback();
2710
+ };
2711
+ const timeoutId = window.setTimeout(
2712
+ () => settle(() => reject(new Error('RTCConfiguration timeout'))),
2713
+ 30000,
2714
+ );
2614
2715
 
2615
2716
  const messageHandler = (event: MessageEvent) => {
2717
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
2718
+ return;
2719
+ }
2616
2720
  try {
2617
2721
  const message = JSON.parse(event.data);
2618
- if (message.type === 'rtcConfiguration') {
2619
- window.clearTimeout(timeoutId);
2620
- ws.removeEventListener('message', messageHandler);
2621
- resolve({
2622
- rtcConfiguration: message.rtcConfiguration,
2623
- cameraSupported: message.cameraSupported === true,
2624
- });
2722
+ if (
2723
+ message.type === 'signalingError' &&
2724
+ message.requestType === 'requestRtcConfiguration' &&
2725
+ message.sessionId === sessionId
2726
+ ) {
2727
+ const reason =
2728
+ typeof message.error === 'string' ?
2729
+ message.error
2730
+ : 'Unable to load streaming configuration';
2731
+ settle(() => reject(new Error(reason)));
2732
+ } else if (message.type === 'rtcConfiguration') {
2733
+ settle(() =>
2734
+ resolve({
2735
+ rtcConfiguration: message.rtcConfiguration,
2736
+ cameraSupported: message.cameraSupported === true,
2737
+ foldSupported: message.foldSupported === true,
2738
+ }),
2739
+ );
2625
2740
  }
2626
2741
  } catch (e) {
2627
- window.clearTimeout(timeoutId);
2628
- ws.removeEventListener('message', messageHandler);
2629
2742
  console.error('Error handling RTC configuration:', e);
2630
- reject(e);
2743
+ settle(() => reject(e));
2631
2744
  }
2632
2745
  };
2633
2746
 
@@ -2640,16 +2753,23 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2640
2753
  );
2641
2754
  });
2642
2755
 
2643
- const { rtcConfiguration: rtcConfig, cameraSupported: serverCameraSupported } =
2644
- await rtcConfigPromise;
2756
+ const {
2757
+ rtcConfiguration: rtcConfig,
2758
+ cameraSupported: serverCameraSupported,
2759
+ foldSupported,
2760
+ } = await rtcConfigPromise;
2645
2761
  if (!isCurrentAttempt() || wsRef.current !== ws) {
2646
2762
  return;
2647
2763
  }
2648
2764
 
2765
+ const isDuo = deviceModel === 'iphone-duo' || foldSupported;
2766
+ setDetectedDuo(isDuo);
2649
2767
  const peerConnection = new RTCPeerConnection(rtcConfig);
2650
2768
  peerConnectionRef.current = peerConnection;
2651
2769
  peerConnection.addTransceiver('audio', { direction: 'recvonly' });
2652
2770
  const videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly' });
2771
+ const duoTransceiver =
2772
+ isDuo ? peerConnection.addTransceiver('video', { direction: 'recvonly' }) : null;
2653
2773
 
2654
2774
  // iOS always supports camera/mic injection; Android servers
2655
2775
  // advertise it through `cameraSupported` (Android 15 and newer).
@@ -2703,6 +2823,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2703
2823
  return getCodecPriority(a) - getCodecPriority(b);
2704
2824
  });
2705
2825
  videoTransceiver.setCodecPreferences(sortedCodecs);
2826
+ duoTransceiver?.setCodecPreferences(sortedCodecs);
2706
2827
  debugLog('Set codec preferences:', sortedCodecs.map((c) => c.mimeType).join(', '));
2707
2828
  }
2708
2829
  }
@@ -2774,6 +2895,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2774
2895
  controlChannelOpenedRef.current = true;
2775
2896
  clearConnectionSuccessTimeout();
2776
2897
  updateStatus('Control channel opened');
2898
+ if (isDuo) ws.send(JSON.stringify({ type: 'getFoldState', id: 'duo-capabilities' }));
2777
2899
 
2778
2900
  // Spin up the AX fetcher now that we have a stable WS + control
2779
2901
  // channel. The fetcher's send function reuses this WS; it stops
@@ -2942,7 +3064,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2942
3064
  updateStatus('Received remote track: ' + event.track.kind);
2943
3065
  if (event.track.kind === 'video' && videoRef.current) {
2944
3066
  debugLog(`[${new Date().toISOString()}] Video track received:`, event.track);
2945
- videoRef.current.srcObject = event.streams[0];
3067
+ const inner =
3068
+ event.track.id === 'duo-inner' || event.streams.some((stream) => stream.id === 'duo-inner');
3069
+ const target = inner ? innerVideoRef.current : videoRef.current;
3070
+ if (target) {
3071
+ target.srcObject =
3072
+ isDuo ? new MediaStream([event.track]) : event.streams[0] ?? new MediaStream([event.track]);
3073
+ void target.play().catch(() => undefined);
3074
+ }
2946
3075
  }
2947
3076
  };
2948
3077
 
@@ -2986,6 +3115,36 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2986
3115
  }
2987
3116
  updateStatus('Received: ' + message.type);
2988
3117
  switch (message.type) {
3118
+ case 'performActionsResult':
3119
+ case 'foldStateResult':
3120
+ case 'foldStateChanged': {
3121
+ if (!isDuo) break;
3122
+ if (message.state) {
3123
+ setDuoState(message.state);
3124
+ setDuoError(undefined);
3125
+ } else if (message.id === 'duo-capabilities')
3126
+ setDuoError(message.error || 'This instance does not support native iPhone Duo displays.');
3127
+ const request = duoRequests.current.get(message.id);
3128
+ if (request) {
3129
+ clearTimeout(request.timer);
3130
+ duoRequests.current.delete(message.id);
3131
+ if (message.error) request.reject(new Error(message.error));
3132
+ else request.resolve();
3133
+ }
3134
+ break;
3135
+ }
3136
+ case 'signalingError': {
3137
+ if (message.requestType !== 'offer' || message.sessionId !== sessionId) {
3138
+ break;
3139
+ }
3140
+ const reason =
3141
+ typeof message.error === 'string' ?
3142
+ message.error
3143
+ : 'Unable to establish the streaming connection';
3144
+ updateStatus('Error: ' + reason);
3145
+ scheduleRetry(reason, generation);
3146
+ break;
3147
+ }
2989
3148
  case 'answer':
2990
3149
  if (!peerConnectionRef.current || peerConnectionRef.current !== peerConnection) {
2991
3150
  updateStatus('No peer connection, skipping answer');
@@ -3178,6 +3337,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3178
3337
  ws.send(
3179
3338
  JSON.stringify({
3180
3339
  type: 'offer',
3340
+ ...(isDuo ? { displayStreams: true } : {}),
3181
3341
  sdp: offer.sdp,
3182
3342
  sessionId: sessionId,
3183
3343
  }),
@@ -3254,7 +3414,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3254
3414
  // connection effect doesn't need to bounce when the camera
3255
3415
  // turns on/off — no SDP-affecting change.
3256
3416
  // eslint-disable-next-line react-hooks/exhaustive-deps
3257
- }, [url, token, propSessionId]);
3417
+ }, [url, token, propSessionId, deviceModel]);
3258
3418
 
3259
3419
  // Recompute the inspect-overlay geometry (container-local pixel rect of
3260
3420
  // the actually-rendered video content) from the current mapping context.
@@ -3296,7 +3456,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3296
3456
  const updateVideoPosition = () => {
3297
3457
  // If no frame, just refresh overlay geometry; no inset/letterbox math
3298
3458
  // is needed since the video element is its own size.
3299
- if (!showFrame || !frame) {
3459
+ if (!frameVisible || !frame) {
3300
3460
  setVideoStyle({});
3301
3461
  recomputeOverlayGeometry();
3302
3462
  return;
@@ -3369,7 +3529,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3369
3529
  video.removeEventListener('resize', bumpOnResize);
3370
3530
  if (frame) frame.removeEventListener('load', updateVideoPosition);
3371
3531
  };
3372
- }, [config, showFrame]);
3532
+ }, [config, frameVisible]);
3373
3533
 
3374
3534
  // Start/stop the AX poller and reset inspect state when inspect mode
3375
3535
  // toggles. Connection state is independent: the fetcher gets created on
@@ -3490,7 +3650,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3490
3650
  sendBinaryControlMessage(message);
3491
3651
  }
3492
3652
  },
3493
- screenshot: (): Promise<ScreenshotData> => {
3653
+ screenshot: (display?: 'outer' | 'inner'): Promise<ScreenshotData> => {
3494
3654
  return new Promise<ScreenshotData>((resolve, reject) => {
3495
3655
  if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
3496
3656
  debugWarn('WebSocket not open, cannot send screenshot command.');
@@ -3499,7 +3659,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3499
3659
 
3500
3660
  const id = `ui-ss-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
3501
3661
  const request = {
3502
- type: 'screenshot', // Matches the type expected by instance API
3662
+ type: display ? 'screenshotDisplay' : 'screenshot',
3663
+ ...(display ? { display } : {}),
3503
3664
  id: id,
3504
3665
  };
3505
3666
 
@@ -3613,12 +3774,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3613
3774
 
3614
3775
  // Show indicators when Alt is held and we have a valid hover point (null when outside)
3615
3776
  const showAltIndicators = isAltHeld && hoverPoint !== null;
3616
- const frameImageSrc =
3617
- platform === 'android' && useAndroidTabletFrame ?
3618
- isLandscape ? pixelTabletFrameImageLandscape
3619
- : pixelTabletFrameImage
3620
- : isLandscape ? config.frame.imageLandscape
3621
- : config.frame.image;
3622
3777
 
3623
3778
  return (
3624
3779
  <div
@@ -3654,7 +3809,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3654
3809
  />
3655
3810
  </>
3656
3811
  )}
3657
- {showFrame && (
3812
+ {frameVisible && (
3658
3813
  <img
3659
3814
  ref={frameRef}
3660
3815
  src={frameImageSrc}
@@ -3665,12 +3820,17 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3665
3820
  )}
3666
3821
  <video
3667
3822
  ref={videoRef}
3668
- className={clsx('rc-video', !showFrame && 'rc-video-frameless', !videoLoaded && 'rc-video-loading')}
3823
+ className={clsx(
3824
+ 'rc-video',
3825
+ !frameVisible && 'rc-video-frameless',
3826
+ !videoLoaded && 'rc-video-loading',
3827
+ )}
3669
3828
  style={{
3670
3829
  ...videoStyle,
3671
- ...(config.loadingLogo ?
3830
+ ...(isDuo ? { opacity: 0, width: 1, height: 1, pointerEvents: 'none' as const } : {}),
3831
+ ...(loadingLogo ?
3672
3832
  {
3673
- backgroundImage: `url("${config.loadingLogo}")`,
3833
+ backgroundImage: `url("${loadingLogo}")`,
3674
3834
  backgroundRepeat: 'no-repeat',
3675
3835
  backgroundPosition: 'center',
3676
3836
  backgroundSize: config.loadingLogoSize,
@@ -3680,7 +3840,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3680
3840
  autoPlay
3681
3841
  playsInline
3682
3842
  muted
3683
- tabIndex={0}
3843
+ tabIndex={isDuo ? -1 : 0}
3684
3844
  onKeyDown={handleKeyboard}
3685
3845
  onKeyUp={handleKeyboard}
3686
3846
  onClick={handleVideoClick}
@@ -3696,7 +3856,47 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3696
3856
  }
3697
3857
  }}
3698
3858
  />
3699
- {inspectActive && (
3859
+ {isDuo && (
3860
+ <video
3861
+ ref={innerVideoRef}
3862
+ autoPlay
3863
+ playsInline
3864
+ muted
3865
+ style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
3866
+ />
3867
+ )}
3868
+ {isDuo && duoState && (
3869
+ <Suspense fallback={<div>Loading iPhone Duo…</div>}>
3870
+ <DuoView
3871
+ modelUrl={duoModelUrl}
3872
+ outer={videoRef.current}
3873
+ inner={innerVideoRef.current}
3874
+ state={duoState}
3875
+ setAngle={(angleDegrees) => sendDuoControl('setHingeAngle', { angleDegrees })}
3876
+ setOrientation={(orientation: DuoOrientation) =>
3877
+ sendDuoControl('setDuoOrientation', { orientation })
3878
+ }
3879
+ button={(button, down) =>
3880
+ sendDuoControl('performActions', {
3881
+ actions: [{ type: down ? 'buttonDown' : 'buttonUp', button }],
3882
+ })
3883
+ }
3884
+ touch={(action, screenId, x, y) => {
3885
+ const channel = dataChannelRef.current;
3886
+ if (channel?.readyState === 'open')
3887
+ channel.send(createDisplayTouchMessage(action, screenId, x, y));
3888
+ }}
3889
+ onKeyDown={handleKeyboard}
3890
+ onKeyUp={handleKeyboard}
3891
+ />
3892
+ </Suspense>
3893
+ )}
3894
+ {isDuo && duoError && (
3895
+ <div role="alert" className="rc-duo-error">
3896
+ {duoError}
3897
+ </div>
3898
+ )}
3899
+ {!isDuo && inspectActive && (
3700
3900
  <InspectOverlay
3701
3901
  snapshot={axSnapshot}
3702
3902
  geometry={overlayGeometry}