@limrun/ui 0.16.0 → 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 (58) hide show
  1. package/README.md +49 -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.d.ts +5 -1
  17. package/dist/core/duo.d.ts +63 -0
  18. package/dist/duo-fold-renderer-BXejNHcY.mjs +257 -0
  19. package/dist/duo-fold-renderer-DdltAnF7.js +53 -0
  20. package/dist/duo-frame-BzyYljix.mjs +26767 -0
  21. package/dist/duo-frame-DjfA4gG-.js +4059 -0
  22. package/dist/duo-view-COZg8ieG.js +1 -0
  23. package/dist/duo-view-o1yxIzxu.mjs +654 -0
  24. package/dist/index.cjs +1 -1
  25. package/dist/index.css +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/lite.cjs +1 -1
  28. package/dist/lite.js +1 -1
  29. package/dist/remote-control-CQjSEVDF.mjs +2673 -0
  30. package/dist/remote-control-DVRXfCR2.js +1 -0
  31. package/package.json +4 -2
  32. package/src/components/duo-button-hinge.test.tsx +138 -0
  33. package/src/components/duo-button-hinge.ts +75 -0
  34. package/src/components/duo-controls.tsx +142 -0
  35. package/src/components/duo-flat-frame.tsx +145 -0
  36. package/src/components/duo-flat-geometry.test.ts +63 -0
  37. package/src/components/duo-flat-geometry.ts +97 -0
  38. package/src/components/duo-fold-motion.test.tsx +245 -0
  39. package/src/components/duo-fold-motion.tsx +192 -0
  40. package/src/components/duo-fold-renderer.test.ts +29 -0
  41. package/src/components/duo-fold-renderer.ts +397 -0
  42. package/src/components/duo-fold-timing.test.ts +44 -0
  43. package/src/components/duo-fold-timing.ts +9 -0
  44. package/src/components/duo-frame.tsx +579 -0
  45. package/src/components/duo-hardware.test.ts +61 -0
  46. package/src/components/duo-hardware.ts +61 -0
  47. package/src/components/duo-model.test.ts +243 -0
  48. package/src/components/duo-model.ts +607 -0
  49. package/src/components/duo-screen-handoff.ts +52 -0
  50. package/src/components/duo-view.test.tsx +104 -0
  51. package/src/components/duo-view.tsx +315 -0
  52. package/src/components/remote-control.css +310 -0
  53. package/src/components/remote-control.tsx +150 -14
  54. package/src/core/duo.test.ts +142 -0
  55. package/src/core/duo.ts +156 -0
  56. package/src/demo.tsx +14 -0
  57. package/dist/remote-control-BRgbD7yo.mjs +0 -2516
  58. package/dist/remote-control-VISrP1zI.js +0 -1
@@ -129,3 +129,313 @@
129
129
  line-height: 1.3;
130
130
  color: #6b7280;
131
131
  }
132
+
133
+ .rc-duo-view {
134
+ position: absolute;
135
+ inset: 0;
136
+ }
137
+ .rc-duo-mode {
138
+ position: absolute;
139
+ top: 12px;
140
+ right: 16px;
141
+ z-index: 3;
142
+ display: flex;
143
+ gap: 2px;
144
+ padding: 3px;
145
+ background: #fff;
146
+ border: 1px solid #d9dde4;
147
+ border-radius: 10px;
148
+ }
149
+ .rc-duo-mode button {
150
+ border: 0;
151
+ border-radius: 7px;
152
+ padding: 7px 13px;
153
+ font: 12px system-ui;
154
+ color: #29313e;
155
+ background: transparent;
156
+ cursor: pointer;
157
+ }
158
+ .rc-duo-mode button[aria-pressed='true'] {
159
+ background: #202b3c;
160
+ color: white;
161
+ }
162
+ .rc-duo-flat-stage {
163
+ position: relative;
164
+ flex: 1;
165
+ min-height: 160px;
166
+ margin-top: 48px;
167
+ }
168
+ .rc-duo-flat-device {
169
+ position: absolute;
170
+ left: 50%;
171
+ top: 50%;
172
+ flex: none;
173
+ transform-origin: center;
174
+ }
175
+ .rc-duo-flat-frame {
176
+ position: absolute;
177
+ inset: 0;
178
+ width: 100%;
179
+ height: 100%;
180
+ overflow: visible;
181
+ pointer-events: none;
182
+ filter: drop-shadow(0 5px 6px #17202e1c);
183
+ }
184
+ .rc-duo-flat-screen {
185
+ position: absolute;
186
+ overflow: hidden;
187
+ background: #090b0d;
188
+ touch-action: none;
189
+ outline: none;
190
+ }
191
+ .rc-duo-flat-screen video,
192
+ .rc-duo-flat-screen canvas {
193
+ position: absolute;
194
+ left: 50%;
195
+ top: 50%;
196
+ max-width: none;
197
+ pointer-events: none;
198
+ }
199
+ .rc-duo-flat-stage[data-folding='true'] > .rc-duo-flat-device,
200
+ .rc-duo-flat-stage[data-folding='true'] > .rc-duo-hardware {
201
+ visibility: hidden;
202
+ pointer-events: none;
203
+ }
204
+ .rc-duo-fold-hold {
205
+ position: absolute;
206
+ left: 50%;
207
+ top: 50%;
208
+ pointer-events: none;
209
+ }
210
+ .rc-duo-fold-still {
211
+ position: relative;
212
+ }
213
+ .rc-duo-fold-canvas {
214
+ position: absolute;
215
+ inset: 0;
216
+ width: 100%;
217
+ height: 100%;
218
+ pointer-events: none;
219
+ }
220
+ @media (prefers-reduced-motion: reduce) {
221
+ .rc-duo-fold-canvas,
222
+ .rc-duo-fold-hold {
223
+ display: none;
224
+ }
225
+ .rc-duo-flat-stage[data-folding='true'] > .rc-duo-flat-device {
226
+ visibility: visible;
227
+ }
228
+ }
229
+ .rc-duo .rc-duo-physical {
230
+ position: absolute;
231
+ z-index: 2;
232
+ padding: 0;
233
+ border-radius: 0;
234
+ touch-action: none;
235
+ }
236
+ .rc-duo-physical[data-edge='top'] {
237
+ transform: translate(-50%, -100%);
238
+ }
239
+ .rc-duo-physical[data-edge='right'] {
240
+ transform: translate(0, -50%);
241
+ }
242
+ .rc-duo-physical span {
243
+ position: absolute;
244
+ display: block;
245
+ border: 1px solid #7d8387;
246
+ border-radius: 2px;
247
+ box-sizing: border-box;
248
+ background: linear-gradient(180deg, #747d83, #fafcfb 32%, #afb5b9 64%, #636b70);
249
+ box-shadow: 0 0.5px 1px #0004;
250
+ transition: transform 110ms ease;
251
+ pointer-events: none;
252
+ }
253
+ .rc-duo-physical[data-edge='top'] span {
254
+ bottom: 0;
255
+ left: 50%;
256
+ transform: translateX(-50%);
257
+ }
258
+ .rc-duo-physical[data-edge='right'] span {
259
+ top: 50%;
260
+ left: 0;
261
+ transform: translateY(-50%);
262
+ background: linear-gradient(90deg, #747d83, #fafcfb 32%, #afb5b9 64%, #636b70);
263
+ }
264
+ .rc-duo-physical[data-edge='top'][data-highlighted='true'] span,
265
+ .rc-duo-physical[data-edge='top']:focus-visible span {
266
+ transform: translate(-50%, -2px);
267
+ }
268
+ .rc-duo-physical[data-edge='right'][data-highlighted='true'] span,
269
+ .rc-duo-physical[data-edge='right']:focus-visible span {
270
+ transform: translate(2px, -50%);
271
+ }
272
+ .rc-duo-physical[data-edge='top'][data-pressed='true'] span {
273
+ transform: translate(-50%, 0.5px);
274
+ }
275
+ .rc-duo-physical[data-edge='right'][data-pressed='true'] span {
276
+ transform: translate(-0.5px, -50%);
277
+ }
278
+ .rc-duo {
279
+ position: absolute;
280
+ inset: 0;
281
+ display: flex;
282
+ flex-direction: column;
283
+ overflow: hidden;
284
+ background: radial-gradient(ellipse at 50% 40%, #ffffff, #e8ebee);
285
+ border-radius: 16px;
286
+ color: #29313e;
287
+ }
288
+ .rc-duo-stage {
289
+ position: relative;
290
+ margin-top: 48px;
291
+ flex: 1;
292
+ min-height: 160px;
293
+ width: 100%;
294
+ outline: none;
295
+ touch-action: none;
296
+ }
297
+ .rc-duo-stage canvas {
298
+ display: block;
299
+ width: 100%;
300
+ height: 100%;
301
+ touch-action: none;
302
+ }
303
+ .rc-duo .rc-duo-hardware {
304
+ position: absolute;
305
+ display: grid;
306
+ place-items: center;
307
+ z-index: 1;
308
+ width: 44px;
309
+ height: 44px;
310
+ padding: 0;
311
+ transform: translate(-50%, -50%);
312
+ color: #73777c;
313
+ opacity: 0;
314
+ pointer-events: none;
315
+ transition:
316
+ opacity 120ms ease,
317
+ color 100ms ease;
318
+ }
319
+ .rc-duo .rc-duo-hardware[data-visible='true'],
320
+ .rc-duo .rc-duo-hardware:focus-visible {
321
+ opacity: 1;
322
+ pointer-events: auto;
323
+ }
324
+ .rc-duo .rc-duo-hardware svg {
325
+ width: var(--duo-hardware-icon-size, 24px);
326
+ height: var(--duo-hardware-icon-size, 24px);
327
+ transform: translate(var(--duo-hardware-icon-x, 0px), var(--duo-hardware-icon-y, 0px));
328
+ }
329
+ .rc-duo .rc-duo-hardware:hover,
330
+ .rc-duo .rc-duo-hardware:focus-visible,
331
+ .rc-duo .rc-duo-hardware[data-pressed='true'] {
332
+ color: #007aff;
333
+ }
334
+ .rc-duo-controls {
335
+ flex: none;
336
+ align-self: center;
337
+ width: min(540px, calc(100% - 40px));
338
+ box-sizing: border-box;
339
+ margin: 0 20px 20px;
340
+ padding: 12px;
341
+ background: rgba(255, 255, 255, 0.88);
342
+ border: 1px solid #d9dde4;
343
+ border-radius: 16px;
344
+ box-shadow: 0 6px 25px #13203610;
345
+ font:
346
+ 12px system-ui,
347
+ sans-serif;
348
+ }
349
+ .rc-duo-hinge,
350
+ .rc-duo-views {
351
+ display: flex;
352
+ align-items: center;
353
+ gap: 10px;
354
+ flex-wrap: wrap;
355
+ }
356
+ .rc-duo-hinge {
357
+ flex-wrap: nowrap;
358
+ gap: 8px;
359
+ }
360
+ .rc-duo-hinge input {
361
+ flex: 1;
362
+ min-width: 30px;
363
+ width: 100%;
364
+ accent-color: #34435b;
365
+ }
366
+ .rc-duo-hinge output {
367
+ min-width: 32px;
368
+ font-variant-numeric: tabular-nums;
369
+ }
370
+ .rc-duo button {
371
+ border: 0;
372
+ padding: 7px 10px;
373
+ border-radius: 8px;
374
+ background: transparent;
375
+ color: inherit;
376
+ cursor: pointer;
377
+ font: inherit;
378
+ }
379
+ .rc-duo-hinge .rc-duo-fold {
380
+ flex: none;
381
+ background: #202b3c;
382
+ color: white;
383
+ min-width: 65px;
384
+ }
385
+ .rc-duo-views {
386
+ min-height: 29px;
387
+ margin-top: 8px;
388
+ gap: 2px;
389
+ font-size: 11px;
390
+ }
391
+ .rc-duo-views button {
392
+ padding: 6px 8px;
393
+ }
394
+ .rc-duo-view-label {
395
+ padding: 6px 8px;
396
+ color: #77808d;
397
+ }
398
+ .rc-duo .rc-duo-rotate {
399
+ display: flex;
400
+ align-items: center;
401
+ justify-content: center;
402
+ padding: 7px;
403
+ border: 1px solid #d9dde4;
404
+ }
405
+ .rc-duo button:disabled {
406
+ cursor: default;
407
+ opacity: 0.5;
408
+ }
409
+ .rc-duo-views button[aria-pressed='true'] {
410
+ background: #e5eaf0;
411
+ }
412
+ .rc-duo button:focus-visible,
413
+ .rc-duo input:focus-visible {
414
+ outline: 2px solid #4275c3;
415
+ outline-offset: 2px;
416
+ }
417
+ .rc-duo-error {
418
+ position: absolute;
419
+ top: 16px;
420
+ left: 16px;
421
+ right: 16px;
422
+ padding: 10px;
423
+ border-radius: 8px;
424
+ background: #fff0ee;
425
+ color: #9c2e26;
426
+ font: 13px system-ui;
427
+ }
428
+
429
+ .rc-duo .rc-duo-lock {
430
+ display: inline-flex;
431
+ align-items: center;
432
+ gap: 6px;
433
+ margin-left: auto;
434
+ border: 1px solid #d3dae3;
435
+ }
436
+ .rc-duo-stage[data-position-locked='false'] canvas {
437
+ cursor: grab;
438
+ }
439
+ .rc-duo-stage[data-position-locked='false'] canvas:active {
440
+ cursor: grabbing;
441
+ }
@@ -1,7 +1,19 @@
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
19
  import {
@@ -58,6 +70,12 @@ export interface RemoteControlProps {
58
70
  // around the video. Defaults to true.
59
71
  showFrame?: boolean;
60
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
+
61
79
  // When true, drops after a working session auto-reconnect instead of
62
80
  // surfacing the manual "Retry" button. Defaults to false.
63
81
  autoReconnect?: boolean;
@@ -306,7 +324,7 @@ export interface ImperativeKeyboardEvent {
306
324
  export interface RemoteControlHandle {
307
325
  openUrl: (url: string) => void;
308
326
  sendKeyEvent: (event: ImperativeKeyboardEvent) => void;
309
- screenshot: () => Promise<ScreenshotData>;
327
+ screenshot: (display?: 'outer' | 'inner') => Promise<ScreenshotData>;
310
328
  terminateApp: (bundleId: string) => Promise<void>;
311
329
  reconnect: () => void;
312
330
 
@@ -491,6 +509,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
491
509
  sessionId: propSessionId,
492
510
  openUrl,
493
511
  showFrame = true,
512
+ deviceModel,
513
+ duoModelUrl,
494
514
  autoReconnect = false,
495
515
  onTerminated,
496
516
  assets,
@@ -512,6 +532,32 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
512
532
  const containerRef = useRef<HTMLDivElement>(null);
513
533
  const videoRef = useRef<HTMLVideoElement>(null);
514
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
+ });
515
561
  const [videoLoaded, setVideoLoaded] = useState(false);
516
562
  const [retryExhausted, setRetryExhausted] = useState(false);
517
563
  // Set once we've concluded the instance is permanently gone (its
@@ -740,7 +786,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
740
786
  const loadingLogo = platformAssets?.loadingLogo;
741
787
  // Without a frame image (the lite entry) the video lays out frameless
742
788
  // whatever showFrame says.
743
- const frameVisible = showFrame && !!frameImageSrc;
789
+ const frameVisible = !isDuo && showFrame && !!frameImageSrc;
744
790
 
745
791
  const updateStatus = (message: string) => {
746
792
  // Use the wrapper for conditional logging
@@ -1239,6 +1285,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1239
1285
 
1240
1286
  // Unified handler for both mouse and touch interactions
1241
1287
  const handleInteraction = (event: React.MouseEvent | React.TouchEvent) => {
1288
+ if (isDuo) return;
1242
1289
  event.preventDefault();
1243
1290
  event.stopPropagation();
1244
1291
 
@@ -1688,6 +1735,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1688
1735
  const handleKeyboard = (event: React.KeyboardEvent) => {
1689
1736
  event.preventDefault();
1690
1737
  event.stopPropagation();
1738
+ const inputFocused =
1739
+ document.activeElement === videoRef.current ||
1740
+ (isDuo && document.activeElement === event.currentTarget);
1691
1741
  // Use the wrapper for conditional logging
1692
1742
  debugLog('Keyboard event:', {
1693
1743
  type: event.type,
@@ -1695,12 +1745,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1695
1745
  keyCode: event.keyCode,
1696
1746
  code: event.code,
1697
1747
  target: (event.target as HTMLElement).tagName,
1698
- focused: document.activeElement === videoRef.current,
1748
+ focused: inputFocused,
1699
1749
  });
1700
1750
 
1701
- if (document.activeElement !== videoRef.current) {
1751
+ if (!inputFocused) {
1702
1752
  // Use the wrapper for conditional warning
1703
- debugWarn('Video element not focused, skipping keyboard event');
1753
+ debugWarn('Simulator input not focused, skipping keyboard event');
1704
1754
  return;
1705
1755
  }
1706
1756
 
@@ -2354,6 +2404,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2354
2404
  };
2355
2405
 
2356
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;
2357
2414
  clearConnectionSuccessTimeout();
2358
2415
  clearIceDisconnectedGrace();
2359
2416
  stopRequestFrameLoop();
@@ -2644,6 +2701,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2644
2701
  const rtcConfigPromise = new Promise<{
2645
2702
  rtcConfiguration: RTCConfiguration;
2646
2703
  cameraSupported: boolean;
2704
+ foldSupported: boolean;
2647
2705
  }>((resolve, reject) => {
2648
2706
  const settle = (callback: () => void) => {
2649
2707
  window.clearTimeout(timeoutId);
@@ -2676,6 +2734,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2676
2734
  resolve({
2677
2735
  rtcConfiguration: message.rtcConfiguration,
2678
2736
  cameraSupported: message.cameraSupported === true,
2737
+ foldSupported: message.foldSupported === true,
2679
2738
  }),
2680
2739
  );
2681
2740
  }
@@ -2694,16 +2753,23 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2694
2753
  );
2695
2754
  });
2696
2755
 
2697
- const { rtcConfiguration: rtcConfig, cameraSupported: serverCameraSupported } =
2698
- await rtcConfigPromise;
2756
+ const {
2757
+ rtcConfiguration: rtcConfig,
2758
+ cameraSupported: serverCameraSupported,
2759
+ foldSupported,
2760
+ } = await rtcConfigPromise;
2699
2761
  if (!isCurrentAttempt() || wsRef.current !== ws) {
2700
2762
  return;
2701
2763
  }
2702
2764
 
2765
+ const isDuo = deviceModel === 'iphone-duo' || foldSupported;
2766
+ setDetectedDuo(isDuo);
2703
2767
  const peerConnection = new RTCPeerConnection(rtcConfig);
2704
2768
  peerConnectionRef.current = peerConnection;
2705
2769
  peerConnection.addTransceiver('audio', { direction: 'recvonly' });
2706
2770
  const videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly' });
2771
+ const duoTransceiver =
2772
+ isDuo ? peerConnection.addTransceiver('video', { direction: 'recvonly' }) : null;
2707
2773
 
2708
2774
  // iOS always supports camera/mic injection; Android servers
2709
2775
  // advertise it through `cameraSupported` (Android 15 and newer).
@@ -2757,6 +2823,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2757
2823
  return getCodecPriority(a) - getCodecPriority(b);
2758
2824
  });
2759
2825
  videoTransceiver.setCodecPreferences(sortedCodecs);
2826
+ duoTransceiver?.setCodecPreferences(sortedCodecs);
2760
2827
  debugLog('Set codec preferences:', sortedCodecs.map((c) => c.mimeType).join(', '));
2761
2828
  }
2762
2829
  }
@@ -2828,6 +2895,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2828
2895
  controlChannelOpenedRef.current = true;
2829
2896
  clearConnectionSuccessTimeout();
2830
2897
  updateStatus('Control channel opened');
2898
+ if (isDuo) ws.send(JSON.stringify({ type: 'getFoldState', id: 'duo-capabilities' }));
2831
2899
 
2832
2900
  // Spin up the AX fetcher now that we have a stable WS + control
2833
2901
  // channel. The fetcher's send function reuses this WS; it stops
@@ -2996,7 +3064,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2996
3064
  updateStatus('Received remote track: ' + event.track.kind);
2997
3065
  if (event.track.kind === 'video' && videoRef.current) {
2998
3066
  debugLog(`[${new Date().toISOString()}] Video track received:`, event.track);
2999
- 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
+ }
3000
3075
  }
3001
3076
  };
3002
3077
 
@@ -3040,6 +3115,24 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3040
3115
  }
3041
3116
  updateStatus('Received: ' + message.type);
3042
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
+ }
3043
3136
  case 'signalingError': {
3044
3137
  if (message.requestType !== 'offer' || message.sessionId !== sessionId) {
3045
3138
  break;
@@ -3244,6 +3337,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3244
3337
  ws.send(
3245
3338
  JSON.stringify({
3246
3339
  type: 'offer',
3340
+ ...(isDuo ? { displayStreams: true } : {}),
3247
3341
  sdp: offer.sdp,
3248
3342
  sessionId: sessionId,
3249
3343
  }),
@@ -3320,7 +3414,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3320
3414
  // connection effect doesn't need to bounce when the camera
3321
3415
  // turns on/off — no SDP-affecting change.
3322
3416
  // eslint-disable-next-line react-hooks/exhaustive-deps
3323
- }, [url, token, propSessionId]);
3417
+ }, [url, token, propSessionId, deviceModel]);
3324
3418
 
3325
3419
  // Recompute the inspect-overlay geometry (container-local pixel rect of
3326
3420
  // the actually-rendered video content) from the current mapping context.
@@ -3556,7 +3650,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3556
3650
  sendBinaryControlMessage(message);
3557
3651
  }
3558
3652
  },
3559
- screenshot: (): Promise<ScreenshotData> => {
3653
+ screenshot: (display?: 'outer' | 'inner'): Promise<ScreenshotData> => {
3560
3654
  return new Promise<ScreenshotData>((resolve, reject) => {
3561
3655
  if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
3562
3656
  debugWarn('WebSocket not open, cannot send screenshot command.');
@@ -3565,7 +3659,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3565
3659
 
3566
3660
  const id = `ui-ss-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
3567
3661
  const request = {
3568
- type: 'screenshot', // Matches the type expected by instance API
3662
+ type: display ? 'screenshotDisplay' : 'screenshot',
3663
+ ...(display ? { display } : {}),
3569
3664
  id: id,
3570
3665
  };
3571
3666
 
@@ -3732,6 +3827,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3732
3827
  )}
3733
3828
  style={{
3734
3829
  ...videoStyle,
3830
+ ...(isDuo ? { opacity: 0, width: 1, height: 1, pointerEvents: 'none' as const } : {}),
3735
3831
  ...(loadingLogo ?
3736
3832
  {
3737
3833
  backgroundImage: `url("${loadingLogo}")`,
@@ -3744,7 +3840,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3744
3840
  autoPlay
3745
3841
  playsInline
3746
3842
  muted
3747
- tabIndex={0}
3843
+ tabIndex={isDuo ? -1 : 0}
3748
3844
  onKeyDown={handleKeyboard}
3749
3845
  onKeyUp={handleKeyboard}
3750
3846
  onClick={handleVideoClick}
@@ -3760,7 +3856,47 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3760
3856
  }
3761
3857
  }}
3762
3858
  />
3763
- {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 && (
3764
3900
  <InspectOverlay
3765
3901
  snapshot={axSnapshot}
3766
3902
  geometry={overlayGeometry}