authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-23 20:42:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-23 20:42:53-07:00
log78af372cf46a80147f05183b69d7a8339259ca37
tree2f5dfec9769840659ff693edcaa353eee61efc22
parentf1add18ae1560f509405a5eece07f3cfb1cc850f
signature Signed by PGP key B5690EEEBB952194

feat(web): add an interactive 3D device workspace (#12787)


55 files changed, 4704 insertions(+), 348 deletions(-)

apps/desktop/src/electron/ElectronProtocol.test.ts+9-2
...@@ -105,7 +105,7 @@ describe("ElectronProtocol", () => {...@@ -105,7 +105,7 @@ describe("ElectronProtocol", () => {
105 );105 );
106 assert.include(106 assert.include(
107 response.headers.get("content-security-policy") ?? "",107 response.headers.get("content-security-policy") ?? "",
108 "connect-src 'self' http: https: ws: wss:",108 "connect-src 'self' blob: http: https: ws: wss:",
109 );109 );
110 assert.include(110 assert.include(
111 response.headers.get("content-security-policy") ?? "",111 response.headers.get("content-security-policy") ?? "",
...@@ -255,7 +255,14 @@ describe("ElectronProtocol", () => {...@@ -255,7 +255,14 @@ describe("ElectronProtocol", () => {
255 "https://clerk.t3.codes",255 "https://clerk.t3.codes",
256 "https://challenges.cloudflare.com",256 "https://challenges.cloudflare.com",
257 ]);257 ]);
258 assert.deepEqual(directives["connect-src"], ["'self'", "http:", "https:", "ws:", "wss:"]);258 assert.deepEqual(directives["connect-src"], [
259 "'self'",
260 "blob:",
261 "http:",
262 "https:",
263 "ws:",
264 "wss:",
265 ]);
259 assert.deepEqual(directives["img-src"], [266 assert.deepEqual(directives["img-src"], [
260 "'self'",267 "'self'",
261 "t3code:",268 "t3code:",
apps/desktop/src/electron/ElectronProtocol.ts+2-1
...@@ -84,7 +84,8 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat...@@ -84,7 +84,8 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
84 // the build-configured Clerk, relay, and OTLP endpoints. Those environment84 // the build-configured Clerk, relay, and OTLP endpoints. Those environment
85 // origins are not known when this response policy is created, so restrict85 // origins are not known when this response policy is created, so restrict
86 // connections by the network schemes the client supports instead of by host.86 // connections by the network schemes the client supports instead of by host.
87 const connectSources = ["'self'", "http:", "https:", "ws:", "wss:"];87 // GLTFLoader fetches embedded textures through blob URLs after parsing the model.
88 const connectSources = ["'self'", "blob:", "http:", "https:", "ws:", "wss:"];
8889
89 return [90 return [
90 "default-src 'self'",91 "default-src 'self'",
apps/desktop/src/ipc/channels.ts+1
...@@ -11,6 +11,7 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action";...@@ -11,6 +11,7 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action";
11export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";11export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
12export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";12export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
13export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";13export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
14export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end";
14export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";15export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
15export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";16export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
16export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready";17export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready";
apps/desktop/src/preload.ts+5
...@@ -79,6 +79,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -79,6 +79,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
79 ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);79 ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
80 return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);80 return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
81 },81 },
82 onTrackpadScrollEnd: (listener) => {
83 const handler = () => listener();
84 ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler);
85 return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler);
86 },
82 getSystemLocale: () => {87 getSystemLocale: () => {
83 const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);88 const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
84 return typeof result === "string" ? result : null;89 return typeof result === "string" ? result : null;
apps/desktop/src/window/DesktopWindow.test.ts+25
...@@ -49,6 +49,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";...@@ -49,6 +49,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
49import {49import {
50 MENU_ACTION_CHANNEL,50 MENU_ACTION_CHANNEL,
51 SNAP_SHOT_EVENT_CHANNEL,51 SNAP_SHOT_EVENT_CHANNEL,
52 TRACKPAD_SCROLL_END_CHANNEL,
52 WINDOW_FULLSCREEN_STATE_CHANNEL,53 WINDOW_FULLSCREEN_STATE_CHANNEL,
53} from "../ipc/channels.ts";54} from "../ipc/channels.ts";
54import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";55import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
...@@ -709,6 +710,30 @@ describe("DesktopWindow", () => {...@@ -709,6 +710,30 @@ describe("DesktopWindow", () => {
709 }),710 }),
710 );711 );
711712
713 it.effect("forwards native trackpad release to the renderer", () =>
714 Effect.gen(function* () {
715 const fakeWindow = makeFakeBrowserWindow();
716 const send = vi.spyOn(fakeWindow.window.webContents, "send");
717 const createCount = yield* Ref.make(0);
718 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
719 const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow });
720
721 yield* Effect.gen(function* () {
722 const desktopWindow = yield* DesktopWindow.DesktopWindow;
723 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
724 const onInput = fakeWindow.webContentsListeners.get("input-event");
725 if (!onInput) return yield* Effect.die("input-event listener was not registered");
726 onInput({}, { type: "gestureScrollUpdate" });
727 assert.notInclude(
728 send.mock.calls.map(([channel]) => channel),
729 TRACKPAD_SCROLL_END_CHANNEL,
730 );
731 onInput({}, { type: "gestureScrollEnd" });
732 assert.isTrue(send.mock.calls.some(([channel]) => channel === TRACKPAD_SCROLL_END_CHANNEL));
733 }).pipe(Effect.provide(layer));
734 }),
735 );
736
712 // Chromium hands the main window's zoom level down to embedded preview737 // Chromium hands the main window's zoom level down to embedded preview
713 // guests, so every app zoom has to put the preview browser back at its own738 // guests, so every app zoom has to put the preview browser back at its own
714 // zoom or zooming the UI drags the previewed page with it.739 // zoom or zooming the UI drags the previewed page with it.
apps/desktop/src/window/DesktopWindow.ts+4
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22 MENU_ACTION_CHANNEL,22 MENU_ACTION_CHANNEL,
23 QUIT_SHORTCUT_CHANNEL,23 QUIT_SHORTCUT_CHANNEL,
24 SNAP_SHOT_EVENT_CHANNEL,24 SNAP_SHOT_EVENT_CHANNEL,
25 TRACKPAD_SCROLL_END_CHANNEL,
25 WINDOW_FULLSCREEN_STATE_CHANNEL,26 WINDOW_FULLSCREEN_STATE_CHANNEL,
26} from "../ipc/channels.ts";27} from "../ipc/channels.ts";
27import * as PreviewManager from "../preview/Manager.ts";28import * as PreviewManager from "../preview/Manager.ts";
...@@ -661,6 +662,9 @@ export const make = Effect.gen(function* () {...@@ -661,6 +662,9 @@ export const make = Effect.gen(function* () {
661 event.preventDefault();662 event.preventDefault();
662 }663 }
663 });664 });
665 window.webContents.on("input-event", (_event, input) => {
666 if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL);
667 });
664668
665 window.on("page-title-updated", (event) => {669 window.on("page-title-updated", (event) => {
666 event.preventDefault();670 event.preventDefault();
apps/web/src/components/RightPanelTabs.tsx+36-1
...@@ -63,6 +63,7 @@ import { ScrollArea } from "~/components/ui/scroll-area";...@@ -63,6 +63,7 @@ import { ScrollArea } from "~/components/ui/scroll-area";
63import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";63import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
64import { faviconUrlForOrigin } from "~/lib/favicon";64import { faviconUrlForOrigin } from "~/lib/favicon";
65import { useTheme } from "~/hooks/useTheme";65import { useTheme } from "~/hooks/useTheme";
66import { useDeviceState } from "~/state/device";
66import {67import {
67 newestPullRequestSummary,68 newestPullRequestSummary,
68 pullRequestEnvironment,69 pullRequestEnvironment,
...@@ -1244,7 +1245,17 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -1244,7 +1245,17 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
1244 </button>1245 </button>
1245 }1246 }
1246 />1247 />
1247 <TooltipPopup>{title}</TooltipPopup>1248 <TooltipPopup>
1249 {surface.kind === "device" ? (
1250 <DeviceTabTooltip
1251 surface={surface}
1252 environmentId={props.environmentId}
1253 title={title}
1254 />
1255 ) : (
1256 title
1257 )}
1258 </TooltipPopup>
1248 </Tooltip>1259 </Tooltip>
1249 )}1260 )}
1250 </div>1261 </div>
...@@ -1421,3 +1432,27 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -1421,3 +1432,27 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
1421 </PreviewPanelShell>1432 </PreviewPanelShell>
1422 );1433 );
1423}1434}
1435
1436function DeviceTabTooltip(props: {
1437 surface: Extract<RightPanelSurface, { kind: "device" }>;
1438 environmentId: EnvironmentId | null;
1439 title: string;
1440}) {
1441 const target = props.surface.target;
1442 const { state } = useDeviceState(target ? props.environmentId : null);
1443 const device = target
1444 ? state.devices.find((entry) => entry.hostId === target.hostId && entry.id === target.deviceId)
1445 : undefined;
1446 const host = target ? state.hosts.find((entry) => entry.id === target.hostId) : undefined;
1447 return (
1448 <div className="flex flex-col gap-0.5">
1449 <span>{props.title}</span>
1450 {target ? (
1451 <span className="text-muted-foreground">
1452 {host?.label ?? "Device host"} ·{" "}
1453 {device?.version ?? (target.platform === "ios" ? "iOS" : "Android")}
1454 </span>
1455 ) : null}
1456 </div>
1457 );
1458}
apps/web/src/components/device/DeviceControlsRail.tsx created+308
...@@ -0,0 +1,308 @@
1import type { DevicePlatform } from "@t3tools/contracts";
2import {
3 Camera,
4 ChevronLeft,
5 Home,
6 Keyboard,
7 Box,
8 Maximize,
9 Moon,
10 MoreHorizontal,
11 PictureInPicture2,
12 Power,
13 RotateCcw,
14 SlidersHorizontal,
15 Smartphone,
16 Square,
17 Sun,
18 Type,
19 X,
20} from "lucide-react";
21import type { ReactNode } from "react";
22import { Button } from "~/components/ui/button";
23import {
24 Menu,
25 MenuItem,
26 MenuPopup,
27 MenuSeparator,
28 MenuTrigger,
29 MenuRadioGroup,
30 MenuRadioItem,
31 MenuRadioItemIndicator,
32} from "~/components/ui/menu";
33import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
34import type { DeviceStreamHandle, DeviceViewControls } from "./DeviceStreamView";
35import type { DeviceControls } from "./useDeviceControls";
36
37/** Stable floating controls for both presentations, regardless of panel width. */
38export function DeviceControlsRail(props: {
39 platform: DevicePlatform;
40 handle: DeviceStreamHandle | null;
41 view: DeviceViewControls;
42 controls: DeviceControls;
43 screenshotPending: boolean;
44 onScreenshot: () => void;
45 toolsOpen: boolean;
46 onTools: () => void;
47 onFloat: () => void;
48 onClose: () => void;
49 onPowerOff: () => void;
50}) {
51 const { view, handle, controls } = props;
52 const popupSide = "left";
53 const settings = controls.detail?.settings;
54 const inputDisabled = !handle?.inputConnected;
55 const nextAppearance = settings?.appearance === "dark" ? "light" : "dark";
56 return (
57 <aside
58 aria-label="Device controls"
59 data-layout="rail"
60 className="pointer-events-none absolute inset-y-0 right-0 z-10 flex w-14 flex-col items-center justify-center py-3 pr-2"
61 >
62 <div className="pointer-events-auto flex max-h-full shrink-0 flex-col items-center gap-1 overflow-y-auto rounded-full border border-border/50 bg-background/80 p-2 shadow-sm [scrollbar-width:none]">
63 <RailButton
64 tooltipSide={popupSide}
65 label="Home"
66 disabled={inputDisabled}
67 onClick={() => handle?.pressButton("home")}
68 >
69 <Home />
70 </RailButton>
71 {props.platform === "android" ? (
72 <>
73 <RailButton
74 tooltipSide={popupSide}
75 label="Back"
76 disabled={inputDisabled}
77 onClick={() => handle?.pressButton("back")}
78 >
79 <ChevronLeft />
80 </RailButton>
81 <RailButton
82 tooltipSide={popupSide}
83 label="Recents"
84 disabled={inputDisabled}
85 onClick={() => handle?.pressButton("recents")}
86 >
87 <Square />
88 </RailButton>
89 <Menu>
90 <MenuTrigger
91 render={
92 <Button
93 size="icon-sm"
94 variant="ghost"
95 aria-label="Rotate device"
96 disabled={controls.disabled}
97 />
98 }
99 >
100 <RotateCcw />
101 </MenuTrigger>
102 <MenuPopup side={popupSide}>
103 <MenuItem
104 onClick={() => void controls.act({ type: "setOrientation", value: "portrait" })}
105 >
106 Portrait
107 </MenuItem>
108 <MenuItem
109 onClick={() =>
110 void controls.act({ type: "setOrientation", value: "landscape_left" })
111 }
112 >
113 Landscape
114 </MenuItem>
115 </MenuPopup>
116 </Menu>
117 </>
118 ) : (
119 <RailButton
120 tooltipSide={popupSide}
121 label="Rotate device"
122 disabled={inputDisabled || !!view.keyboard?.attached}
123 onClick={() => handle?.rotate()}
124 >
125 <RotateCcw />
126 </RailButton>
127 )}
128 <RailDivider />
129 <RailButton
130 tooltipSide={popupSide}
131 label={`Switch device to ${nextAppearance} mode`}
132 disabled={controls.disabled || !settings?.appearance}
133 onClick={() => void controls.act({ type: "setAppearance", value: nextAppearance })}
134 >
135 {settings?.appearance === "dark" ? <Sun /> : <Moon />}
136 </RailButton>
137 <Menu>
138 <MenuTrigger
139 render={
140 <Button
141 size="icon-sm"
142 variant="ghost"
143 aria-label="Device text size"
144 title="Device text size"
145 disabled={controls.disabled || !settings?.textSize}
146 />
147 }
148 >
149 <Type />
150 </MenuTrigger>
151 <MenuPopup side={popupSide} className="min-w-40">
152 <MenuRadioGroup
153 value={settings?.textSize ?? ""}
154 onValueChange={(value) => {
155 if (
156 value === "small" ||
157 value === "default" ||
158 value === "large" ||
159 value === "extra-large"
160 )
161 void controls.act({ type: "setTextSize", value });
162 }}
163 >
164 {(
165 [
166 ["small", "Small"],
167 ["default", "Default"],
168 ["large", "Large"],
169 ["extra-large", "Extra large"],
170 ] as const
171 ).map(([value, label]) => (
172 <MenuRadioItem key={value} value={value}>
173 <span className="flex items-center gap-2">
174 <span className="flex-1">{label}</span>
175 <MenuRadioItemIndicator />
176 </span>
177 </MenuRadioItem>
178 ))}
179 </MenuRadioGroup>
180 </MenuPopup>
181 </Menu>
182 <RailButton
183 tooltipSide={popupSide}
184 label="Device tools"
185 pressed={props.toolsOpen}
186 onClick={props.onTools}
187 >
188 <SlidersHorizontal />
189 </RailButton>
190 <RailButton
191 tooltipSide={popupSide}
192 label={props.screenshotPending ? "Capturing screenshot" : "Save screenshot"}
193 disabled={!view.streaming || props.screenshotPending}
194 onClick={props.onScreenshot}
195 >
196 <Camera />
197 </RailButton>
198 <Menu>
199 <MenuTrigger
200 render={
201 <Button
202 size="icon-sm"
203 variant="ghost"
204 aria-label="More device actions"
205 title="More device actions"
206 />
207 }
208 >
209 <MoreHorizontal />
210 </MenuTrigger>
211 <MenuPopup side={popupSide} align="end">
212 <MenuItem onClick={props.onFloat}>
213 <PictureInPicture2 />
214 Float device over chat
215 </MenuItem>
216 <MenuItem onClick={props.onClose}>
217 <X />
218 Close device panel
219 </MenuItem>
220 <MenuSeparator />
221 <MenuItem variant="destructive" onClick={props.onPowerOff}>
222 <Power />
223 Power off device
224 </MenuItem>
225 </MenuPopup>
226 </Menu>
227 <RailDivider />
228 <RailButton
229 tooltipSide={popupSide}
230 label="3D view"
231 pressed={view.phone}
232 disabled={!view.streaming || !!view.phoneUnavailableReason}
233 description={view.phoneUnavailableReason ?? undefined}
234 onClick={view.showPhone}
235 >
236 <Box />
237 </RailButton>
238 <RailButton
239 tooltipSide={popupSide}
240 label="Flat view"
241 pressed={!view.phone}
242 disabled={!view.streaming}
243 onClick={view.showFlat}
244 >
245 <Smartphone />
246 </RailButton>
247 {view.keyboard ? (
248 <RailButton
249 tooltipSide={popupSide}
250 label={view.keyboard.attached ? "Detach Magic Keyboard" : "Attach Magic Keyboard"}
251 pressed={view.keyboard.attached}
252 onClick={view.keyboard.toggle}
253 >
254 <Keyboard />
255 </RailButton>
256 ) : null}
257 {view.phone ? (
258 <RailButton tooltipSide={popupSide} label="Reset phone view" onClick={view.resetView}>
259 <Maximize />
260 </RailButton>
261 ) : null}
262 </div>
263 </aside>
264 );
265}
266
267function RailDivider() {
268 return <div aria-hidden className="my-1 h-px w-5 shrink-0 bg-border/70" />;
269}
270
271function RailButton(props: {
272 tooltipSide: "left" | "bottom";
273 label: string;
274 description?: string | undefined;
275 disabled?: boolean;
276 pressed?: boolean;
277 onClick: () => void;
278 children: ReactNode;
279}) {
280 const button = (
281 <Button
282 size="icon-sm"
283 variant={props.pressed ? "secondary" : "ghost"}
284 aria-label={props.label}
285 aria-pressed={props.pressed}
286 disabled={props.disabled}
287 onClick={props.onClick}
288 >
289 {props.children}
290 </Button>
291 );
292 return (
293 <Tooltip>
294 {props.disabled && props.description ? (
295 <TooltipTrigger
296 render={
297 <span className="inline-flex" tabIndex={0} role="group" aria-label={props.label} />
298 }
299 >
300 {button}
301 </TooltipTrigger>
302 ) : (
303 <TooltipTrigger render={button} />
304 )}
305 <TooltipPopup side={props.tooltipSide}>{props.description ?? props.label}</TooltipPopup>
306 </Tooltip>
307 );
308}
apps/web/src/components/device/DevicePanel.tsx+17-137
...@@ -5,17 +5,7 @@ import type {...@@ -5,17 +5,7 @@ import type {
5 DeviceSummary,5 DeviceSummary,
6 ScopedThreadRef,6 ScopedThreadRef,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import {8import { Smartphone, X } from "lucide-react";
9 ChevronLeft,
10 Home,
11 PictureInPicture2,
12 Power,
13 RotateCcw,
14 SlidersHorizontal,
15 Smartphone,
16 Square,
17 X,
18} from "lucide-react";
19import { useEffect, useMemo, useState } from "react";9import { useEffect, useMemo, useState } from "react";
2010
21import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";11import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
...@@ -25,16 +15,13 @@ import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"...@@ -25,16 +15,13 @@ import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"
25import { Dialog } from "~/components/ui/dialog";15import { Dialog } from "~/components/ui/dialog";
26import { WizardPopup } from "~/components/ui/wizard";16import { WizardPopup } from "~/components/ui/wizard";
27import { Spinner } from "~/components/ui/spinner";17import { Spinner } from "~/components/ui/spinner";
28import { Toggle } from "~/components/ui/toggle";
29import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
30import { cn } from "~/lib/utils";18import { cn } from "~/lib/utils";
31import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/device";19import { deviceEnvironment, useDeviceState } from "~/state/device";
32import { formatEnvironmentQueryError } from "~/state/query";20import { formatEnvironmentQueryError } from "~/state/query";
33import { useAtomCommand } from "~/state/use-atom-command";21import { useAtomCommand } from "~/state/use-atom-command";
34import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView";
35import { DeviceLoadingView } from "./DeviceLoadingView";22import { DeviceLoadingView } from "./DeviceLoadingView";
36import { DeviceSetup } from "./DeviceSetup";23import { DeviceSetup } from "./DeviceSetup";
37import { DeviceToolsPanel } from "./DeviceToolsPanel";24import { DeviceWorkspace } from "./DeviceWorkspace";
38import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell";25import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell";
3926
40const platformLabel = (platform: DevicePlatform) =>27const platformLabel = (platform: DevicePlatform) =>
...@@ -59,10 +46,6 @@ export function DevicePanel(props: {...@@ -59,10 +46,6 @@ export function DevicePanel(props: {
59 const [operationError, setOperationError] = useState<string | null>(null);46 const [operationError, setOperationError] = useState<string | null>(null);
60 const [pendingDevice, setPendingDevice] = useState<DeviceSummary | null>(null);47 const [pendingDevice, setPendingDevice] = useState<DeviceSummary | null>(null);
61 const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null;48 const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null;
62 const [handle, setHandle] = useState<DeviceStreamHandle | null>(null);
63 const [toolsOpen, setToolsOpen] = useState(false);
64 const [axOverlay, setAxOverlay] = useState(false);
65 const access = useDeviceHubAccess(environmentId);
6649
67 const hostDisabled = state.hostStatus === "disabled";50 const hostDisabled = state.hostStatus === "disabled";
6851
...@@ -184,69 +167,7 @@ export function DevicePanel(props: {...@@ -184,69 +167,7 @@ export function DevicePanel(props: {
184167
185 return (168 return (
186 <PreviewPanelShell mode={props.mode}>169 <PreviewPanelShell mode={props.mode}>
187 <div className="flex h-9 shrink-0 items-center gap-1.5 border-b px-2">170 {hostReady && !activeDevice && state.hostStatusDetail ? (
188 <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
189 {props.surface.target
190 ? `${state.hosts.find((host) => host.id === props.surface.target?.hostId)?.label ?? "Device host"} · ${activeDevice?.version ?? props.surface.target.platform}`
191 : (pendingDevice?.name ?? "Choose a device")}
192 </span>
193 {activeDevice ? (
194 <>
195 <DeviceButton
196 label="Home"
197 onClick={() => handle?.pressButton("home")}
198 disabled={!handle?.inputConnected}
199 >
200 <Home />
201 </DeviceButton>
202 {activeDevice.platform === "android" ? (
203 <>
204 <DeviceButton
205 label="Back"
206 onClick={() => handle?.pressButton("back")}
207 disabled={!handle?.inputConnected}
208 >
209 <ChevronLeft />
210 </DeviceButton>
211 <DeviceButton
212 label="Recents"
213 onClick={() => handle?.pressButton("recents")}
214 disabled={!handle?.inputConnected}
215 >
216 <Square />
217 </DeviceButton>
218 </>
219 ) : (
220 <DeviceButton
221 label="Rotate"
222 onClick={() => handle?.rotate()}
223 disabled={!handle?.inputConnected}
224 >
225 <RotateCcw />
226 </DeviceButton>
227 )}
228 <Toggle
229 aria-label="Tools"
230 variant="ghost"
231 size="xs"
232 pressed={toolsOpen}
233 onPressedChange={(pressed) => setToolsOpen(Boolean(pressed))}
234 >
235 <SlidersHorizontal />
236 </Toggle>
237 <DeviceButton label="Float device over chat" onClick={floatActive}>
238 <PictureInPicture2 />
239 </DeviceButton>
240 <DeviceButton label="Power off" onClick={() => closeActive(true)}>
241 <Power />
242 </DeviceButton>
243 <DeviceButton label="Close" onClick={() => closeActive(false)}>
244 <X />
245 </DeviceButton>
246 </>
247 ) : null}
248 </div>
249 {hostReady && state.hostStatusDetail ? (
250 <div171 <div
251 role="status"172 role="status"
252 className="whitespace-pre-line border-b px-3 py-2 text-xs text-muted-foreground"173 className="whitespace-pre-line border-b px-3 py-2 text-xs text-muted-foreground"
...@@ -278,34 +199,19 @@ export function DevicePanel(props: {...@@ -278,34 +199,19 @@ export function DevicePanel(props: {
278 ) : null}199 ) : null}
279 <div className="@container relative flex min-h-0 flex-1">200 <div className="@container relative flex min-h-0 flex-1">
280 {activeDevice && activeSession ? (201 {activeDevice && activeSession ? (
281 <>202 <DeviceWorkspace
282 <div className="relative min-h-0 min-w-0 flex-1">203 key={`${environmentId}\u0000${deviceKey(activeDevice)}`}
283 <DeviceStreamView204 environmentId={environmentId}
284 key={deviceKey(activeDevice)}205 device={activeDevice}
285 environmentId={environmentId}206 hostLabel={
286 platform={activeDevice.platform}207 state.hosts.find((host) => host.id === activeDevice.hostId)?.label ?? "Device host"
287 deviceName={activeDevice.name}208 }
288 deviceDescription={`${state.hosts.find((host) => host.id === activeDevice.hostId)?.label ?? "Device host"} · ${activeDevice.version}`}209 hostDiagnostics={state.hostStatusDetail}
289 deviceId={activeDevice.id}210 visible={props.visible}
290 hostId={activeDevice.hostId}211 onFloat={floatActive}
291 visible={props.visible}212 onClose={() => closeActive(false)}
292 axOverlay={axOverlay}213 onPowerOff={() => closeActive(true)}
293 onHandle={setHandle}214 />
294 />
295 </div>
296 {toolsOpen ? (
297 <DeviceToolsPanel
298 key={deviceKey(activeDevice)}
299 environmentId={environmentId}
300 device={activeDevice}
301 access={access}
302 axOverlay={axOverlay}
303 onAxOverlayChange={setAxOverlay}
304 onClose={() => setToolsOpen(false)}
305 className="absolute inset-y-0 right-0 z-10 w-full max-w-72 border-l shadow-lg @[560px]:static @[560px]:w-72 @[560px]:shrink-0 @[560px]:shadow-none"
306 />
307 ) : null}
308 </>
309 ) : pendingDevice || hostBusy || !loaded ? (215 ) : pendingDevice || hostBusy || !loaded ? (
310 <DeviceLoadingView216 <DeviceLoadingView
311 name={pendingDevice?.name ?? "Devices"}217 name={pendingDevice?.name ?? "Devices"}
...@@ -407,32 +313,6 @@ export function DevicePanel(props: {...@@ -407,32 +313,6 @@ export function DevicePanel(props: {
407 );313 );
408}314}
409315
410function DeviceButton(props: {
411 readonly label: string;
412 readonly onClick: () => void;
413 readonly disabled?: boolean;
414 readonly children: React.ReactNode;
415}) {
416 return (
417 <Tooltip>
418 <TooltipTrigger
419 render={
420 <Button
421 size="icon-xs"
422 variant="ghost-muted"
423 aria-label={props.label}
424 onClick={props.onClick}
425 disabled={props.disabled ?? false}
426 />
427 }
428 >
429 {props.children}
430 </TooltipTrigger>
431 <TooltipPopup>{props.label}</TooltipPopup>
432 </Tooltip>
433 );
434}
435
436function groupDevices(state: DeviceServiceState) {316function groupDevices(state: DeviceServiceState) {
437 const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = [];317 const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = [];
438 for (const platform of ["ios", "android"] as const) {318 for (const platform of ["ios", "android"] as const) {
apps/web/src/components/device/DevicePhoneViewport.tsx created+151
...@@ -0,0 +1,151 @@
1import { useEffect, useRef, type RefObject } from "react";
2import type { PhoneViewer } from "@t3tools/client-runtime/device/phone-viewer";
3import type {
4 DeviceAccessorySource,
5 DeviceModelSource,
6} from "@t3tools/client-runtime/device/model";
7import type { DeviceShapeProfile } from "@t3tools/client-runtime/device/shape-profile";
8import { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction";
9import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";
10import { bindPhoneTrackpad } from "./phoneTrackpad";
11
12const loadPhoneViewer = () => import("@t3tools/client-runtime/device/phone-viewer");
13
14/** Web shell for the framework-independent viewer. The decoded screen and input connection remain owned by DeviceStreamView. */
15export function DevicePhoneViewport(props: {
16 readonly profile: DeviceShapeProfile;
17 readonly model: DeviceModelSource | null;
18 readonly accessory: DeviceAccessorySource | null;
19 readonly source: RefObject<HTMLCanvasElement | null>;
20 readonly onFrameListener: (listener: (() => void) | null) => void;
21 readonly onResetReady: (reset: (() => void) | null) => void;
22 readonly onInputCancel: (cancel: (() => void) | null) => void;
23 readonly client: RefObject<DeviceStreamClient | null>;
24 readonly screen: DeviceScreenSize | null;
25 readonly onUnavailable: () => void;
26}) {
27 const hostRef = useRef<HTMLDivElement | null>(null);
28 const canvasRef = useRef<HTMLCanvasElement | null>(null);
29 const viewerRef = useRef<PhoneViewer | null>(null);
30 const interactionRef = useRef<ReturnType<typeof createPhoneInteraction> | null>(null);
31 const screenRef = useRef(props.screen);
32 const profileRef = useRef(props.profile);
33 const modelRef = useRef(props.model);
34 const accessoryRef = useRef(props.accessory);
35 const { source, onFrameListener, client, onInputCancel, onResetReady, onUnavailable } = props;
36
37 useEffect(() => {
38 screenRef.current = props.screen;
39 profileRef.current = props.profile;
40 modelRef.current = props.model;
41 accessoryRef.current = props.accessory;
42 interactionRef.current?.end();
43 viewerRef.current?.setModel(props.model);
44 viewerRef.current?.setAccessory(props.accessory);
45 viewerRef.current?.setScreen(props.screen, props.profile);
46 }, [props.screen, props.profile, props.model, props.accessory]);
47
48 useEffect(() => {
49 const host = hostRef.current;
50 const canvas = canvasRef.current;
51 const decoded = source.current;
52 if (!host || !canvas || !decoded) return;
53 let disposed = false;
54 let trackpad: ReturnType<typeof bindPhoneTrackpad> | null = null;
55 let stopTrackpadEnd: (() => void) | undefined;
56 const resize = () => {
57 const { width, height } = host.getBoundingClientRect();
58 viewerRef.current?.resize(width, height, window.devicePixelRatio);
59 };
60 const observer = new ResizeObserver(resize);
61 observer.observe(host);
62 const blur = () => {
63 interactionRef.current?.end();
64 trackpad?.cancel();
65 };
66 onInputCancel(blur);
67 window.addEventListener("blur", blur);
68 void loadPhoneViewer()
69 .then(({ createPhoneViewer }) => {
70 if (disposed) return;
71 const viewer = createPhoneViewer({
72 canvas,
73 source: decoded,
74 onUnavailable,
75 onModelError: (cause) => console.warn("Device 3D asset could not load", cause),
76 profile: profileRef.current,
77 model: modelRef.current,
78 accessory: accessoryRef.current,
79 });
80 viewerRef.current = viewer;
81 onResetReady(viewer.resetPose);
82 viewer.setScreen(screenRef.current);
83 onFrameListener(viewer.frameUpdated);
84 interactionRef.current = createPhoneInteraction({
85 screenPoint: (point, captured) => viewer.screenPoint(point.x, point.y, captured),
86 touch: (phase, point) => client.current?.sendTouch(phase, point.x, point.y),
87 orbit: viewer.orbit,
88 zoomBy: () => {},
89 onInteractionActive: viewer.setInteractionActive,
90 });
91 trackpad = bindPhoneTrackpad(canvas, interactionRef.current);
92 stopTrackpadEnd = window.desktopBridge?.onTrackpadScrollEnd?.(() => trackpad?.endOrbit());
93 resize();
94 viewer.frameUpdated();
95 })
96 .catch(() => {
97 if (!disposed) onUnavailable();
98 });
99 return () => {
100 disposed = true;
101 stopTrackpadEnd?.();
102 trackpad?.dispose();
103 interactionRef.current?.end();
104 interactionRef.current = null;
105 onInputCancel(null);
106 onResetReady(null);
107 onFrameListener(null);
108 observer.disconnect();
109 window.removeEventListener("blur", blur);
110 viewerRef.current?.dispose();
111 viewerRef.current = null;
112 };
113 }, [onInputCancel, onResetReady, client, onFrameListener, onUnavailable, source]);
114
115 const point = (event: React.PointerEvent<HTMLCanvasElement>) => {
116 const rect = event.currentTarget.getBoundingClientRect();
117 return {
118 x: (event.clientX - rect.left) / rect.width,
119 y: (event.clientY - rect.top) / rect.height,
120 };
121 };
122 return (
123 <div className="absolute inset-0">
124 <div ref={hostRef} className="absolute inset-0">
125 <div
126 aria-hidden
127 className="pointer-events-none absolute bottom-[6%] left-1/2 h-5 w-2/5 -translate-x-1/2 rounded-full bg-foreground/10 blur-xl"
128 />
129 <canvas
130 ref={canvasRef}
131 aria-label="Interactive 3D device. Drag the screen to interact. Drag outside it or swipe with two fingers to turn."
132 className="size-full touch-none"
133 onPointerDown={(event) => {
134 if (event.button !== 0) return;
135 if (!interactionRef.current?.begin(event.pointerId, point(event), event.altKey)) return;
136 event.preventDefault();
137 event.currentTarget.setPointerCapture(event.pointerId);
138 (event.currentTarget.closest('[role="application"]') as HTMLElement | null)?.focus();
139 }}
140 onPointerMove={(event) => interactionRef.current?.move(event.pointerId, point(event))}
141 onPointerUp={(event) => {
142 interactionRef.current?.move(event.pointerId, point(event));
143 interactionRef.current?.end(event.pointerId);
144 }}
145 onPointerCancel={(event) => interactionRef.current?.end(event.pointerId)}
146 onLostPointerCapture={(event) => interactionRef.current?.end(event.pointerId)}
147 />
148 </div>
149 </div>
150 );
151}
apps/web/src/components/device/DeviceStreamView.tsx+263-112
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1import type { DevicePlatform, EnvironmentId } from "@t3tools/contracts";1import type { DevicePlatform, EnvironmentId } from "@t3tools/contracts";
2import { useCallback, useEffect, useMemo, useRef, useState } from "react";2import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
33
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { Button } from "~/components/ui/button";5import { Button } from "~/components/ui/button";
6import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";6import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";
7import { createCanvasFrameSink } from "@t3tools/client-runtime/device/frame";
8import { resolveDeviceShape } from "@t3tools/client-runtime/device/shape-profile";
9import { deviceKeyboard, deviceModel } from "./deviceModels";
10import { fitDeviceFrame } from "./deviceFrameLayout";
11import { DevicePhoneViewport } from "./DevicePhoneViewport";
7import { DeviceLoadingView } from "./DeviceLoadingView";12import { DeviceLoadingView } from "./DeviceLoadingView";
8import { type DeviceAxElement, fetchDeviceAxTree } from "./deviceHubApi";13import { type DeviceAxElement, fetchDeviceAxTree } from "./deviceHubApi";
9import {14import {
...@@ -15,6 +20,17 @@ import {...@@ -15,6 +20,17 @@ import {
15} from "@t3tools/client-runtime/device/stream";20} from "@t3tools/client-runtime/device/stream";
1621
17const AX_POLL_INTERVAL_MS = 2_000;22const AX_POLL_INTERVAL_MS = 2_000;
23const CONTROLS_RAIL_WIDTH = 56;
24
25export interface DeviceViewControls {
26 readonly phone: boolean;
27 readonly streaming: boolean;
28 readonly phoneUnavailableReason: string | null;
29 readonly showPhone: () => void;
30 readonly showFlat: () => void;
31 readonly resetView: () => void;
32 readonly keyboard: { readonly attached: boolean; readonly toggle: () => void } | null;
33}
1834
19export interface DeviceStreamHandle {35export interface DeviceStreamHandle {
20 readonly pressButton: (button: DeviceHardwareButton) => void;36 readonly pressButton: (button: DeviceHardwareButton) => void;
...@@ -37,11 +53,31 @@ export function DeviceStreamView(props: {...@@ -37,11 +53,31 @@ export function DeviceStreamView(props: {
37 readonly deviceDescription?: string;53 readonly deviceDescription?: string;
38 readonly visible: boolean;54 readonly visible: boolean;
39 readonly hostId: string;55 readonly hostId: string;
56 /** The full panel opts into the phone spike; compact viewers retain their flat presentation. */
57 readonly allowPhoneView?: boolean;
58 readonly renderControls?: (view: DeviceViewControls) => ReactNode;
40 /** Draw accessibility element frames over the screen. */59 /** Draw accessibility element frames over the screen. */
41 readonly axOverlay?: boolean;60 readonly axOverlay?: boolean;
42 readonly onHandle?: (handle: DeviceStreamHandle | null) => void;61 readonly onHandle?: (handle: DeviceStreamHandle | null) => void;
43 readonly onScreen?: (screen: DeviceScreenSize | null) => void;62 readonly onScreen?: (screen: DeviceScreenSize | null) => void;
44}) {63}) {
64 const [presentation, setPresentation] = useState<"phone" | "flat">("phone");
65 const [keyboardAttached, setKeyboardAttached] = useState(false);
66 const [phoneUnavailable, setPhoneUnavailable] = useState(false);
67 const onPhoneUnavailable = useCallback(() => setPhoneUnavailable(true), []);
68 const cancelPhoneInputRef = useRef<(() => void) | null>(null);
69 const cancelPhoneInput = useCallback(() => cancelPhoneInputRef.current?.(), []);
70 const resetViewRef = useRef<(() => void) | null>(null);
71 const onResetReady = useCallback((reset: (() => void) | null) => {
72 resetViewRef.current = reset;
73 }, []);
74 const frameListenerRef = useRef<(() => void) | null>(null);
75 const onFrameListener = useCallback((listener: (() => void) | null) => {
76 frameListenerRef.current = listener;
77 }, []);
78 const onInputCancel = useCallback((cancel: (() => void) | null) => {
79 cancelPhoneInputRef.current = cancel;
80 }, []);
45 const access = useDeviceHubAccess(props.environmentId, props.hostId);81 const access = useDeviceHubAccess(props.environmentId, props.hostId);
46 const canvasRef = useRef<HTMLCanvasElement | null>(null);82 const canvasRef = useRef<HTMLCanvasElement | null>(null);
47 const clientRef = useRef<DeviceStreamClient | null>(null);83 const clientRef = useRef<DeviceStreamClient | null>(null);
...@@ -67,7 +103,7 @@ export function DeviceStreamView(props: {...@@ -67,7 +103,7 @@ export function DeviceStreamView(props: {
67 }103 }
68 const client = createDeviceStreamClient(104 const client = createDeviceStreamClient(
69 { platform: props.platform, deviceId: props.deviceId, access },105 { platform: props.platform, deviceId: props.deviceId, access },
70 canvas,106 createCanvasFrameSink(canvas, () => frameListenerRef.current?.()),
71 {107 {
72 onStatus: (next, nextDetail) => {108 onStatus: (next, nextDetail) => {
73 setStatus(next);109 setStatus(next);
...@@ -101,6 +137,7 @@ export function DeviceStreamView(props: {...@@ -101,6 +137,7 @@ export function DeviceStreamView(props: {
101 client.start();137 client.start();
102 onHandle?.({ pressButton: client.pressButton, rotate: client.rotate, inputConnected: false });138 onHandle?.({ pressButton: client.pressButton, rotate: client.rotate, inputConnected: false });
103 return () => {139 return () => {
140 cancelPhoneInput();
104 client.stop();141 client.stop();
105 clientRef.current = null;142 clientRef.current = null;
106 onHandle?.(null);143 onHandle?.(null);
...@@ -109,6 +146,7 @@ export function DeviceStreamView(props: {...@@ -109,6 +146,7 @@ export function DeviceStreamView(props: {
109 };146 };
110 }, [147 }, [
111 access,148 access,
149 cancelPhoneInput,
112 onHandle,150 onHandle,
113 onScreen,151 onScreen,
114 props.deviceId,152 props.deviceId,
...@@ -131,6 +169,16 @@ export function DeviceStreamView(props: {...@@ -131,6 +169,16 @@ export function DeviceStreamView(props: {
131 return w / h;169 return w / h;
132 }, [props.platform, screen]);170 }, [props.platform, screen]);
133171
172 const showPhone =
173 props.allowPhoneView &&
174 status === "streaming" &&
175 props.visible &&
176 presentation === "phone" &&
177 !phoneUnavailable &&
178 !mjpegUrl &&
179 !props.axOverlay;
180 const controlsInset = props.renderControls && !showPhone ? CONTROLS_RAIL_WIDTH : 0;
181
134 // The frame is the largest box at `aspect` that fits the container, so a182 // The frame is the largest box at `aspect` that fits the container, so a
135 // narrow panel shows a shorter phone rather than a squeezed one. CSS183 // narrow panel shows a shorter phone rather than a squeezed one. CSS
136 // `aspect-ratio` alone cannot do this: with the height pinned to 100% the184 // `aspect-ratio` alone cannot do this: with the height pinned to 100% the
...@@ -154,12 +202,8 @@ export function DeviceStreamView(props: {...@@ -154,12 +202,8 @@ export function DeviceStreamView(props: {
154 return () => observer.disconnect();202 return () => observer.disconnect();
155 }, []);203 }, []);
156 const frame = useMemo(() => {204 const frame = useMemo(() => {
157 if (host.width === 0 || host.height === 0) return { width: 0, height: 0 };205 return fitDeviceFrame(aspect, host.width, host.height, controlsInset);
158 const byHeight = { width: host.height * aspect, height: host.height };206 }, [aspect, controlsInset, host]);
159 return byHeight.width <= host.width
160 ? byHeight
161 : { width: host.width, height: host.width / aspect };
162 }, [aspect, host]);
163207
164 // serve-sim streams the raw framebuffer; rotate the display for a device208 // serve-sim streams the raw framebuffer; rotate the display for a device
165 // that reports landscape while its frames stay portrait.209 // that reports landscape while its frames stay portrait.
...@@ -231,123 +275,230 @@ export function DeviceStreamView(props: {...@@ -231,123 +275,230 @@ export function DeviceStreamView(props: {
231 return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) };275 return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) };
232 };276 };
233277
278 const phoneUnavailableReason = phoneUnavailable
279 ? "3D is unavailable on this browser"
280 : mjpegUrl
281 ? "3D requires the H.264 stream"
282 : props.axOverlay
283 ? "Turn off accessibility frames to use 3D"
284 : null;
285
286 const keyboardSource = deviceKeyboard(props.platform, props.deviceName ?? "");
287 const resetView = useCallback(() => {
288 const orientation = keyboardAttached ? "landscape_right" : "portrait";
289 if (screen?.orientation !== orientation) clientRef.current?.setOrientation(orientation);
290 resetViewRef.current?.();
291 }, [keyboardAttached, screen?.orientation]);
292 const profile = resolveDeviceShape({
293 platform: props.platform,
294 name: props.deviceName ?? "",
295 portraitAspect: Math.min(aspect, 1 / aspect),
296 });
297
234 return (298 return (
235 <div299 <div
236 ref={hostRef}300 className={cn(
237 className="relative flex size-full items-center justify-center overflow-hidden bg-black/90 outline-none"301 "relative flex size-full min-h-0 min-w-0",
238 tabIndex={0}302 props.allowPhoneView ? "bg-background" : "bg-black/90",
239 role="application"303 )}
240 aria-label={`${props.platform === "ios" ? "iOS Simulator" : "Android Emulator"} screen`}
241 onKeyDown={(event) => {
242 if (event.target !== event.currentTarget) return;
243 if (event.metaKey && !["r", "R"].includes(event.key)) return;
244 event.preventDefault();
245 clientRef.current?.sendKey(event.nativeEvent, "down");
246 }}
247 onKeyUp={(event) => {
248 if (event.target !== event.currentTarget) return;
249 clientRef.current?.sendKey(event.nativeEvent, "up");
250 }}
251 >304 >
305 {props.renderControls ? (
306 <DeviceControlsSlot
307 renderControls={props.renderControls}
308 view={{
309 phone: !!showPhone,
310 streaming: status === "streaming",
311 phoneUnavailableReason,
312 keyboard:
313 showPhone && keyboardSource
314 ? {
315 attached: keyboardAttached,
316 toggle: () => {
317 cancelPhoneInput();
318 if (!keyboardAttached && screen?.orientation !== "landscape_right")
319 clientRef.current?.setOrientation("landscape_right");
320 setKeyboardAttached(!keyboardAttached);
321 },
322 }
323 : null,
324 showPhone: () => setPresentation("phone"),
325 showFlat: () => setPresentation("flat"),
326 }}
327 onResetView={resetView}
328 />
329 ) : null}
252 <div330 <div
253 className="relative select-none"331 ref={hostRef}
254 style={{ width: frame.width, height: frame.height }}332 className={cn(
255 onPointerDown={(event) => {333 "relative flex min-h-0 min-w-0 flex-1 items-center justify-center overflow-hidden outline-none",
256 event.currentTarget.setPointerCapture(event.pointerId);334 controlsInset && "pr-14",
257 (event.currentTarget.parentElement as HTMLElement | null)?.focus();335 )}
258 pointerActive.current = true;336 tabIndex={0}
259 const { x, y } = normalizedPoint(event);337 role="application"
260 clientRef.current?.sendTouch("begin", x, y);338 aria-label={`${props.platform === "ios" ? "iOS Simulator" : "Android Emulator"} screen`}
339 onKeyDown={(event) => {
340 if (event.target !== event.currentTarget) return;
341 if (event.metaKey && !["r", "R"].includes(event.key)) return;
342 event.preventDefault();
343 clientRef.current?.sendKey(event.nativeEvent, "down");
261 }}344 }}
262 onPointerMove={(event) => {345 onKeyUp={(event) => {
263 if (!pointerActive.current) return;346 if (event.target !== event.currentTarget) return;
264 const { x, y } = normalizedPoint(event);347 clientRef.current?.sendKey(event.nativeEvent, "up");
265 clientRef.current?.sendTouch("move", x, y);
266 }}
267 onPointerUp={(event) => {
268 if (!pointerActive.current) return;
269 pointerActive.current = false;
270 const { x, y } = normalizedPoint(event);
271 clientRef.current?.sendTouch("end", x, y);
272 }}
273 onPointerCancel={(event) => {
274 if (!pointerActive.current) return;
275 pointerActive.current = false;
276 const { x, y } = normalizedPoint(event);
277 clientRef.current?.sendTouch("end", x, y);
278 }}348 }}
279 >349 >
280 <canvas350 <div
281 ref={canvasRef}351 className={cn("relative select-none", showPhone && "invisible pointer-events-none")}
282 className={cn("absolute top-0 left-0", mjpegUrl && "hidden")}352 style={{ width: frame.width, height: frame.height }}
283 style={mediaStyle}353 onPointerDown={(event) => {
284 />354 event.currentTarget.setPointerCapture(event.pointerId);
285 {props.visible && access && mjpegUrl ? (355 (event.currentTarget.parentElement as HTMLElement | null)?.focus();
286 <img356 pointerActive.current = true;
287 key={mjpegGeneration}357 const { x, y } = normalizedPoint(event);
288 ref={attachMjpegImage}358 clientRef.current?.sendTouch("begin", x, y);
289 alt=""359 }}
290 draggable={false}360 onPointerMove={(event) => {
291 className="absolute top-0 left-0 object-contain"361 if (!pointerActive.current) return;
362 const { x, y } = normalizedPoint(event);
363 clientRef.current?.sendTouch("move", x, y);
364 }}
365 onPointerUp={(event) => {
366 if (!pointerActive.current) return;
367 pointerActive.current = false;
368 const { x, y } = normalizedPoint(event);
369 clientRef.current?.sendTouch("end", x, y);
370 }}
371 onPointerCancel={(event) => {
372 if (!pointerActive.current) return;
373 pointerActive.current = false;
374 const { x, y } = normalizedPoint(event);
375 clientRef.current?.sendTouch("end", x, y);
376 }}
377 >
378 <canvas
379 ref={canvasRef}
380 className={cn("absolute top-0 left-0", mjpegUrl && "hidden")}
292 style={mediaStyle}381 style={mediaStyle}
293 />382 />
383 {props.visible && access && mjpegUrl ? (
384 <img
385 key={mjpegGeneration}
386 ref={attachMjpegImage}
387 alt=""
388 draggable={false}
389 className="absolute top-0 left-0 object-contain"
390 style={mediaStyle}
391 />
392 ) : null}
393 {axElements.length > 0 ? (
394 <div className="pointer-events-none absolute inset-0" aria-hidden>
395 {axElements.map((element) => (
396 <div
397 key={element.id}
398 className="absolute border border-sky-400/80 bg-sky-400/10"
399 style={{
400 left: `${element.x * 100}%`,
401 top: `${element.y * 100}%`,
402 width: `${element.width * 100}%`,
403 height: `${element.height * 100}%`,
404 }}
405 >
406 {element.label ? (
407 <span className="absolute -top-3.5 left-0 max-w-full truncate rounded-sm bg-sky-500 px-1 text-[9px] leading-3.5 text-white">
408 {element.label}
409 </span>
410 ) : null}
411 </div>
412 ))}
413 </div>
414 ) : null}
415 </div>
416 {showPhone ? (
417 <DevicePhoneViewport
418 profile={profile}
419 model={deviceModel(props.platform, props.deviceName ?? "")}
420 accessory={keyboardAttached ? keyboardSource : null}
421 source={canvasRef}
422 onFrameListener={onFrameListener}
423 client={clientRef}
424 onInputCancel={onInputCancel}
425 onResetReady={onResetReady}
426 screen={screen}
427 onUnavailable={onPhoneUnavailable}
428 />
429 ) : null}
430 {props.allowPhoneView && !props.renderControls && status === "streaming" ? (
431 <div className="absolute top-3 left-3 flex gap-1 rounded-lg border border-border/50 bg-background/90 p-1 shadow-sm">
432 <Button
433 variant={showPhone ? "secondary" : "ghost"}
434 size="xs"
435 aria-pressed={!!showPhone}
436 disabled={phoneUnavailable || !!mjpegUrl || !!props.axOverlay}
437 title={
438 phoneUnavailable
439 ? "3D is unavailable on this browser"
440 : mjpegUrl
441 ? "3D requires the H.264 stream"
442 : props.axOverlay
443 ? "Turn off accessibility frames to use 3D"
444 : "Show 3D phone"
445 }
446 onClick={() => setPresentation("phone")}
447 >
448 3D
449 </Button>
450 <Button
451 variant={!showPhone ? "secondary" : "ghost"}
452 size="xs"
453 aria-pressed={!showPhone}
454 onClick={() => setPresentation("flat")}
455 >
456 Flat
457 </Button>
458 </div>
294 ) : null}459 ) : null}
295 {axElements.length > 0 ? (460 {status === "streaming" && !inputState.connected ? (
296 <div className="pointer-events-none absolute inset-0" aria-hidden>461 <div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-2">
297 {axElements.map((element) => (462 <span className="rounded-md bg-background/85 px-2 py-1 text-xs text-muted-foreground">
298 <div463 Input disconnected{inputState.detail ? ` (${inputState.detail})` : ""}, reconnecting…
299 key={element.id}464 </span>
300 className="absolute border border-sky-400/80 bg-sky-400/10"465 </div>
301 style={{466 ) : null}
302 left: `${element.x * 100}%`,467 {status !== "streaming" ? (
303 top: `${element.y * 100}%`,468 <div className="absolute inset-0">
304 width: `${element.width * 100}%`,469 <DeviceLoadingView
305 height: `${element.height * 100}%`,470 name={props.deviceName ?? "Device"}
306 }}471 description={props.deviceDescription ?? ""}
307 >472 stage="stream"
308 {element.label ? (473 message={status === "error" ? (detail ?? "Stream failed.") : "Connecting video…"}
309 <span className="absolute -top-3.5 left-0 max-w-full truncate rounded-sm bg-sky-500 px-1 text-[9px] leading-3.5 text-white">474 error={status === "error"}
310 {element.label}475 >
311 </span>476 {status === "error" ? (
312 ) : null}477 <Button
313 </div>478 size="sm"
314 ))}479 variant="outline"
480 onClick={() => {
481 // An expired ticket surfaces as unauthorized on restart and
482 // refreshes access through the effect; no need to mint one here.
483 clientRef.current?.stop();
484 clientRef.current?.start();
485 }}
486 >
487 Reconnect
488 </Button>
489 ) : null}
490 </DeviceLoadingView>
315 </div>491 </div>
316 ) : null}492 ) : null}
317 </div>493 </div>
318 {status === "streaming" && !inputState.connected ? (
319 <div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-2">
320 <span className="rounded-md bg-background/85 px-2 py-1 text-xs text-muted-foreground">
321 Input disconnected{inputState.detail ? ` (${inputState.detail})` : ""}, reconnecting…
322 </span>
323 </div>
324 ) : null}
325 {status !== "streaming" ? (
326 <div className="absolute inset-0">
327 <DeviceLoadingView
328 name={props.deviceName ?? "Device"}
329 description={props.deviceDescription ?? ""}
330 stage="stream"
331 message={status === "error" ? (detail ?? "Stream failed.") : "Connecting video…"}
332 error={status === "error"}
333 >
334 {status === "error" ? (
335 <Button
336 size="sm"
337 variant="outline"
338 onClick={() => {
339 // An expired ticket surfaces as unauthorized on restart and
340 // refreshes access through the effect; no need to mint one here.
341 clientRef.current?.stop();
342 clientRef.current?.start();
343 }}
344 >
345 Reconnect
346 </Button>
347 ) : null}
348 </DeviceLoadingView>
349 </div>
350 ) : null}
351 </div>494 </div>
352 );495 );
353}496}
497
498function DeviceControlsSlot(props: {
499 renderControls: (view: DeviceViewControls) => ReactNode;
500 view: Omit<DeviceViewControls, "resetView">;
501 onResetView: () => void;
502}) {
503 return props.renderControls({ ...props.view, resetView: props.onResetView });
504}
apps/web/src/components/device/DeviceToolsPanel.tsx+19-84
...@@ -1,14 +1,7 @@...@@ -1,14 +1,7 @@
1import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";1import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
2import type {2import type { DevicePermission, DeviceSummary, DeviceTextSize } from "@t3tools/contracts";
3 DeviceActionInput,
4 DeviceDetail,
5 DevicePermission,
6 DeviceSummary,
7 DeviceTextSize,
8 EnvironmentId,
9} from "@t3tools/contracts";
10import { ChevronDown, X } from "lucide-react";3import { ChevronDown, X } from "lucide-react";
11import { useCallback, useEffect, useMemo, useState } from "react";4import { useEffect, useState } from "react";
125
13import { Button } from "~/components/ui/button";6import { Button } from "~/components/ui/button";
14import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible";7import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible";
...@@ -24,21 +17,8 @@ import { Spinner } from "~/components/ui/spinner";...@@ -24,21 +17,8 @@ import { Spinner } from "~/components/ui/spinner";
24import { Switch } from "~/components/ui/switch";17import { Switch } from "~/components/ui/switch";
25import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";18import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
26import { cn } from "~/lib/utils";19import { cn } from "~/lib/utils";
27import { deviceEnvironment } from "~/state/device";20import type { DeviceControls } from "./useDeviceControls";
28import { formatEnvironmentQueryError } from "~/state/query";21import { type DeviceEventLogEntry, subscribeDeviceEventLog } from "./deviceHubApi";
29import { useAtomCommand } from "~/state/use-atom-command";
30import {
31 type DeviceEventLogEntry,
32 type DeviceForegroundInfo,
33 subscribeDeviceEventLog,
34 subscribeDeviceForeground,
35} from "./deviceHubApi";
36
37type ActionBody = DeviceActionInput extends infer A
38 ? A extends { readonly type: string }
39 ? Omit<A, "hostId" | "deviceId">
40 : never
41 : never;
4222
43const TEXT_SIZES: ReadonlyArray<{ value: DeviceTextSize; label: string }> = [23const TEXT_SIZES: ReadonlyArray<{ value: DeviceTextSize; label: string }> = [
44 { value: "small", label: "Small" },24 { value: "small", label: "Small" },
...@@ -102,7 +82,8 @@ const LOCATION_PRESETS = [...@@ -102,7 +82,8 @@ const LOCATION_PRESETS = [
102 * local state so the controls never show a value the device did not confirm.82 * local state so the controls never show a value the device did not confirm.
103 */83 */
104export function DeviceToolsPanel(props: {84export function DeviceToolsPanel(props: {
105 readonly environmentId: EnvironmentId;85 readonly controls: DeviceControls;
86 readonly hostDiagnostics: string | undefined;
106 readonly device: DeviceSummary;87 readonly device: DeviceSummary;
107 readonly access: DeviceHubAccess | null;88 readonly access: DeviceHubAccess | null;
108 readonly axOverlay: boolean;89 readonly axOverlay: boolean;
...@@ -110,62 +91,10 @@ export function DeviceToolsPanel(props: {...@@ -110,62 +91,10 @@ export function DeviceToolsPanel(props: {
110 readonly onClose: () => void;91 readonly onClose: () => void;
111 readonly className?: string;92 readonly className?: string;
112}) {93}) {
113 const { environmentId, device } = props;94 const { device, controls } = props;
114 const readDetail = useAtomCommand(deviceEnvironment.detail, { reportFailure: false });95 const { detail, pending, error, foregroundApp, disabled, act } = controls;
115 const runAction = useAtomCommand(deviceEnvironment.action, { reportFailure: false });
116 const [detail, setDetail] = useState<DeviceDetail | null>(null);
117 const [pending, setPending] = useState(false);
118 const [error, setError] = useState<string | null>(null);
119 const [foreground, setForeground] = useState<DeviceForegroundInfo | null | undefined>(undefined);
120 const isIos = device.platform === "ios";
121
122 const target = useMemo(
123 () => ({ hostId: device.hostId, deviceId: device.id }),
124 [device.hostId, device.id],
125 );
126
127 // The panel is keyed by device, so a mount is always a fresh device.
128 useEffect(() => {
129 let cancelled = false;
130 void readDetail({ environmentId, input: target }).then((result) => {
131 if (cancelled) return;
132 if (result._tag === "Success") setDetail(result.value);
133 else setError(formatEnvironmentQueryError(result.cause));
134 });
135 return () => {
136 cancelled = true;
137 };
138 }, [environmentId, readDetail, target]);
139
140 useEffect(() => {
141 if (!props.access) return;
142 return subscribeDeviceForeground(
143 { access: props.access, platform: device.platform, deviceId: device.id },
144 setForeground,
145 );
146 }, [device.id, device.platform, props.access]);
147
148 const act = useCallback(
149 async (body: ActionBody) => {
150 setPending(true);
151 setError(null);
152 try {
153 const result = await runAction({
154 environmentId,
155 input: { ...target, ...body } as DeviceActionInput,
156 });
157 if (result._tag === "Success") setDetail(result.value);
158 else setError(formatEnvironmentQueryError(result.cause));
159 } finally {
160 setPending(false);
161 }
162 },
163 [environmentId, runAction, target],
164 );
165
166 const settings = detail?.settings;96 const settings = detail?.settings;
167 const foregroundApp = foreground === undefined ? (detail?.foregroundApp ?? null) : foreground;97 const isIos = device.platform === "ios";
168 const disabled = pending || detail === null;
16998
170 return (99 return (
171 <div100 <div
...@@ -194,6 +123,14 @@ export function DeviceToolsPanel(props: {...@@ -194,6 +123,14 @@ export function DeviceToolsPanel(props: {
194 </div>123 </div>
195 ) : null}124 ) : null}
196125
126 {props.hostDiagnostics ? (
127 <Section title="Host diagnostics">
128 <p className="whitespace-pre-line text-xs text-muted-foreground">
129 {props.hostDiagnostics}
130 </p>
131 </Section>
132 ) : null}
133
197 <Section title="App">134 <Section title="App">
198 <Row label="Foreground">135 <Row label="Foreground">
199 <span className="truncate font-mono text-xs">{foregroundApp?.id ?? "—"}</span>136 <span className="truncate font-mono text-xs">{foregroundApp?.id ?? "—"}</span>
...@@ -398,9 +335,7 @@ export function DeviceToolsPanel(props: {...@@ -398,9 +335,7 @@ export function DeviceToolsPanel(props: {
398function Section(props: { readonly title: string; readonly children: React.ReactNode }) {335function Section(props: { readonly title: string; readonly children: React.ReactNode }) {
399 return (336 return (
400 <section className="flex flex-col gap-2 border-b px-3 py-2.5 last:border-b-0">337 <section className="flex flex-col gap-2 border-b px-3 py-2.5 last:border-b-0">
401 <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">338 <h3 className="text-xs font-medium text-muted-foreground">{props.title}</h3>
402 {props.title}
403 </h3>
404 {props.children}339 {props.children}
405 </section>340 </section>
406 );341 );
...@@ -698,7 +633,7 @@ function EventLogSection(props: {...@@ -698,7 +633,7 @@ function EventLogSection(props: {
698633
699 return (634 return (
700 <Collapsible open={open} onOpenChange={setOpen}>635 <Collapsible open={open} onOpenChange={setOpen}>
701 <CollapsibleTrigger className="flex w-full items-center gap-1.5 border-b px-3 py-2.5 text-left text-xs font-medium tracking-wide text-muted-foreground uppercase">636 <CollapsibleTrigger className="flex w-full items-center gap-1.5 border-b px-3 py-2.5 text-left text-xs font-medium text-muted-foreground">
702 Event log637 Event log
703 <ChevronDown638 <ChevronDown
704 className={cn("ml-auto size-3.5 transition-transform", open && "rotate-180")}639 className={cn("ml-auto size-3.5 transition-transform", open && "rotate-180")}
apps/web/src/components/device/DeviceWorkspace.tsx created+131
...@@ -0,0 +1,131 @@
1import type { DeviceSummary, EnvironmentId } from "@t3tools/contracts";
2import { useEffect, useRef, useState } from "react";
3import {
4 captureDeviceScreenshot,
5 DeviceScreenshotError,
6} from "@t3tools/client-runtime/device/screenshot";
7import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";
8import { DeviceControlsRail } from "./DeviceControlsRail";
9import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView";
10import { DeviceToolsPanel } from "./DeviceToolsPanel";
11import { useDeviceControls } from "./useDeviceControls";
12
13/** Keyed by environment and device; the screen, quick controls and drawer share the same session. */
14export function DeviceWorkspace(props: {
15 environmentId: EnvironmentId;
16 device: DeviceSummary;
17 hostLabel: string;
18 hostDiagnostics: string | undefined;
19 visible: boolean;
20 onFloat: () => void;
21 onClose: () => void;
22 onPowerOff: () => void;
23}) {
24 const [handle, setHandle] = useState<DeviceStreamHandle | null>(null);
25 const [toolsOpen, setToolsOpen] = useState(false);
26 const [axOverlay, setAxOverlay] = useState(false);
27 const [screenshotPending, setScreenshotPending] = useState(false);
28 const [screenshotError, setScreenshotError] = useState<string | null>(null);
29 const captureRef = useRef<AbortController | null>(null);
30 const downloadRef = useRef<string | null>(null);
31 const access = useDeviceHubAccess(props.environmentId, props.device.hostId);
32 const controls = useDeviceControls({ ...props, access });
33 useEffect(() => {
34 if (!access || !props.visible) captureRef.current?.abort();
35 return () => {
36 captureRef.current?.abort();
37 if (downloadRef.current) URL.revokeObjectURL(downloadRef.current);
38 downloadRef.current = null;
39 };
40 }, [access, props.visible]);
41 const saveScreenshot = () => {
42 if (!access || captureRef.current || !props.visible) return;
43 const controller = new AbortController();
44 captureRef.current = controller;
45 setScreenshotPending(true);
46 setScreenshotError(null);
47 return captureDeviceScreenshot(
48 { access, platform: props.device.platform, deviceId: props.device.id },
49 controller.signal,
50 )
51 .then((image) => {
52 if (controller.signal.aborted) return;
53 if (downloadRef.current) URL.revokeObjectURL(downloadRef.current);
54 const url = URL.createObjectURL(image);
55 downloadRef.current = url;
56 const link = document.createElement("a");
57 link.href = url;
58 link.download = `${props.device.name.replace(/[^a-z0-9-]/gi, "-")}-${Date.now()}.png`;
59 document.body.append(link);
60 link.click();
61 link.remove();
62 })
63 .catch((cause: unknown) => {
64 if (!controller.signal.aborted) {
65 if (cause instanceof DeviceScreenshotError && cause.status === 401)
66 refreshDeviceHubAccess(props.environmentId);
67 setScreenshotError(
68 cause instanceof Error ? cause.message : "Screenshot capture failed. Try again.",
69 );
70 }
71 })
72 .finally(() => {
73 if (captureRef.current === controller) {
74 captureRef.current = null;
75 setScreenshotPending(false);
76 }
77 });
78 };
79 return (
80 <>
81 <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
82 {screenshotError || (controls.error && !toolsOpen) ? (
83 <p role="alert" className="px-3 py-2 text-xs text-destructive">
84 {screenshotError ?? controls.error}
85 </p>
86 ) : null}
87 <div className="min-h-0 flex-1">
88 <DeviceStreamView
89 environmentId={props.environmentId}
90 platform={props.device.platform}
91 deviceName={props.device.name}
92 deviceDescription={`${props.hostLabel} · ${props.device.version}`}
93 deviceId={props.device.id}
94 hostId={props.device.hostId}
95 visible={props.visible}
96 axOverlay={axOverlay}
97 allowPhoneView
98 onHandle={setHandle}
99 renderControls={(view) => (
100 <DeviceControlsRail
101 platform={props.device.platform}
102 handle={handle}
103 view={view}
104 controls={controls}
105 screenshotPending={screenshotPending}
106 onScreenshot={() => void saveScreenshot()}
107 toolsOpen={toolsOpen}
108 onTools={() => setToolsOpen(!toolsOpen)}
109 onFloat={props.onFloat}
110 onClose={props.onClose}
111 onPowerOff={props.onPowerOff}
112 />
113 )}
114 />
115 </div>
116 </div>
117 {toolsOpen ? (
118 <DeviceToolsPanel
119 device={props.device}
120 controls={controls}
121 hostDiagnostics={props.hostDiagnostics}
122 access={access}
123 axOverlay={axOverlay}
124 onAxOverlayChange={setAxOverlay}
125 onClose={() => setToolsOpen(false)}
126 className="absolute inset-y-0 right-0 z-10 w-full max-w-72 border-l shadow-lg @[700px]:static @[700px]:w-72 @[700px]:shrink-0 @[700px]:shadow-none"
127 />
128 ) : null}
129 </>
130 );
131}
apps/web/src/components/device/deviceFrameLayout.test.ts created+9
...@@ -0,0 +1,9 @@
1import { expect, it } from "vite-plus/test";
2import { fitDeviceFrame } from "./deviceFrameLayout";
3
4it("fits a flat device beside the fixed controls rail", () => {
5 const frame = fitDeviceFrame(9 / 19.5, 480, 1_000, 56);
6 expect(frame.width + 56).toBeLessThanOrEqual(480);
7 expect(frame.height).toBeLessThanOrEqual(1_000);
8 expect(frame.width / frame.height).toBeCloseTo(9 / 19.5);
9});
apps/web/src/components/device/deviceFrameLayout.ts created+8
...@@ -0,0 +1,8 @@
1export function fitDeviceFrame(aspect: number, width: number, height: number, rightInset = 0) {
2 const availableWidth = Math.max(0, width - rightInset);
3 if (availableWidth === 0 || height === 0) return { width: 0, height: 0 };
4 const byHeight = { width: height * aspect, height };
5 return byHeight.width <= availableWidth
6 ? byHeight
7 : { width: availableWidth, height: availableWidth / aspect };
8}
apps/web/src/components/device/deviceModels.ts created+35
...@@ -0,0 +1,35 @@
1import {
2 resolveDeviceModelId,
3 type DeviceAccessorySource,
4 type DeviceModelSource,
5} from "@t3tools/client-runtime/device/model";
6import type { DevicePlatform } from "@t3tools/contracts";
7import iphone18Pro from "./models/iphone-18-pro.glb?url";
8import iphone18ProMax from "./models/iphone-18-pro-max.glb?url";
9import magicKeyboard from "./models/ipad-pro-13-m5-magic-keyboard.glb?url";
10import ipadPro13M5 from "./models/ipad-pro-13-m5.glb?url";
11
12// Bundled URLs follow the client origin in local, desktop, hosted and remote sessions.
13const models: Record<DeviceModelSource["id"], DeviceModelSource> = {
14 "iphone-18-pro": { id: "iphone-18-pro", url: iphone18Pro },
15 "iphone-18-pro-max": { id: "iphone-18-pro-max", url: iphone18ProMax },
16 "ipad-pro-13-m5": { id: "ipad-pro-13-m5", url: ipadPro13M5 },
17};
18
19export function deviceModel(platform: DevicePlatform, name: string): DeviceModelSource | null {
20 const id = resolveDeviceModelId(platform, name);
21 return id ? models[id] : null;
22}
23
24const keyboard: DeviceAccessorySource = {
25 id: "ipad-pro-13-m5-magic-keyboard",
26 modelId: "ipad-pro-13-m5",
27 url: magicKeyboard,
28};
29
30export function deviceKeyboard(
31 platform: DevicePlatform,
32 name: string,
33): DeviceAccessorySource | null {
34 return resolveDeviceModelId(platform, name) === keyboard.modelId ? keyboard : null;
35}
apps/web/src/components/device/models/ipad-pro-13-m5-magic-keyboard.glb created
Binary files /dev/null and b/apps/web/src/components/device/models/ipad-pro-13-m5-magic-keyboard.glb differ
apps/web/src/components/device/models/ipad-pro-13-m5.glb created
Binary files /dev/null and b/apps/web/src/components/device/models/ipad-pro-13-m5.glb differ
apps/web/src/components/device/models/iphone-18-pro-max.glb created
Binary files /dev/null and b/apps/web/src/components/device/models/iphone-18-pro-max.glb differ
apps/web/src/components/device/models/iphone-18-pro.glb created
Binary files /dev/null and b/apps/web/src/components/device/models/iphone-18-pro.glb differ
apps/web/src/components/device/models/sources.json created+49
...@@ -0,0 +1,49 @@
1{
2 "author": "Apple Inc.",
3 "assetLicense": "No open-source or redistribution license has been established.",
4 "conversion": {
5 "blender": "4.5.9",
6 "gltfTransform": "4.2.1",
7 "changes": [
8 "Extracted device bodies and a separate Magic Keyboard accessory; excluded unrelated parts and presentation duplicates.",
9 "Normalized portrait axes, centered the screen and uniformly scaled its height to 2.2 scene units.",
10 "Replaced the baked marketing screen with a named placeholder for the live framebuffer.",
11 "Resized textures to at most 1024 pixels and encoded them as WebP; retained body geometry."
12 ]
13 },
14 "models": [
15 {
16 "id": "iphone-18-pro",
17 "file": "iphone-18-pro.glb",
18 "sourceUrl": "https://www.apple.com/105/media/us/iphone-18-pro/2026/591df885-5ee2-4173-86e6-401021249f7c/ar/iphone-18-pro-e-sim.usdz",
19 "sourceSha256": "0f2dac354fbc6505e7917983de460f2cc68e253f96aabf52e9f0468a4803f317",
20 "bodyNode": "UBGArkKGrAMRRnj",
21 "screenNode": "EHjrGDHNHzgNjtE.001"
22 },
23 {
24 "id": "iphone-18-pro-max",
25 "file": "iphone-18-pro-max.glb",
26 "sourceUrl": "https://www.apple.com/105/media/us/iphone-18-pro/2026/591df885-5ee2-4173-86e6-401021249f7c/ar/iphone-18-pro-e-sim.usdz",
27 "sourceSha256": "0f2dac354fbc6505e7917983de460f2cc68e253f96aabf52e9f0468a4803f317",
28 "bodyNode": "wUOhcMgiBmCgGaw",
29 "screenNode": "EHjrGDHNHzgNjtE"
30 },
31 {
32 "id": "ipad-pro-13-m5",
33 "file": "ipad-pro-13-m5.glb",
34 "sourceUrl": "https://www.apple.com/105/media/us/ipad-pro/2025/adee90db-c01e-430d-b726-fe64c0063f08/ar/ipad-pro-space-black.usdz",
35 "sourceSha256": "48098daf7c49117c81a4e4e1808899b93fad081faeba6c8b65124946b83b5ac6",
36 "bodyNode": "zRrSLDpdYmKeRJQ",
37 "screenNode": "lsDiIbtoSGSmWWZ"
38 },
39 {
40 "id": "ipad-pro-13-m5-magic-keyboard",
41 "file": "ipad-pro-13-m5-magic-keyboard.glb",
42 "sourceUrl": "https://www.apple.com/105/media/us/ipad-pro/2025/adee90db-c01e-430d-b726-fe64c0063f08/ar/ipad-pro-space-black.usdz",
43 "sourceSha256": "48098daf7c49117c81a4e4e1808899b93fad081faeba6c8b65124946b83b5ac6",
44 "bodyNode": "zRrSLDpdYmKeRJQ",
45 "screenNode": "lsDiIbtoSGSmWWZ",
46 "accessoryNode": "PoBqSMmyhhcJsBX"
47 }
48 ]
49}
apps/web/src/components/device/phoneTrackpad.test.ts created+105
...@@ -0,0 +1,105 @@
1import { afterEach, expect, it, vi } from "vite-plus/test";
2import { bindPhoneTrackpad } from "./phoneTrackpad";
3
4afterEach(() => vi.useRealTimers());
5
6class Canvas extends EventTarget {
7 getBoundingClientRect() {
8 return { width: 400, height: 800 } as DOMRect;
9 }
10}
11const wheel = (ctrlKey = false) =>
12 Object.assign(new Event("wheel", { cancelable: true }), {
13 deltaX: 16,
14 deltaY: -20,
15 deltaMode: 0,
16 ctrlKey,
17 });
18const gesture = (type: string, scale: number) =>
19 Object.assign(new Event(type, { cancelable: true }), { scale });
20
21it("consumes scrolling and page zoom only on the bound canvas and releases listeners on detach", () => {
22 const canvas = new Canvas();
23 const navigate = vi.fn(() => true);
24 const listeners = vi.spyOn(canvas, "addEventListener");
25 const binding = bindPhoneTrackpad(canvas, { navigate, endWheel: vi.fn() });
26 const swipe = wheel();
27 canvas.dispatchEvent(swipe);
28 const pinch = wheel(true);
29 canvas.dispatchEvent(pinch);
30 expect(swipe.defaultPrevented).toBe(true);
31 expect(pinch.defaultPrevented).toBe(true);
32 expect(navigate.mock.calls).toEqual([[{ type: "orbit", x: -0.04, y: 0.025 }]]);
33 expect(
34 listeners.mock.calls.every(
35 ([, , options]) => typeof options === "object" && options.passive === false,
36 ),
37 ).toBe(true);
38 binding.dispose();
39 const detached = wheel(true);
40 canvas.dispatchEvent(detached);
41 expect(detached.defaultPrevented).toBe(false);
42 expect(navigate).toHaveBeenCalledOnce();
43});
44
45it("consumes Safari pinch without zooming the model and resumes orbit after cancellation", () => {
46 const canvas = new Canvas();
47 const navigate = vi.fn(() => true);
48 const binding = bindPhoneTrackpad(canvas, { navigate, endWheel: vi.fn() });
49 canvas.dispatchEvent(gesture("gesturestart", 1));
50 canvas.dispatchEvent(gesture("gesturechange", 1.2));
51 canvas.dispatchEvent(wheel(true));
52 expect(navigate).not.toHaveBeenCalled();
53 binding.cancel();
54 canvas.dispatchEvent(wheel());
55 expect(navigate).toHaveBeenCalledOnce();
56 binding.dispose();
57});
58
59it("normalizes Chrome and Safari pinch, expires wheel sequences and cancels on detach", () => {
60 vi.useFakeTimers();
61 const canvas = new Canvas();
62 const pinch = { begin: vi.fn(() => true), move: vi.fn(), end: vi.fn() };
63 const navigate = vi.fn(() => true);
64 const binding = bindPhoneTrackpad(canvas, { navigate, endWheel: vi.fn() }, pinch);
65 canvas.dispatchEvent(wheel(true));
66 canvas.dispatchEvent(wheel(true));
67 expect(pinch.begin).toHaveBeenCalledOnce();
68 expect(pinch.move.mock.calls).toEqual([[0.2], [0.2]]);
69 vi.advanceTimersByTime(180);
70 expect(pinch.end).toHaveBeenCalledOnce();
71 canvas.dispatchEvent(gesture("gesturestart", 1));
72 canvas.dispatchEvent(gesture("gesturechange", 1.2));
73 expect(pinch.move).toHaveBeenLastCalledWith(Math.log(1.2));
74 canvas.dispatchEvent(wheel(true));
75 expect(pinch.move).toHaveBeenCalledTimes(3);
76 binding.dispose();
77 const count = pinch.move.mock.calls.length;
78 vi.advanceTimersByTime(500);
79 canvas.dispatchEvent(wheel(true));
80 expect(pinch.move).toHaveBeenCalledTimes(count);
81 expect(navigate).not.toHaveBeenCalled();
82});
83
84it("keeps a paused wheel orbit active until native release or browser fallback", () => {
85 vi.useFakeTimers();
86 const canvas = new Canvas();
87 const navigate = vi.fn(() => true);
88 const endWheel = vi.fn();
89 const binding = bindPhoneTrackpad(canvas, { navigate, endWheel });
90 canvas.dispatchEvent(wheel());
91 vi.advanceTimersByTime(500);
92 expect(endWheel).not.toHaveBeenCalled();
93 binding.endOrbit();
94 expect(endWheel).toHaveBeenCalledOnce();
95 canvas.dispatchEvent(wheel());
96 vi.advanceTimersByTime(1199);
97 expect(endWheel).toHaveBeenCalledOnce();
98 vi.advanceTimersByTime(1);
99 expect(endWheel).toHaveBeenCalledTimes(2);
100 canvas.dispatchEvent(wheel());
101 canvas.dispatchEvent(Object.assign(wheel(), { momentum: true }));
102 expect(endWheel).toHaveBeenCalledTimes(3);
103 expect(navigate).toHaveBeenCalledTimes(3);
104 binding.dispose();
105});
apps/web/src/components/device/phoneTrackpad.ts created+137
...@@ -0,0 +1,137 @@
1// @effect-diagnostics globalTimers:off - Canvas-local wheel gesture expiry.
2import {
3 phoneWheelNavigation,
4 type createPhoneInteraction,
5} from "@t3tools/client-runtime/device/phone-interaction";
6
7/** Canvas-local, non-passive listeners consume browser zoom. Safari reports cumulative pinch scale instead of Ctrl-wheel. */
8export function bindPhoneTrackpad(
9 canvas: Pick<
10 HTMLCanvasElement,
11 "addEventListener" | "removeEventListener" | "getBoundingClientRect"
12 >,
13 interaction: Pick<ReturnType<typeof createPhoneInteraction>, "navigate" | "endWheel">,
14 pinch?: {
15 begin: (x: number, y: number) => boolean;
16 move: (logScale: number) => void;
17 end: () => void;
18 },
19) {
20 let scale: number | null = null;
21 let wheelActive = false;
22 let timer: ReturnType<typeof setTimeout> | null = null;
23 let orbitActive = false;
24 let orbitTimer: ReturnType<typeof setTimeout> | null = null;
25 const endOrbit = () => {
26 if (orbitTimer) clearTimeout(orbitTimer);
27 orbitTimer = null;
28 if (!orbitActive) return;
29 orbitActive = false;
30 interaction.endWheel();
31 };
32 const finish = () => {
33 if (timer) clearTimeout(timer);
34 timer = null;
35 wheelActive = false;
36 scale = null;
37 pinch?.end();
38 };
39 const begin = (event: Event) => {
40 const rect = canvas.getBoundingClientRect();
41 const x = "clientX" in event && typeof event.clientX === "number" ? event.clientX : NaN;
42 const y = "clientY" in event && typeof event.clientY === "number" ? event.clientY : NaN;
43 pinch?.begin((x - rect.left) / rect.width, (y - rect.top) / rect.height);
44 };
45 const consume = (event: Event) => {
46 event.preventDefault();
47 event.stopPropagation();
48 };
49 const wheel = (event: WheelEvent) => {
50 consume(event);
51 if (scale !== null) return;
52 if (event.ctrlKey) {
53 endOrbit();
54 if (!wheelActive) {
55 begin(event);
56 wheelActive = true;
57 }
58 const unit =
59 event.deltaMode === 1
60 ? 16
61 : event.deltaMode === 2
62 ? canvas.getBoundingClientRect().height
63 : 1;
64 pinch?.move((-event.deltaY * unit) / 100);
65 if (timer) clearTimeout(timer);
66 timer = setTimeout(finish, 180);
67 return;
68 }
69 if (wheelActive) finish();
70 if ("momentum" in event && event.momentum === true) {
71 endOrbit();
72 return;
73 }
74 const rect = canvas.getBoundingClientRect();
75 const navigation = phoneWheelNavigation({
76 width: rect.width,
77 height: rect.height,
78 deltaX: event.deltaX,
79 deltaY: event.deltaY,
80 deltaMode: event.deltaMode,
81 ctrlKey: event.ctrlKey,
82 });
83 if (navigation && interaction.navigate(navigation)) {
84 orbitActive = true;
85 if (orbitTimer) clearTimeout(orbitTimer);
86 // Browsers without a release signal still return to a useful view.
87 orbitTimer = setTimeout(endOrbit, 1200);
88 }
89 };
90 const gestureScale = (event: Event) => {
91 if (
92 !("scale" in event) ||
93 typeof event.scale !== "number" ||
94 !Number.isFinite(event.scale) ||
95 event.scale <= 0
96 )
97 return null;
98 return event.scale;
99 };
100 const start = (event: Event) => {
101 consume(event);
102 endOrbit();
103 finish();
104 begin(event);
105 scale = gestureScale(event) ?? 1;
106 };
107 const change = (event: Event) => {
108 consume(event);
109 const next = gestureScale(event);
110 if (scale === null || next === null) return;
111 pinch?.move(Math.log(next / scale));
112 scale = next;
113 };
114 const end = (event: Event) => {
115 consume(event);
116 finish();
117 };
118 canvas.addEventListener("wheel", wheel, { passive: false });
119 canvas.addEventListener("gesturestart", start, { passive: false });
120 canvas.addEventListener("gesturechange", change, { passive: false });
121 canvas.addEventListener("gestureend", end, { passive: false });
122 return {
123 endOrbit,
124 cancel() {
125 endOrbit();
126 finish();
127 },
128 dispose() {
129 canvas.removeEventListener("wheel", wheel);
130 canvas.removeEventListener("gesturestart", start);
131 canvas.removeEventListener("gesturechange", change);
132 canvas.removeEventListener("gestureend", end);
133 endOrbit();
134 finish();
135 },
136 };
137}
apps/web/src/components/device/useDeviceControls.test.tsx created+232
...@@ -0,0 +1,232 @@
1import { EnvironmentId, type DeviceDetail, type DeviceSummary } from "@t3tools/contracts";
2import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access";
3import { act, useEffect } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6import { useDeviceControls, type DeviceControls } from "./useDeviceControls";
7
8type Result = { _tag: "Success"; value: DeviceDetail } | { _tag: "Failure"; cause: unknown };
9const { read, action, subscribe } = vi.hoisted(() => ({
10 read: vi.fn<() => Promise<Result>>(),
11 action: vi.fn<() => Promise<Result>>(),
12 subscribe:
13 vi.fn<(_target: unknown, onChange: (app: { id: string } | null) => void) => () => void>(),
14}));
15vi.mock("~/state/device", () => ({ deviceEnvironment: { detail: "detail", action: "action" } }));
16vi.mock("~/state/use-atom-command", () => ({
17 useAtomCommand: (command: string) => (command === "detail" ? read : action),
18}));
19vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device action failed" }));
20vi.mock("./deviceHubApi", () => ({ subscribeDeviceForeground: subscribe }));
21
22const device: DeviceSummary = {
23 hostId: "remote",
24 id: "phone",
25 name: "Phone",
26 platform: "ios",
27 version: "iOS",
28 booted: true,
29 physical: false,
30};
31const snapshot = (appearance: "light" | "dark"): Result => ({
32 _tag: "Success",
33 value: {
34 hostId: "remote",
35 deviceId: "phone",
36 settings: { appearance },
37 foregroundApp: null,
38 readAt: "2026-09-20T00:00:00Z",
39 },
40});
41function deferred() {
42 let resolve!: (value: Result) => void;
43 const promise = new Promise<Result>((done) => {
44 resolve = done;
45 });
46 return { promise, resolve };
47}
48let renderer: ReactTestRenderer | undefined;
49let controls: DeviceControls;
50function Probe({ visible, access = null }: { visible: boolean; access?: DeviceHubAccess | null }) {
51 const next = useDeviceControls({
52 environmentId: EnvironmentId.make("environment"),
53 device,
54 access,
55 visible,
56 });
57 useEffect(() => {
58 controls = next;
59 }, [next]);
60 return null;
61}
62beforeEach(() => {
63 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
64 read.mockReset();
65 action.mockReset();
66 subscribe.mockReset();
67 subscribe.mockReturnValue(() => {});
68 read.mockResolvedValue(snapshot("light"));
69});
70afterEach(async () => {
71 await act(async () => renderer?.unmount());
72 vi.unstubAllGlobals();
73});
74async function mount() {
75 await act(async () => {
76 renderer = create(<Probe visible />);
77 });
78}
79
80it("serializes rapid actions and retains confirmed settings while pending or after failure", async () => {
81 await mount();
82 const response = deferred();
83 action.mockReturnValueOnce(response.promise);
84 let pending!: Promise<void>;
85 await act(async () => {
86 pending = controls.act({ type: "setAppearance", value: "dark" });
87 await controls.act({ type: "setAppearance", value: "light" });
88 });
89 expect(action).toHaveBeenCalledOnce();
90 expect(controls.disabled).toBe(true);
91 expect(controls.detail?.settings.appearance).toBe("light");
92 await act(async () => {
93 response.resolve(snapshot("dark"));
94 await pending;
95 });
96 expect(controls.detail?.settings.appearance).toBe("dark");
97 action.mockResolvedValueOnce({ _tag: "Failure", cause: "offline" });
98 await act(async () => controls.act({ type: "setAppearance", value: "light" }));
99 expect(controls.detail?.settings.appearance).toBe("dark");
100 expect(controls.error).toBe("Device action failed");
101 expect(controls.disabled).toBe(false);
102});
103
104it("serializes host actions across hide/reopen and discards detached results", async () => {
105 await mount();
106 const response = deferred();
107 action.mockReturnValueOnce(response.promise);
108 let pending!: Promise<void>;
109 await act(async () => {
110 pending = controls.act({ type: "setAppearance", value: "dark" });
111 });
112 await act(async () => renderer!.update(<Probe visible={false} />));
113 expect(controls.disabled).toBe(true);
114 await act(async () => renderer!.update(<Probe visible />));
115 await act(async () => controls.act({ type: "setAppearance", value: "light" }));
116 expect(action).toHaveBeenCalledOnce();
117 expect(controls.disabled).toBe(true);
118 const confirmed = deferred();
119 read.mockReturnValueOnce(confirmed.promise);
120 await act(async () => {
121 response.resolve(snapshot("dark"));
122 await response.promise;
123 });
124 expect(controls.detail?.settings.appearance).toBe("light");
125 expect(controls.disabled).toBe(true);
126 await act(async () => {
127 confirmed.resolve(snapshot("dark"));
128 await pending;
129 });
130 expect(controls.detail?.settings.appearance).toBe("dark");
131 expect(controls.disabled).toBe(false);
132 action.mockResolvedValueOnce(snapshot("dark"));
133 await act(async () => controls.act({ type: "setAppearance", value: "dark" }));
134 expect(action).toHaveBeenCalledTimes(2);
135 expect(controls.detail?.settings.appearance).toBe("dark");
136});
137
138it("keeps actions disabled when settings cannot be confirmed after a detached command", async () => {
139 await mount();
140 const response = deferred();
141 action.mockReturnValueOnce(response.promise);
142 let pending!: Promise<void>;
143 await act(async () => {
144 pending = controls.act({ type: "setAppearance", value: "dark" });
145 });
146 await act(async () => renderer!.update(<Probe visible={false} />));
147 await act(async () => renderer!.update(<Probe visible />));
148 read.mockResolvedValueOnce({ _tag: "Failure", cause: "offline" });
149 await act(async () => {
150 response.resolve(snapshot("dark"));
151 await pending;
152 });
153 expect(controls.detail).toBeNull();
154 expect(controls.disabled).toBe(true);
155 expect(controls.error).toBe("Device action failed");
156 await act(async () => controls.act({ type: "setAppearance", value: "light" }));
157 expect(action).toHaveBeenCalledOnce();
158 await act(async () => renderer!.update(<Probe visible={false} />));
159 await act(async () => renderer!.update(<Probe visible />));
160 expect(controls.disabled).toBe(false);
161 expect(controls.error).toBeNull();
162});
163
164it("disables stale settings after a failed reopen read and recovers on a successful read", async () => {
165 await mount();
166 await act(async () => renderer!.update(<Probe visible={false} />));
167 read.mockResolvedValueOnce({ _tag: "Failure", cause: "offline" });
168 await act(async () => renderer!.update(<Probe visible />));
169 expect(controls.detail).toBeNull();
170 expect(controls.disabled).toBe(true);
171 expect(controls.error).toBe("Device action failed");
172 await act(async () => controls.act({ type: "setAppearance", value: "dark" }));
173 expect(action).not.toHaveBeenCalled();
174 await act(async () => renderer!.update(<Probe visible={false} />));
175 await act(async () => renderer!.update(<Probe visible />));
176 expect(controls.detail?.settings.appearance).toBe("light");
177 expect(controls.disabled).toBe(false);
178 expect(controls.error).toBeNull();
179});
180
181it("does not let an older refresh overwrite a newer confirmed action", async () => {
182 await mount();
183 await act(async () => renderer!.update(<Probe visible={false} />));
184 const refresh = deferred();
185 read.mockReturnValueOnce(refresh.promise);
186 await act(async () => renderer!.update(<Probe visible />));
187 action.mockResolvedValueOnce(snapshot("dark"));
188 await act(async () => controls.act({ type: "setAppearance", value: "dark" }));
189 await act(async () => {
190 refresh.resolve(snapshot("light"));
191 await refresh.promise;
192 });
193 expect(controls.detail?.settings.appearance).toBe("dark");
194});
195
196it.each(["hidden", "access renewed"] as const)(
197 "uses the detail snapshot until a new foreground event after the subscription is %s",
198 async (restart) => {
199 const access: DeviceHubAccess = {
200 httpBase: "https://remote.test/api/device-hub",
201 wsBase: "wss://remote.test/api/device-hub",
202 query: {},
203 credentials: false,
204 };
205 const detail = snapshot("light");
206 if (detail._tag !== "Success") throw new Error("Expected a successful snapshot");
207 read.mockResolvedValue({
208 ...detail,
209 value: { ...detail.value, foregroundApp: { id: "snapshot.app" } },
210 });
211 await act(async () => {
212 renderer = create(<Probe visible access={access} />);
213 });
214 const oldEvent = subscribe.mock.calls[0]![1];
215 await act(async () => oldEvent({ id: "previous.live.app" }));
216 expect(controls.foregroundApp?.id).toBe("previous.live.app");
217 if (restart === "hidden") {
218 await act(async () => renderer!.update(<Probe visible={false} access={access} />));
219 await act(async () => renderer!.update(<Probe visible access={access} />));
220 } else {
221 await act(async () =>
222 renderer!.update(<Probe visible access={{ ...access, query: { ticket: "renewed" } }} />),
223 );
224 }
225 expect(controls.foregroundApp?.id).toBe("snapshot.app");
226 await act(async () => oldEvent({ id: "late.previous.app" }));
227 expect(controls.foregroundApp?.id).toBe("snapshot.app");
228 const newEvent = subscribe.mock.calls[1]![1];
229 await act(async () => newEvent({ id: "current.live.app" }));
230 expect(controls.foregroundApp?.id).toBe("current.live.app");
231 },
232);
apps/web/src/components/device/useDeviceControls.ts created+130
...@@ -0,0 +1,130 @@
1import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
2import type {
3 DeviceActionInput,
4 DeviceDetail,
5 DeviceSummary,
6 EnvironmentId,
7} from "@t3tools/contracts";
8import { useEffect, useMemo, useRef, useState } from "react";
9import { deviceEnvironment } from "~/state/device";
10import { formatEnvironmentQueryError } from "~/state/query";
11import { useAtomCommand } from "~/state/use-atom-command";
12import { subscribeDeviceForeground, type DeviceForegroundInfo } from "./deviceHubApi";
13
14type ActionBody = DeviceActionInput extends infer A
15 ? A extends { readonly type: string }
16 ? Omit<A, "hostId" | "deviceId">
17 : never
18 : never;
19
20/** One confirmed settings snapshot and serialized actions for the rail and drawer. Mount keyed by device. */
21export function useDeviceControls(options: {
22 environmentId: EnvironmentId;
23 device: DeviceSummary;
24 access: DeviceHubAccess | null;
25 visible: boolean;
26}) {
27 const { environmentId, device, access, visible } = options;
28 const readDetail = useAtomCommand(deviceEnvironment.detail, { reportFailure: false });
29 const runAction = useAtomCommand(deviceEnvironment.action, { reportFailure: false });
30 const [detail, setDetail] = useState<DeviceDetail | null>(null);
31 const [pending, setPending] = useState(false);
32 const [error, setError] = useState<string | null>(null);
33 const [foreground, setForeground] = useState<DeviceForegroundInfo | null | undefined>();
34 // Hiding invalidates UI results, but does not cancel host commands. Keep them serialized
35 // until settlement rather than allowing a reopened panel to race the previous command.
36 const busy = useRef(false);
37 const generation = useRef(0);
38 const visibleRead = useRef<Parameters<typeof readDetail>[0] | null>(null);
39 const target = useMemo(
40 () => ({ hostId: device.hostId, deviceId: device.id }),
41 [device.hostId, device.id],
42 );
43
44 useEffect(() => {
45 const revision = ++generation.current;
46 if (!visible) return;
47 const request = { environmentId, input: target };
48 visibleRead.current = request;
49 void readDetail(request).then((result) => {
50 if (generation.current !== revision) return;
51 if (result._tag === "Success") {
52 setDetail(result.value);
53 setError(null);
54 } else {
55 setDetail(null);
56 setError(formatEnvironmentQueryError(result.cause));
57 }
58 });
59 return () => {
60 visibleRead.current = null;
61 generation.current++;
62 };
63 }, [environmentId, readDetail, target, visible]);
64
65 useEffect(() => {
66 if (!access || !visible) return;
67 let active = true;
68 const unsubscribe = subscribeDeviceForeground(
69 { access, platform: device.platform, deviceId: device.id },
70 (app) => {
71 if (active) setForeground(app);
72 },
73 );
74 return () => {
75 active = false;
76 unsubscribe();
77 setForeground(undefined);
78 };
79 }, [access, device.id, device.platform, visible]);
80
81 const available = detail !== null && visible;
82 const act = async (body: ActionBody) => {
83 if (busy.current || !available) return;
84 busy.current = true;
85 // A settings read started before this action must not overwrite its confirmed result.
86 const revision = ++generation.current;
87 setPending(true);
88 setError(null);
89 return runAction({
90 environmentId,
91 input: { ...target, ...body } as DeviceActionInput,
92 })
93 .then((result) => {
94 if (generation.current !== revision) {
95 const request = visibleRead.current;
96 if (!request) return;
97 // Reopening can read settings before the host command finishes. Confirm them again
98 // after it settles, keeping actions serialized through this refresh.
99 const refreshRevision = ++generation.current;
100 return readDetail(request).then((refreshed) => {
101 if (generation.current !== refreshRevision) return;
102 if (refreshed._tag === "Success") {
103 setDetail(refreshed.value);
104 setError(null);
105 } else {
106 setDetail(null);
107 setError(formatEnvironmentQueryError(refreshed.cause));
108 }
109 });
110 }
111 if (result._tag === "Success") setDetail(result.value);
112 else setError(formatEnvironmentQueryError(result.cause));
113 })
114 .finally(() => {
115 busy.current = false;
116 setPending(false);
117 });
118 };
119
120 return {
121 detail,
122 pending,
123 error,
124 act,
125 disabled: pending || !detail || !visible,
126 foregroundApp: foreground === undefined ? (detail?.foregroundApp ?? null) : foreground,
127 };
128}
129
130export type DeviceControls = ReturnType<typeof useDeviceControls>;
packages/client-runtime/package.json+26
...@@ -266,6 +266,30 @@...@@ -266,6 +266,30 @@
266 "./file-preview": {266 "./file-preview": {
267 "types": "./src/filePreview.ts",267 "types": "./src/filePreview.ts",
268 "default": "./src/filePreview.ts"268 "default": "./src/filePreview.ts"
269 },
270 "./device/frame": {
271 "types": "./src/device/frame.ts",
272 "default": "./src/device/frame.ts"
273 },
274 "./device/phone-viewer": {
275 "types": "./src/device/phoneViewer.ts",
276 "default": "./src/device/phoneViewer.ts"
277 },
278 "./device/phone-interaction": {
279 "types": "./src/device/phoneInteraction.ts",
280 "default": "./src/device/phoneInteraction.ts"
281 },
282 "./device/shape-profile": {
283 "types": "./src/device/shapeProfile.ts",
284 "default": "./src/device/shapeProfile.ts"
285 },
286 "./device/screenshot": {
287 "types": "./src/device/screenshot.ts",
288 "default": "./src/device/screenshot.ts"
289 },
290 "./device/model": {
291 "types": "./src/device/model.ts",
292 "default": "./src/device/model.ts"
269 }293 }
270 },294 },
271 "scripts": {295 "scripts": {
...@@ -280,10 +304,12 @@...@@ -280,10 +304,12 @@
280 "micromark-extension-directive": "^4.0.0",304 "micromark-extension-directive": "^4.0.0",
281 "micromark-util-character": "^2.1.1",305 "micromark-util-character": "^2.1.1",
282 "remark-parse": "^11.0.0",306 "remark-parse": "^11.0.0",
307 "three": "0.180.0",
283 "unified": "^11.0.5"308 "unified": "^11.0.5"
284 },309 },
285 "devDependencies": {310 "devDependencies": {
286 "@effect/vitest": "catalog:",311 "@effect/vitest": "catalog:",
312 "@types/three": "0.180.0",
287 "micromark-util-types": "^2.0.2",313 "micromark-util-types": "^2.0.2",
288 "vite-plus": "catalog:"314 "vite-plus": "catalog:"
289 }315 }
packages/client-runtime/src/device/deviceFraming.test.ts created+53
...@@ -0,0 +1,53 @@
1import { expect, it } from "vite-plus/test";
2import { Box3, Vector3 } from "three";
3import { createDeviceFraming } from "./deviceFraming.ts";
4
5const box = (x: number, width: number) =>
6 new Box3(new Vector3(x - width / 2, -1, -0.2), new Vector3(x + width / 2, 1, 0.2));
7it("fits both axes through a moving pivot, freezes for contact, and stops once framing settles", () => {
8 const framing = createDeviceFraming();
9 framing.setBounds(box(0, 1), Math.PI / 8, 0.7, 0);
10 const initialDistance = framing.distance();
11 const next = box(1.5, 3);
12 framing.setBounds(next, Math.PI / 8, 0.7, 10);
13 framing.advance(50);
14 expect(framing.center.x).toBeGreaterThan(0);
15 expect(framing.center.x).toBeLessThan(1.5);
16 for (const x of [next.min.x, next.max.x])
17 expect(
18 Math.abs(x - framing.center.x) /
19 ((framing.distance() - next.max.z) * Math.tan(Math.PI / 8) * 0.7),
20 ).toBeLessThan(1);
21 framing.hold(true, 50);
22 const center = framing.center.clone(),
23 distance = framing.distance();
24 framing.advance(10000);
25 expect(framing.center.equals(center)).toBe(true);
26 expect(framing.distance()).toBe(distance);
27 expect(framing.needsFrame()).toBe(false);
28 framing.hold(false, 10000);
29 framing.advance(10016);
30 expect(framing.center.x).toBeLessThan(1.5);
31 framing.advance(12000);
32 expect(framing.center.x).toBe(1.5);
33 expect(framing.distance()).toBeGreaterThan(initialDistance);
34 expect(framing.needsFrame()).toBe(false);
35});
36
37it("uses elapsed time independently of frame partitions and fits a changed viewport immediately", () => {
38 const run = (step: number) => {
39 const framing = createDeviceFraming();
40 framing.setBounds(box(0, 1), Math.PI / 8, 1, 0);
41 framing.setBounds(box(-1, 2), Math.PI / 8, 1, 0);
42 for (let time = step; time < 400; time += step) framing.advance(time);
43 framing.advance(400);
44 return framing;
45 };
46 const fast = run(8),
47 slow = run(33);
48 expect(fast.center.distanceTo(slow.center)).toBeLessThan(1e-8);
49 expect(fast.distance()).toBeCloseTo(slow.distance(), 8);
50 fast.setBounds(box(-1, 2), Math.PI / 8, 0.4, 400, true);
51 expect(fast.distance()).toBeGreaterThan(slow.distance());
52 expect(fast.needsFrame()).toBe(false);
53});
packages/client-runtime/src/device/deviceFraming.ts created+82
...@@ -0,0 +1,82 @@
1import { Box3, Vector3 } from "three";
2
3/** Fit the current assembly, with independent framing dynamics so a leaf pivot does not drag it off-screen. */
4export function createDeviceFraming() {
5 const center = new Vector3();
6 const target = new Vector3();
7 const velocity = new Vector3();
8 const bounds = new Box3();
9 let distance = 1;
10 let targetDistance = 1;
11 let distanceVelocity = 0;
12 let tanX = 1;
13 let tanY = 1;
14 let at = 0;
15 let initialized = false;
16 let moving = false;
17 let held = false;
18 const advance = (now: number, immediate = false) => {
19 if (held) return false;
20 const dt = Math.max(0, (now - at) / 1000);
21 at = now;
22 if (!moving) return false;
23 const omega = 14;
24 const decay = Math.exp(-omega * dt);
25 const error = center.clone().sub(target);
26 const coefficient = velocity.clone().addScaledVector(error, omega);
27 center.copy(error).addScaledVector(coefficient, dt).multiplyScalar(decay).add(target);
28 velocity.addScaledVector(coefficient, -omega * dt).multiplyScalar(decay);
29 const distanceError = distance - targetDistance;
30 const distanceCoefficient = distanceVelocity + omega * distanceError;
31 distance = targetDistance + (distanceError + distanceCoefficient * dt) * decay;
32 distanceVelocity = (distanceVelocity - omega * distanceCoefficient * dt) * decay;
33 moving =
34 center.distanceTo(target) > 0.0005 ||
35 velocity.length() > 0.005 ||
36 Math.abs(distance - targetDistance) > 0.0005 ||
37 Math.abs(distanceVelocity) > 0.005;
38 if (immediate || !moving) {
39 center.copy(target);
40 distance = targetDistance;
41 velocity.set(0, 0, 0);
42 distanceVelocity = 0;
43 moving = false;
44 }
45 return true;
46 };
47 return {
48 center,
49 setBounds(next: Box3, vertical: number, aspect: number, now: number, immediate = false) {
50 if (next.isEmpty()) return;
51 advance(now);
52 bounds.copy(next);
53 tanY = Math.tan(vertical);
54 tanX = tanY * aspect;
55 bounds.getCenter(target);
56 target.z = 0;
57 const size = bounds.getSize(new Vector3());
58 targetDistance = Math.max(1, Math.max(size.x / tanX, size.y / tanY) * 0.565 + bounds.max.z);
59 moving = true;
60 advance(now, immediate || !initialized);
61 initialized = true;
62 },
63 advance,
64 distance() {
65 // Spring lag must never clip the fitted assembly.
66 const clearance = Math.max(
67 Math.abs(bounds.min.x - center.x) / tanX,
68 Math.abs(bounds.max.x - center.x) / tanX,
69 Math.abs(bounds.min.y - center.y) / tanY,
70 Math.abs(bounds.max.y - center.y) / tanY,
71 );
72 return Math.max(distance, clearance * 1.02 + bounds.max.z, 1);
73 },
74 hold(active: boolean, now: number) {
75 held = active;
76 at = now;
77 },
78 needsFrame() {
79 return moving && !held;
80 },
81 };
82}
packages/client-runtime/src/device/deviceMotion.test.ts created+175
...@@ -0,0 +1,175 @@
1import { expect, it, vi } from "vite-plus/test";
2import { Quaternion, Vector3 } from "three";
3import { createDeviceMotion, rotationVector } from "./deviceMotion.ts";
4import { nearestDeviceView } from "./deviceViewSnap.ts";
5
6const snaps = ["portrait", "landscape_left", "portrait_upside_down", "landscape_right"].map(
7 (orientation, index) => ({
8 orientation,
9 rotation: new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), (-index * Math.PI) / 2),
10 yawLimit: Math.PI / 3,
11 }),
12);
13const rotation = (x: number, y: number, z = 0) =>
14 new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), Math.hypot(x, y, z));
15
16it("selects the nearest quarter turn, retains a nearby yaw, and treats quaternion signs identically", () => {
17 const side = new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), -Math.PI / 2);
18 const released = side.clone().premultiply(rotation(0, 0.4));
19 const nearest = nearestDeviceView(released, snaps)!;
20 expect(nearest.orientation).toBe("landscape_left");
21 expect(nearest.rotation.angleTo(released)).toBeLessThan(1e-6);
22 const negative = released.clone().set(-released.x, -released.y, -released.z, -released.w);
23 expect(nearestDeviceView(negative, snaps)!.rotation.angleTo(nearest.rotation)).toBeLessThan(1e-6);
24 expect(
25 nearestDeviceView(rotation(0, 3), snaps)!.rotation.angleTo(new Quaternion()),
26 ).toBeLessThanOrEqual(Math.PI / 3 + 1e-6);
27});
28
29it("springs toward a cumulative drag without teleporting and keeps the same target across event partitions", () => {
30 const run = (parts: number) => {
31 const choose = vi.fn((q: Quaternion) => q);
32 const motion = createDeviceMotion({ choose });
33 motion.dragActive(true, 0);
34 for (let part = 0; part < parts; part++) motion.orbit(160 / parts, 80 / parts, 0);
35 expect(motion.rotation.angleTo(new Quaternion())).toBeLessThan(1e-6);
36 for (let time = 8; time <= 400; time += 8) motion.advance(time);
37 expect(motion.rotation.angleTo(new Quaternion())).toBeGreaterThan(0.5);
38 motion.dragActive(false, 400);
39 for (let time = 408; time <= 2400; time += 8) motion.advance(time);
40 expect(choose).toHaveBeenCalledOnce();
41 expect(motion.needsFrame()).toBe(false);
42 return motion.rotation;
43 };
44 expect(run(1).angleTo(run(20))).toBeLessThan(1e-6);
45});
46
47it("selects the predicted nearest view once and preserves velocity when interrupted", () => {
48 const choose = vi.fn((q: Quaternion) => nearestDeviceView(q, snaps)!.rotation);
49 const motion = createDeviceMotion({ choose });
50 motion.dragActive(true, 0);
51 motion.orbit(0, 240, 0);
52 motion.orbit(0, 20, 20);
53 motion.advance(40);
54 motion.dragActive(false, 40);
55 const before = motion.rotation.clone();
56 motion.advance(80);
57 expect(motion.rotation.angleTo(before)).toBeGreaterThan(0.01);
58 const interrupted = motion.rotation.clone();
59 motion.dragActive(true, 80);
60 expect(motion.rotation.angleTo(interrupted)).toBeLessThan(1e-6);
61 motion.orbit(30, -30, 80);
62 motion.advance(96);
63 motion.dragActive(false, 100);
64 for (let time = 116; time <= 2500; time += 16) motion.advance(time);
65 expect(choose).toHaveBeenCalledTimes(2);
66 expect(motion.needsFrame()).toBe(false);
67});
68
69it("lets a hard flick coast through multiple turns before settling on a visible view", () => {
70 const choose = vi.fn(() => new Quaternion());
71 const motion = createDeviceMotion({ choose });
72 motion.dragActive(true, 0);
73 motion.orbit(0, 80, 16);
74 motion.advance(16);
75 motion.orbit(0, 80, 32);
76 motion.advance(32);
77 motion.dragActive(false, 32);
78
79 let previous = motion.rotation.clone();
80 let travel = 0;
81 let lateSpeed = 0;
82 for (let time = 40; time <= 8500; time += 8) {
83 motion.advance(time);
84 const distance = motion.rotation.angleTo(previous);
85 travel += distance;
86 if (time >= 2500) lateSpeed = Math.max(lateSpeed, distance / 0.008);
87 previous = motion.rotation.clone();
88 }
89 expect(travel).toBeGreaterThan(4 * Math.PI);
90 expect(lateSpeed).toBeLessThan(2.5);
91 expect(choose).toHaveBeenCalledOnce();
92 expect(motion.rotation.angleTo(new Quaternion())).toBeLessThan(1e-6);
93 expect(motion.needsFrame()).toBe(false);
94});
95
96it("release uses elapsed time equally at different frame rates and a captured contact freezes projection", () => {
97 const run = (step: number) => {
98 const motion = createDeviceMotion({ choose: () => new Quaternion() });
99 motion.dragActive(true, 0);
100 motion.orbit(160, 80, 0);
101 motion.advance(50);
102 motion.dragActive(false, 50);
103 for (let time = 50; time < 450; time += step) motion.advance(time);
104 motion.advance(450);
105 return motion;
106 };
107 const fast = run(8),
108 slow = run(33),
109 throttled = run(1000);
110 expect(fast.rotation.angleTo(slow.rotation)).toBeLessThan(1e-6);
111 expect(fast.rotation.angleTo(throttled.rotation)).toBeLessThan(1e-6);
112 fast.hold(true, 450);
113 const contact = fast.rotation.clone();
114 fast.orbit(100, 100, 500);
115 fast.advance(10000);
116 expect(fast.rotation.angleTo(contact)).toBeLessThan(1e-6);
117 fast.hold(false, 10000);
118 fast.advance(12000);
119 expect(fast.needsFrame()).toBe(false);
120});
121
122it("holds a trackpad orbit through a pause and snaps only when the gesture ends", () => {
123 const choose = vi.fn(() => new Quaternion());
124 const motion = createDeviceMotion({ choose });
125 motion.orbit(NaN, 0, 0);
126 expect(motion.needsFrame()).toBe(false);
127 motion.orbit(200, 100, 0);
128 motion.advance(100);
129 expect(choose).not.toHaveBeenCalled();
130 expect(motion.rotation.angleTo(new Quaternion())).toBeGreaterThan(0);
131 motion.advance(140, true);
132 expect(choose).not.toHaveBeenCalled();
133 const held = motion.rotation.clone();
134 expect(held.angleTo(new Quaternion())).toBeGreaterThan(0.5);
135 expect(motion.needsFrame()).toBe(false);
136 motion.orbit(80, 0, 200);
137 motion.advance(340, true);
138 expect(motion.rotation.angleTo(held)).toBeGreaterThan(0.1);
139 expect(choose).not.toHaveBeenCalled();
140 expect(motion.needsFrame()).toBe(false);
141 motion.dragActive(false, 340);
142 motion.advance(2500);
143 expect(choose).toHaveBeenCalledOnce();
144 expect(motion.rotation.angleTo(new Quaternion())).toBeLessThan(1e-6);
145 expect(motion.needsFrame()).toBe(false);
146 expect(rotationVector(rotation(0, Math.PI)).length()).toBeCloseTo(Math.PI);
147});
148
149it("keeps release angular speed bounded even across a back-facing half turn", () => {
150 const motion = createDeviceMotion({ choose: () => rotation(0, Math.PI) });
151 motion.setPose(rotation(0, Math.PI), 0);
152 let previous = motion.rotation.clone();
153 for (let time = 8; time <= 1600; time += 8) {
154 motion.advance(time);
155 expect(motion.rotation.angleTo(previous)).toBeLessThanOrEqual(9 * 0.008 + 1e-5);
156 previous = motion.rotation.clone();
157 }
158 expect(motion.rotation.angleTo(rotation(0, Math.PI))).toBeLessThan(1e-6);
159 expect(motion.needsFrame()).toBe(false);
160});
161
162it("a click without dragging resumes the interrupted resting view instead of stranding the device", () => {
163 const choose = vi.fn(() => new Quaternion());
164 const motion = createDeviceMotion({ choose });
165 const rest = rotation(0, 0.8);
166 motion.setPose(rest, 0);
167 motion.advance(50);
168 motion.dragActive(true, 50);
169 motion.advance(60);
170 motion.dragActive(false, 60);
171 motion.advance(2100);
172 expect(motion.rotation.angleTo(rest)).toBeLessThan(1e-6);
173 expect(choose).not.toHaveBeenCalled();
174 expect(motion.needsFrame()).toBe(false);
175});
packages/client-runtime/src/device/deviceMotion.ts created+279
...@@ -0,0 +1,279 @@
1import { Quaternion, Vector3 } from "three";
2
3/** Shortest rotation vector. q and -q represent the same orientation. */
4export function rotationVector(rotation: Quaternion) {
5 const q = rotation.clone().normalize();
6 if (q.w < 0) q.set(-q.x, -q.y, -q.z, -q.w);
7 const length = Math.hypot(q.x, q.y, q.z);
8 return length < 1e-8
9 ? new Vector3()
10 : new Vector3(q.x, q.y, q.z).multiplyScalar((2 * Math.atan2(length, q.w)) / length);
11}
12function fromVector(vector: Vector3) {
13 const angle = vector.length();
14 return angle < 1e-8
15 ? new Quaternion()
16 : new Quaternion().setFromAxisAngle(vector.clone().divideScalar(angle), angle);
17}
18
19// Recovered from Bitrig 0.25's rotation dynamics: point gain, drag force limit,
20// velocity limits, release prediction/blend, and the 0.5 / 0.78 release spring.
21const gain = 0.006;
22const response = 0.5;
23const damping = 0.78;
24const frequency = (2 * Math.PI) / response;
25const decay = frequency * damping;
26const stepSeconds = 1 / 120;
27// Fast releases combine exponential friction with a critically damped correction.
28const flickThreshold = 4.5;
29const flickLimit = 22;
30const spinDecay = 1.1;
31const settleFrequency = 2;
32
33/** A moving spring target follows cumulative camera-space gestures; release latches the nearest useful view. */
34export function createDeviceMotion(options: { choose: (rotation: Quaternion) => Quaternion }) {
35 const rotation = new Quaternion();
36 const target = new Quaternion();
37 const velocity = new Vector3();
38 const gestureVelocity = new Vector3();
39 let spring: { at: number; rotation: Quaternion; velocity: Vector3; steps: number } | null = null;
40 let spin: { at: number; rotation: Quaternion; velocity: Vector3; correction: Vector3 } | null =
41 null;
42 let drag: {
43 start: Quaternion;
44 rest: Quaternion;
45 x: number;
46 y: number;
47 error: Vector3;
48 at: number;
49 } | null = null;
50 let held = false;
51 let interruptedDrag = false;
52 let lastInput = -Infinity;
53 const beginSpring = (next: Quaternion, now: number) => {
54 target.copy(next).normalize();
55 spring = {
56 at: now,
57 rotation: rotation.clone(),
58 velocity: velocity.clone(),
59 steps: 0,
60 };
61 };
62 const release = (now: number) => {
63 if (!drag) return;
64 // A pause before lifting a pointer should not resurrect an old flick.
65 if (now - lastInput > 100) gestureVelocity.set(0, 0, 0);
66 const prediction = target
67 .clone()
68 .premultiply(fromVector(gestureVelocity.clone().multiplyScalar(0.085)));
69 velocity.multiplyScalar(0.2).addScaledVector(gestureVelocity, 0.8).clampLength(0, flickLimit);
70 const moved = drag.x !== 0 || drag.y !== 0;
71 const rest = drag.rest;
72 drag = null;
73 if (moved && velocity.length() >= flickThreshold) {
74 // Choose where the free spin would finish, then correct toward that view
75 // throughout the coast rather than handing off to a spring at low speed.
76 const projected = rotation
77 .clone()
78 .premultiply(fromVector(velocity.clone().multiplyScalar(1 / spinDecay)));
79 target.copy(options.choose(projected)).normalize();
80 spin = {
81 at: now,
82 rotation: rotation.clone(),
83 velocity: velocity.clone(),
84 correction: rotationVector(target.clone().multiply(projected.invert())),
85 };
86 return;
87 }
88 if (moved) beginSpring(options.choose(prediction), now);
89 else beginSpring(rest, now);
90 };
91 const advance = (now: number, reduced = false) => {
92 if (held || !Number.isFinite(now)) return false;
93 if (spin) {
94 const seconds = Math.max(0, (now - spin.at) / 1000);
95 const coast = Math.exp(-spinDecay * seconds);
96 const settle = Math.exp(-settleFrequency * seconds);
97 const correction = fromVector(
98 spin.correction.clone().multiplyScalar(1 - (1 + settleFrequency * seconds) * settle),
99 );
100 rotation
101 .copy(spin.rotation)
102 .premultiply(fromVector(spin.velocity.clone().multiplyScalar((1 - coast) / spinDecay)))
103 .premultiply(correction)
104 .normalize();
105 velocity
106 .copy(spin.velocity)
107 .multiplyScalar(coast)
108 .applyQuaternion(correction)
109 .addScaledVector(spin.correction, settleFrequency ** 2 * seconds * settle);
110 if (reduced || (rotation.angleTo(target) < 0.01 && velocity.length() < 0.08)) {
111 spin = null;
112 rotation.copy(target);
113 velocity.set(0, 0, 0);
114 }
115 return true;
116 }
117 if (drag) {
118 // Follow the target during the gesture. Retain the logarithm's winding so
119 // a long drag cannot reverse its spring force when crossing a half turn.
120 const seconds = Math.min(0.05, Math.max(0, (now - drag.at) / 1000));
121 drag.at = now;
122 const steps = Math.ceil(seconds * 120);
123 const dt = steps ? seconds / steps : 0;
124 for (let step = 0; step < steps; step++) {
125 const error = rotationVector(target.clone().multiply(rotation.clone().invert()));
126 if (error.lengthSq() > 1e-10) {
127 const axis = error.clone().normalize();
128 const turns = Math.round((drag.error.dot(axis) - error.length()) / (2 * Math.PI));
129 error.addScaledVector(axis, turns * 2 * Math.PI);
130 }
131 drag.error.copy(error);
132 const omega = (2 * Math.PI) / 0.68;
133 const acceleration = error
134 .clampLength(0, 1.2)
135 .multiplyScalar(omega * omega)
136 .addScaledVector(velocity, -2 * 1.12 * omega);
137 velocity.addScaledVector(acceleration, dt).clampLength(0, 9);
138 rotation.premultiply(fromVector(velocity.clone().multiplyScalar(dt))).normalize();
139 }
140 if (reduced) {
141 rotation.copy(target);
142 velocity.set(0, 0, 0);
143 }
144 return steps > 0 || reduced;
145 }
146 if (!spring) return false;
147 const seconds = Math.max(0, (now - spring.at) / 1000);
148 const steps = Math.min(240, Math.floor(seconds / stepSeconds));
149 const integrate = (q: Quaternion, speed: Vector3, dt: number) => {
150 const error = rotationVector(target.clone().multiply(q.clone().invert()));
151 const acceleration = error
152 .multiplyScalar(frequency * frequency)
153 .addScaledVector(speed, -2 * decay);
154 speed.addScaledVector(acceleration, dt).clampLength(0, 9);
155 q.premultiply(fromVector(speed.clone().multiplyScalar(dt))).normalize();
156 };
157 // Fixed ticks make release independent of display refresh rate. The final
158 // partial tick interpolates the next tick without committing it. This also
159 // keeps displayed angular speed bounded between tick boundaries.
160 for (; spring.steps < steps; spring.steps++)
161 integrate(spring.rotation, spring.velocity, stepSeconds);
162 rotation.copy(spring.rotation);
163 velocity.copy(spring.velocity);
164 const nextRotation = spring.rotation.clone();
165 const nextVelocity = spring.velocity.clone();
166 integrate(nextRotation, nextVelocity, stepSeconds);
167 const fraction = Math.min(1, (seconds - steps * stepSeconds) / stepSeconds);
168 rotation.slerp(nextRotation, fraction);
169 velocity.lerp(nextVelocity, fraction);
170 if (
171 reduced ||
172 steps === 240 ||
173 (rotation.angleTo(target) < 0.0005 && velocity.length() < 0.005)
174 ) {
175 rotation.copy(target);
176 velocity.set(0, 0, 0);
177 spring = null;
178 }
179 return true;
180 };
181 const beginDrag = (now: number) => {
182 if (held || drag) return;
183 advance(now);
184 drag = {
185 start: rotation.clone(),
186 rest: target.clone(),
187 x: 0,
188 y: 0,
189 error: new Vector3(),
190 at: now,
191 };
192 target.copy(rotation);
193 gestureVelocity.set(0, 0, 0);
194 lastInput = -Infinity;
195 spring = null;
196 spin = null;
197 };
198 return {
199 rotation,
200 setPose(next: Quaternion, now: number, immediate = false) {
201 advance(now);
202 drag = null;
203 spin = null;
204 beginSpring(next, now);
205 if (immediate) {
206 rotation.copy(target);
207 velocity.set(0, 0, 0);
208 spring = null;
209 }
210 },
211 dragActive(active: boolean, now: number) {
212 if (active) {
213 // A pointer takes over at the displayed pose, even if a wheel gesture
214 // was paused with its target still ahead of the spring.
215 if (drag || spin) {
216 advance(now);
217 drag = null;
218 spin = null;
219 }
220 beginDrag(now);
221 } else {
222 advance(now);
223 release(now);
224 }
225 },
226 /** Deltas are CSS pixels, independent of panel size and event partitioning. */
227 orbit(x: number, y: number, now: number) {
228 if (held || ![x, y, now].every(Number.isFinite) || (!x && !y)) return;
229 advance(now);
230 beginDrag(now);
231 if (!drag) return;
232 const previous = target.clone();
233 drag.x += x;
234 drag.y += y;
235 target
236 .copy(fromVector(new Vector3(drag.y * gain, drag.x * gain, 0)))
237 .multiply(drag.start)
238 .normalize();
239 const seconds = (now - lastInput) / 1000;
240 gestureVelocity.copy(rotationVector(target.clone().multiply(previous.invert())));
241 if (seconds > 0 && seconds < 0.1)
242 gestureVelocity.divideScalar(seconds).clampLength(0, flickLimit);
243 else gestureVelocity.set(0, 0, 0);
244 lastInput = now;
245 },
246 /** Captured device touches and hinge edits freeze projection, independently of orbit dragging. */
247 hold(active: boolean, now: number) {
248 if (held === active) return;
249 if (active) {
250 interruptedDrag = drag !== null || spin !== null;
251 drag = null;
252 spin = null;
253 spring = null;
254 velocity.set(0, 0, 0);
255 }
256 held = active;
257 if (!active) {
258 if (interruptedDrag) beginSpring(options.choose(rotation.clone()), now);
259 else if (rotation.angleTo(target) > 0.0005) beginSpring(target, now);
260 interruptedDrag = false;
261 }
262 },
263 advance,
264 needsFrame() {
265 return (
266 !held &&
267 (spring !== null ||
268 spin !== null ||
269 (drag !== null && (rotation.angleTo(target) > 0.0005 || velocity.length() > 0.005)))
270 );
271 },
272 reset(next: Quaternion, now: number) {
273 advance(now);
274 drag = null;
275 spin = null;
276 beginSpring(next, now);
277 },
278 };
279}
packages/client-runtime/src/device/deviceViewSnap.ts created+26
...@@ -0,0 +1,26 @@
1import { Quaternion, Vector3 } from "three";
2const y = new Vector3(0, 1, 0);
3
4/** Each useful view permits a small yaw. Pick the closest member of each family, then the closest family. */
5export function nearestDeviceView<T extends { rotation: Quaternion; yawLimit: number }>(
6 rotation: Quaternion,
7 snaps: readonly T[],
8) {
9 let closest: T | null = null;
10 let distance = Infinity;
11 for (const snap of snaps) {
12 const relative = rotation.clone().multiply(snap.rotation.clone().invert());
13 const turn = 2 * Math.atan2(relative.y, relative.w);
14 const yaw = Math.max(
15 -snap.yawLimit,
16 Math.min(snap.yawLimit, Math.atan2(Math.sin(turn), Math.cos(turn))),
17 );
18 const candidate = snap.rotation.clone().premultiply(new Quaternion().setFromAxisAngle(y, yaw));
19 const nextDistance = candidate.angleTo(rotation);
20 if (nextDistance < distance - 1e-8) {
21 distance = nextDistance;
22 closest = { ...snap, rotation: candidate };
23 }
24 }
25 return closest;
26}
packages/client-runtime/src/device/frame.test.ts created+32
...@@ -0,0 +1,32 @@
1import { expect, it, vi } from "vite-plus/test";
2import { createCanvasFrameSink } from "./frame.ts";
3
4it("retains a borrowed frame at its native size before notifying the viewer", () => {
5 const drawImage = vi.fn();
6 const canvas = {
7 width: 300,
8 height: 150,
9 getContext: () => ({ drawImage }),
10 } as unknown as HTMLCanvasElement;
11 const source = {} as CanvasImageSource;
12 const notify = vi.fn(() => {
13 expect(canvas.width).toBe(1170);
14 expect(canvas.height).toBe(2532);
15 expect(drawImage).toHaveBeenLastCalledWith(source, 0, 0, 1170, 2532);
16 });
17 createCanvasFrameSink(canvas, notify).present(source, 1170, 2532);
18 expect(notify).toHaveBeenCalledOnce();
19});
20
21it("reports when a canvas cannot present a frame", () => {
22 const canvas = {
23 width: 300,
24 height: 150,
25 getContext: () => null,
26 } as unknown as HTMLCanvasElement;
27 const notify = vi.fn();
28 expect(createCanvasFrameSink(canvas, notify).present({} as CanvasImageSource, 1170, 2532)).toBe(
29 false,
30 );
31 expect(notify).not.toHaveBeenCalled();
32});
packages/client-runtime/src/device/frame.ts created+24
...@@ -0,0 +1,24 @@
1/** A synchronous, borrowed frame. The producer releases its source after present returns. */
2export interface DeviceFrameSink {
3 readonly present: (source: CanvasImageSource, width: number, height: number) => boolean;
4}
5
6/** Retains the latest frame in a canvas; consumers can invalidate textures after each draw. */
7export function createCanvasFrameSink(
8 canvas: HTMLCanvasElement,
9 onFrame?: (width: number, height: number) => void,
10): DeviceFrameSink {
11 return {
12 present(source, width, height) {
13 const context = canvas.getContext("2d");
14 if (!context) return false;
15 if (canvas.width !== width || canvas.height !== height) {
16 canvas.width = width;
17 canvas.height = height;
18 }
19 context.drawImage(source, 0, 0, width, height);
20 onFrame?.(width, height);
21 return true;
22 },
23 };
24}
packages/client-runtime/src/device/model.test.ts created+125
...@@ -0,0 +1,125 @@
1import { expect, it, vi } from "vite-plus/test";
2import { createDeviceModelSlot, resolveDeviceModelId, type DeviceModelSource } from "./model.ts";
3
4const pro: DeviceModelSource = { id: "iphone-18-pro", url: "/pro.glb" };
5const max: DeviceModelSource = { id: "iphone-18-pro-max", url: "/max.glb" };
6
7function fixture() {
8 const pending: {
9 signal: AbortSignal;
10 resolve: (model: { dispose: () => void }) => void;
11 reject: (error: Error) => void;
12 }[] = [];
13 const install = vi.fn();
14 const load = vi.fn(
15 (_source: DeviceModelSource, signal: AbortSignal) =>
16 new Promise<{ dispose: () => void }>((resolve, reject) =>
17 pending.push({ signal, resolve, reject }),
18 ),
19 );
20 const slot = createDeviceModelSlot({ load, install });
21 return { slot, pending, install, load };
22}
23
24it("matches only exact supported hardware, including iPad size and generation", () => {
25 expect(resolveDeviceModelId("ios", "iPhone 18 Pro")).toBe(pro.id);
26 expect(resolveDeviceModelId("ios", "iPhone 18 Pro Max")).toBe(max.id);
27 expect(resolveDeviceModelId("ios", "iPad Pro 13-inch (M5)")).toBe("ipad-pro-13-m5");
28 for (const name of [
29 "iPhone 17 Pro",
30 "iPhone 18",
31 "iPad Pro 11-inch (M5)",
32 "iPad Pro 13-inch (M4)",
33 "Pixel_10_Pro",
34 ]) {
35 expect(resolveDeviceModelId("ios", name)).toBeNull();
36 }
37 expect(resolveDeviceModelId("android", "iPhone 18 Pro")).toBeNull();
38});
39
40it("cancels superseded downloads and releases a late decoded model without installing it", async () => {
41 const { slot, pending, install, load } = fixture();
42 slot.set(pro);
43 slot.set({ ...pro });
44 expect(load).toHaveBeenCalledTimes(1);
45 slot.set(max);
46 expect(pending[0]!.signal.aborted).toBe(true);
47 const latest = { dispose: vi.fn() };
48 const stale = { dispose: vi.fn() };
49 pending[1]!.resolve(latest);
50 await Promise.resolve();
51 pending[0]!.resolve(stale);
52 await Promise.resolve();
53 expect(install).toHaveBeenCalledExactlyOnceWith(latest);
54 expect(stale.dispose).toHaveBeenCalledOnce();
55 slot.set(null);
56 expect(install).toHaveBeenLastCalledWith(null);
57 expect(latest.dispose).toHaveBeenCalledOnce();
58 slot.dispose();
59 expect(latest.dispose).toHaveBeenCalledOnce();
60});
61
62it("keeps the fallback on request failure and disposes results that finish after teardown", async () => {
63 const { slot, pending, install } = fixture();
64 slot.set(pro);
65 pending[0]!.reject(new Error("offline"));
66 await Promise.resolve();
67 expect(install).not.toHaveBeenCalled();
68 slot.set(max);
69 slot.dispose();
70 expect(pending[1]!.signal.aborted).toBe(true);
71 const late = { dispose: vi.fn() };
72 pending[1]!.resolve(late);
73 await Promise.resolve();
74 expect(late.dispose).toHaveBeenCalledOnce();
75 expect(install).not.toHaveBeenCalled();
76});
77
78it("returns the current scene to the fallback before releasing its imported resources", async () => {
79 const { slot, pending, install } = fixture();
80 slot.set(pro);
81 const model = { dispose: vi.fn(() => expect(install).toHaveBeenLastCalledWith(null)) };
82 pending[0]!.resolve(model);
83 await Promise.resolve();
84 slot.dispose();
85 slot.dispose();
86 expect(model.dispose).toHaveBeenCalledOnce();
87});
88
89it("retries a failed source while ignoring rejection of a superseded request", async () => {
90 const { slot, pending, install, load } = fixture();
91 slot.set(pro);
92 pending[0]!.reject(new Error("offline"));
93 await Promise.resolve();
94 slot.set({ ...pro });
95 expect(load).toHaveBeenCalledTimes(2);
96 slot.set(max);
97 pending[1]!.reject(new Error("cancelled"));
98 await Promise.resolve();
99 slot.set({ ...max });
100 expect(load).toHaveBeenCalledTimes(3);
101 const recovered = { dispose: vi.fn() };
102 pending[2]!.resolve(recovered);
103 await Promise.resolve();
104 expect(install).toHaveBeenCalledExactlyOnceWith(recovered);
105 slot.dispose();
106});
107
108it("releases an invalid model and restores the fallback if scene preparation rejects it", async () => {
109 const { slot, pending, install } = fixture();
110 install.mockImplementationOnce(() => {
111 throw new Error("invalid display");
112 });
113 slot.set(pro);
114 const model = { dispose: vi.fn() };
115 pending[0]!.resolve(model);
116 await Promise.resolve();
117 expect(model.dispose).toHaveBeenCalledOnce();
118 expect(install).toHaveBeenLastCalledWith(null);
119 slot.set({ ...pro });
120 const recovered = { dispose: vi.fn() };
121 pending[1]!.resolve(recovered);
122 await Promise.resolve();
123 expect(install).toHaveBeenLastCalledWith(recovered);
124 slot.dispose();
125});
packages/client-runtime/src/device/model.ts created+87
...@@ -0,0 +1,87 @@
1import type { DevicePlatform } from "@t3tools/contracts";
2
3export type DeviceModelId = "iphone-18-pro" | "iphone-18-pro-max" | "ipad-pro-13-m5";
4
5export interface DeviceModelSource {
6 readonly id: DeviceModelId;
7 readonly url: string;
8}
9
10export interface DeviceAccessorySource {
11 readonly id: "ipad-pro-13-m5-magic-keyboard";
12 readonly modelId: "ipad-pro-13-m5";
13 readonly url: string;
14}
15
16export type DeviceAssetSource = DeviceModelSource | DeviceAccessorySource;
17
18/** Match actual hardware, never stretch an available model to impersonate another device. */
19export function resolveDeviceModelId(platform: DevicePlatform, name: string): DeviceModelId | null {
20 if (platform !== "ios") return null;
21 if (/^iPhone 18 Pro Max$/i.test(name)) return "iphone-18-pro-max";
22 if (/^iPhone 18 Pro$/i.test(name)) return "iphone-18-pro";
23 if (/^iPad Pro 13-inch \(M5\)$/i.test(name)) return "ipad-pro-13-m5";
24 return null;
25}
26
27/** Each request owns its result. A cancelled or superseded parse must still release its resources. */
28export function createDeviceModelSlot<
29 T extends { dispose: () => void },
30 Source extends DeviceAssetSource = DeviceModelSource,
31>(options: {
32 load: (source: Source, signal: AbortSignal) => Promise<T>;
33 install: (model: T | null) => void;
34 onError?: ((cause: unknown) => void) | undefined;
35}) {
36 let request: { source: Source; controller: AbortController } | null = null;
37 let current: T | null = null;
38 let disposed = false;
39 const clear = () => {
40 if (current) options.install(null);
41 current?.dispose();
42 current = null;
43 };
44 return {
45 set(source: Source | null) {
46 if (disposed) return;
47 if (source?.id === request?.source.id && source?.url === request?.source.url) return;
48 request?.controller.abort();
49 request = null;
50 clear();
51 if (!source) return;
52 const next = { source, controller: new AbortController() };
53 request = next;
54 void options.load(source, next.controller.signal).then(
55 (model) => {
56 if (disposed || request !== next) {
57 model.dispose();
58 return;
59 }
60 try {
61 options.install(model);
62 current = model;
63 } catch (cause) {
64 options.install(null);
65 model.dispose();
66 request = null;
67 options.onError?.(cause);
68 }
69 },
70 (cause: unknown) => {
71 if (!disposed && request === next) {
72 request = null;
73 options.onError?.(cause);
74 }
75 // The procedural body stays installed on download, decoding or validation failure.
76 },
77 );
78 },
79 dispose() {
80 if (disposed) return;
81 disposed = true;
82 request?.controller.abort();
83 request = null;
84 clear();
85 },
86 };
87}
packages/client-runtime/src/device/modelScene.test.ts created+121
...@@ -0,0 +1,121 @@
1import {
2 BoxGeometry,
3 Group,
4 Mesh,
5 MeshBasicMaterial,
6 PerspectiveCamera,
7 PlaneGeometry,
8 Texture,
9 Vector3,
10} from "three";
11import { expect, it, vi } from "vite-plus/test";
12import { createImportedPhoneScene, disposeDeviceModel } from "./modelScene.ts";
13import { phoneDisplayLayout } from "./phoneScene.ts";
14
15function asset() {
16 const root = new Group();
17 const body = new Mesh(new BoxGeometry(1.15, 2.3, 0.1), new MeshBasicMaterial());
18 body.position.z = -0.02;
19 const screen = new Mesh(new PlaneGeometry(1, 2.2), new MeshBasicMaterial());
20 screen.geometry.translate(0, 0, 0.043);
21 screen.name = "device-screen";
22 root.add(body, screen);
23 return { root, screen, body };
24}
25
26it.each(["portrait", "landscape_left", "landscape_right", "portrait_upside_down"] as const)(
27 "retains the imported geometry while mapping screen input and framebuffer UVs in %s",
28 (orientation) => {
29 const { root, screen } = asset();
30 const texture = new Texture();
31 const original = screen.material;
32 // Optimization strips UVs from the placeholder material; the viewer generates framebuffer UVs.
33 screen.geometry.deleteAttribute("uv");
34 const phone = createImportedPhoneScene(root, texture, phoneDisplayLayout(null, 1206, 2622));
35 const layout = phoneDisplayLayout({ width: 1206, height: 2622, orientation }, 1206, 2622);
36 phone.setDisplay(texture, layout);
37 phone.root.rotation.set(0.2, -0.3, 0, "YXZ");
38 phone.orientation.rotation.z = layout.rotation;
39 phone.root.updateMatrixWorld(true);
40 const camera = new PerspectiveCamera(32, 1, 0.1, 30);
41 camera.position.z = 6;
42 camera.updateMatrixWorld(true);
43 const point = phone.orientation.localToWorld(new Vector3(-0.25, -0.55, 0.043)).project(camera);
44 const hit = phone.screenPoint((point.x + 1) / 2, (1 - point.y) / 2, camera);
45 const expected =
46 orientation === "landscape_left"
47 ? [0.25, 0.25]
48 : orientation === "landscape_right"
49 ? [0.75, 0.75]
50 : orientation === "portrait_upside_down"
51 ? [0.75, 0.25]
52 : [0.25, 0.75];
53 expect(hit?.x).toBeCloseTo(expected[0]!);
54 expect(hit?.y).toBeCloseTo(expected[1]!);
55 const geometry = screen.geometry;
56 const rotated = phoneDisplayLayout(
57 { width: 2622, height: 1206, orientation: "landscape_left" },
58 2622,
59 1206,
60 );
61 phone.setDisplay(texture, rotated);
62 expect(screen.geometry).toBe(geometry);
63 const pos = geometry.getAttribute("position");
64 const uv = geometry.getAttribute("uv");
65 for (let i = 0; i < pos.count; i++) {
66 expect(uv.getX(i)).toBeCloseTo(pos.getY(i) / 2.2 + 0.5);
67 expect(uv.getY(i)).toBeCloseTo(0.5 - pos.getX(i));
68 }
69 phone.dispose();
70 expect(screen.material).toBe(original);
71 disposeDeviceModel(root);
72 texture.dispose();
73 },
74);
75
76it("rejects touches from the rear and preserves borrowed textures when releasing imported resources", () => {
77 const { root, screen, body } = asset();
78 const texture = new Texture();
79 const borrowed = vi.spyOn(texture, "dispose");
80 const ownedTexture = new Texture();
81 const owned = vi.spyOn(ownedTexture, "dispose");
82 body.material.map = ownedTexture;
83 const geometry = vi.spyOn(body.geometry, "dispose");
84 const phone = createImportedPhoneScene(root, texture, phoneDisplayLayout(null, 1206, 2622));
85 const camera = new PerspectiveCamera(32, 1, 0.1, 30);
86 camera.position.z = 6;
87 phone.root.rotation.y = Math.PI;
88 expect(phone.screenPoint(0.5, 0.5, camera)).toBeNull();
89 phone.dispose();
90 expect(borrowed).not.toHaveBeenCalled();
91 expect(geometry).not.toHaveBeenCalled();
92 expect(screen.material.map).toBeNull();
93 disposeDeviceModel(root);
94 expect(owned).toHaveBeenCalledOnce();
95 expect(geometry).toHaveBeenCalledOnce();
96 expect(borrowed).not.toHaveBeenCalled();
97 texture.dispose();
98});
99
100it("rejects missing and incorrectly normalized displays before changing their materials", () => {
101 const { root, screen } = asset();
102 const original = screen.material;
103 const texture = new Texture();
104 screen.geometry.scale(1, 2, 1);
105 expect(() =>
106 createImportedPhoneScene(root, texture, phoneDisplayLayout(null, 1206, 2622)),
107 ).toThrow("not normalized");
108 expect(screen.material).toBe(original);
109 screen.geometry.scale(1, 0.5, 1);
110 root.rotation.y = Math.PI;
111 expect(() =>
112 createImportedPhoneScene(root, texture, phoneDisplayLayout(null, 1206, 2622)),
113 ).toThrow("not normalized");
114 expect(screen.material).toBe(original);
115 screen.name = "wrong-screen";
116 expect(() =>
117 createImportedPhoneScene(root, texture, phoneDisplayLayout(null, 1206, 2622)),
118 ).toThrow("one device-screen");
119 disposeDeviceModel(root);
120 texture.dispose();
121});
packages/client-runtime/src/device/modelScene.ts created+118
...@@ -0,0 +1,118 @@
1// @effect-diagnostics globalFetch:off - This browser-only renderer owns abortable asset requests without an Effect runtime, like the live stream.
2import { Box3, Group, Mesh, MeshBasicMaterial, Texture, type Material, type Object3D } from "three";
3import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
4import type { DeviceAssetSource } from "./model.ts";
5import { createDisplayProjection, updateDisplayUv, type PhoneDisplayLayout } from "./phoneScene.ts";
6
7/** GLB resources belong to one viewer; the live framebuffer texture belongs to its stream presentation. */
8export function disposeDeviceModel(root: Object3D) {
9 const geometries = new Set<Mesh["geometry"]>();
10 const materials = new Set<Material>();
11 const textures = new Set<Texture>();
12 root.traverse((object) => {
13 if (!(object instanceof Mesh)) return;
14 geometries.add(object.geometry);
15 for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
16 materials.add(material);
17 for (const value of Object.values(material)) {
18 if (value instanceof Texture) textures.add(value);
19 }
20 }
21 });
22 for (const geometry of geometries) geometry.dispose();
23 for (const material of materials) material.dispose();
24 const images = new Set<ImageBitmap>();
25 for (const texture of textures) {
26 texture.dispose();
27 if (typeof ImageBitmap !== "undefined" && texture.image instanceof ImageBitmap)
28 images.add(texture.image);
29 }
30 for (const image of images) image.close();
31}
32
33/** Assets are converted offline: portrait, front +Z, centered display, height 2.2 and planar display UVs. */
34export function createImportedPhoneScene(
35 asset: Group,
36 texture: Texture,
37 initial: PhoneDisplayLayout,
38) {
39 const screens: Mesh[] = [];
40 asset.traverse((object) => {
41 if (object instanceof Mesh && object.name === "device-screen") screens.push(object);
42 });
43 const display = screens[0];
44 if (!display || screens.length !== 1)
45 throw new Error("Device model must have one device-screen mesh");
46 asset.updateMatrixWorld(true);
47 const screenBounds = new Box3().setFromObject(display);
48 const screenWidth = screenBounds.max.x - screenBounds.min.x;
49 const screenHeight = screenBounds.max.y - screenBounds.min.y;
50 if (
51 display.matrixWorld.elements.some(
52 (value, index) =>
53 !Number.isFinite(value) || Math.abs(value - (index % 5 === 0 ? 1 : 0)) > 0.001,
54 ) ||
55 !Number.isFinite(screenWidth) ||
56 !Number.isFinite(screenHeight) ||
57 !Number.isFinite(screenBounds.min.z) ||
58 !Number.isFinite(screenBounds.max.z) ||
59 screenWidth <= 0 ||
60 Math.abs(screenHeight - 2.2) > 0.001 ||
61 Math.abs(screenBounds.min.x + screenBounds.max.x) > 0.001 ||
62 Math.abs(screenBounds.min.y + screenBounds.max.y) > 0.001 ||
63 screenBounds.max.z - screenBounds.min.z > 0.001
64 )
65 throw new Error("Device model display is not normalized");
66 updateDisplayUv(display.geometry, screenWidth, screenHeight, initial);
67 const originalMaterial = display.material;
68 const screenMaterial = new MeshBasicMaterial({ map: texture, toneMapped: false });
69 display.material = screenMaterial;
70 const root = new Group();
71 const orientation = new Group();
72 orientation.add(asset);
73 root.add(orientation);
74 const bounds = new Box3().setFromObject(asset);
75 let layout = initial;
76 const projection = createDisplayProjection(
77 display,
78 orientation,
79 screenWidth,
80 screenHeight,
81 () => layout,
82 );
83 return {
84 root,
85 orientation,
86 width: bounds.max.x - bounds.min.x,
87 height: bounds.max.y - bounds.min.y,
88 setDisplay(nextTexture: Texture, nextLayout: PhoneDisplayLayout) {
89 layout = nextLayout;
90 screenMaterial.map = nextTexture;
91 updateDisplayUv(display.geometry, screenWidth, screenHeight, layout);
92 },
93 screenPoint: projection,
94 dispose() {
95 // The model slot owns imported resources. This scene owns only its replacement screen material.
96 display.material = originalMaterial;
97 screenMaterial.map = null;
98 screenMaterial.dispose();
99 orientation.remove(asset);
100 },
101 };
102}
103
104export async function loadDeviceModel(source: DeviceAssetSource, signal: AbortSignal) {
105 const response = await fetch(source.url, { signal });
106 if (!response.ok) throw new Error(`Device model request failed: ${response.status}`);
107 const data = await response.arrayBuffer();
108 signal.throwIfAborted();
109 const gltf = await new GLTFLoader().parseAsync(
110 data,
111 new URL(".", new URL(source.url, globalThis.location.href)).href,
112 );
113 if (signal.aborted) {
114 disposeDeviceModel(gltf.scene);
115 signal.throwIfAborted();
116 }
117 return { asset: gltf.scene, dispose: () => disposeDeviceModel(gltf.scene) };
118}
packages/client-runtime/src/device/phoneInteraction.test.ts created+105
...@@ -0,0 +1,105 @@
1import { expect, it, vi } from "vite-plus/test";
2import { createPhoneInteraction, phoneWheelNavigation } from "./phoneInteraction.ts";
3
4it("keeps a captured screen gesture separate from orbit and ends it once on cancellation", () => {
5 const touch = vi.fn();
6 const orbit = vi.fn();
7 const screenPoint = vi
8 .fn()
9 .mockReturnValueOnce({ x: 0.2, y: 0.3 })
10 .mockReturnValueOnce({ x: 1, y: 0.6 });
11 const input = createPhoneInteraction({ screenPoint, touch, orbit, zoomBy: vi.fn() });
12 expect(input.begin(1, { x: 0.4, y: 0.3 })).toBe(true);
13 expect(input.begin(2, { x: 0.2, y: 0.2 })).toBe(false);
14 input.move(2, { x: 0.8, y: 0.8 });
15 input.move(1, { x: 1.3, y: 0.6 });
16 input.end(2);
17 input.end();
18 input.end(1);
19 expect(touch.mock.calls).toEqual([
20 ["begin", { x: 0.2, y: 0.3 }],
21 ["move", { x: 1, y: 0.6 }],
22 ["end", { x: 1, y: 0.6 }],
23 ]);
24 expect(orbit).not.toHaveBeenCalled();
25 expect(screenPoint.mock.calls[1]?.[1]).toBe(true);
26});
27
28it("orbits without sending touches when dragged outside the screen or with the orbit modifier", () => {
29 const touch = vi.fn();
30 const orbit = vi.fn();
31 const input = createPhoneInteraction({ screenPoint: () => null, touch, orbit, zoomBy: vi.fn() });
32 input.begin(1, { x: 0.1, y: 0.2 });
33 input.move(1, { x: 0.4, y: 0.5 });
34 input.end();
35 input.begin(2, { x: 0.5, y: 0.5 }, true);
36 input.move(2, { x: 0.6, y: 0.5 });
37 input.end();
38 expect(orbit).toHaveBeenCalledTimes(2);
39 expect(orbit.mock.calls[0]?.[0]).toBeCloseTo(0.3);
40 expect(touch).not.toHaveBeenCalled();
41});
42
43it("blocks navigation during either captured gesture, then resumes without sending device touches", () => {
44 const touch = vi.fn();
45 const orbit = vi.fn();
46 const zoomBy = vi.fn();
47 const input = createPhoneInteraction({
48 screenPoint: () => ({ x: 0.5, y: 0.5 }),
49 touch,
50 orbit,
51 zoomBy,
52 });
53 input.begin(1, { x: 0.5, y: 0.5 });
54 expect(input.navigate({ type: "zoom", delta: 0.2 })).toBe(false);
55 expect(input.navigate({ type: "orbit", x: 0.1, y: 0.2 })).toBe(false);
56 input.end();
57 input.begin(2, { x: 0.5, y: 0.5 }, true);
58 expect(input.navigate({ type: "zoom", delta: 0.2 })).toBe(false);
59 input.end();
60 expect(input.navigate({ type: "zoom", delta: 0.2 })).toBe(true);
61 expect(input.navigate({ type: "orbit", x: 0.1, y: 0.2 })).toBe(true);
62 expect(zoomBy.mock.calls).toEqual([[0.2]]);
63 expect(orbit.mock.calls).toEqual([[0.1, 0.2]]);
64 expect(touch.mock.calls.map(([phase]) => phase)).toEqual(["begin", "end"]);
65});
66
67it("ends wheel orbit without ending a captured screen touch", () => {
68 const onInteractionActive = vi.fn();
69 const input = createPhoneInteraction({
70 screenPoint: () => ({ x: 0.5, y: 0.5 }),
71 touch: vi.fn(),
72 orbit: vi.fn(),
73 zoomBy: vi.fn(),
74 onInteractionActive,
75 });
76 input.endWheel();
77 expect(onInteractionActive).toHaveBeenCalledWith(false, "orbit");
78 input.begin(1, { x: 0.5, y: 0.5 });
79 onInteractionActive.mockClear();
80 input.endWheel();
81 expect(onInteractionActive).not.toHaveBeenCalled();
82 input.end(1);
83 expect(onInteractionActive).toHaveBeenCalledWith(false, "touch");
84});
85
86it("normalizes wheel units, distinguishes pinch, and bounds jumps from coarse scroll wheels", () => {
87 const wheel = { width: 400, height: 800, deltaX: 16, deltaY: 32, deltaMode: 0, ctrlKey: false };
88 expect(phoneWheelNavigation(wheel)).toEqual({ type: "orbit", x: -0.04, y: -0.04 });
89 expect(phoneWheelNavigation({ ...wheel, deltaX: 1, deltaY: 2, deltaMode: 1 })).toEqual(
90 phoneWheelNavigation(wheel),
91 );
92 expect(phoneWheelNavigation({ ...wheel, deltaX: 1, deltaY: 1, deltaMode: 2 })).toEqual({
93 type: "orbit",
94 x: -0.25,
95 y: -0.25,
96 });
97 expect(phoneWheelNavigation({ ...wheel, ctrlKey: true })).toEqual({ type: "zoom", delta: -0.32 });
98 expect(phoneWheelNavigation({ ...wheel, deltaY: -10000, ctrlKey: true })).toEqual({
99 type: "zoom",
100 delta: 1,
101 });
102 expect(phoneWheelNavigation({ ...wheel, width: 0 })).toBeNull();
103 expect(phoneWheelNavigation({ ...wheel, deltaY: NaN })).toBeNull();
104 expect(phoneWheelNavigation({ ...wheel, deltaMode: 9 })).toBeNull();
105});
packages/client-runtime/src/device/phoneInteraction.ts created+83
...@@ -0,0 +1,83 @@
1interface Point {
2 readonly x: number;
3 readonly y: number;
4}
5
6export type PhoneNavigation =
7 | { readonly type: "orbit"; readonly x: number; readonly y: number }
8 | { readonly type: "zoom"; readonly delta: number };
9
10/** Browser wheel units vary by device. Navigation uses viewport fractions and logarithmic zoom. */
11export function phoneWheelNavigation(input: {
12 readonly deltaX: number;
13 readonly deltaY: number;
14 readonly deltaMode: number;
15 readonly ctrlKey: boolean;
16 readonly width: number;
17 readonly height: number;
18}): PhoneNavigation | null {
19 const { deltaX, deltaY, deltaMode, ctrlKey, width, height } = input;
20 if (![deltaX, deltaY, width, height].every(Number.isFinite) || width <= 0 || height <= 0)
21 return null;
22 if (deltaMode !== 0 && deltaMode !== 1 && deltaMode !== 2) return null;
23 const unit = deltaMode === 1 ? 16 : deltaMode === 2 ? height : 1;
24 const clamp = (value: number, limit: number) => Math.min(limit, Math.max(-limit, value));
25 if (ctrlKey) return { type: "zoom", delta: clamp(-deltaY * unit * 0.01, 1) };
26 return {
27 type: "orbit",
28 x: clamp((-deltaX * unit) / width, 0.25),
29 y: clamp((-deltaY * unit) / height, 0.25),
30 };
31}
32
33/** A single pointer owns either a device gesture or an orbit until released or cancelled. */
34export function createPhoneInteraction(options: {
35 readonly screenPoint: (point: Point, captured: boolean) => Point | null;
36 readonly touch: (phase: "begin" | "move" | "end", point: Point) => void;
37 readonly orbit: (deltaX: number, deltaY: number) => void;
38 readonly zoomBy: (logDelta: number) => void;
39 readonly onInteractionActive?: (active: boolean, mode: "touch" | "orbit") => void;
40}) {
41 let active: { id: number; mode: "touch" | "orbit"; last: Point; screen: Point | null } | null =
42 null;
43 return {
44 navigate(gesture: PhoneNavigation) {
45 // Moving the camera during a captured device touch would change its projected coordinates.
46 if (active) return false;
47 if (gesture.type === "zoom") options.zoomBy(gesture.delta);
48 else options.orbit(gesture.x, gesture.y);
49 return true;
50 },
51 endWheel() {
52 if (!active) options.onInteractionActive?.(false, "orbit");
53 },
54 begin(id: number, point: Point, forceOrbit = false) {
55 if (active) return false;
56 const screen = forceOrbit ? null : options.screenPoint(point, false);
57 active = { id, mode: screen ? "touch" : "orbit", last: point, screen };
58 options.onInteractionActive?.(true, active.mode);
59 if (screen) options.touch("begin", screen);
60 return true;
61 },
62 move(id: number, point: Point) {
63 if (active?.id !== id) return;
64 if (active.mode === "touch") {
65 const screen = options.screenPoint(point, true);
66 if (screen) {
67 active.screen = screen;
68 options.touch("move", screen);
69 }
70 } else {
71 options.orbit(point.x - active.last.x, point.y - active.last.y);
72 }
73 active.last = point;
74 },
75 end(id?: number) {
76 if (!active || (id !== undefined && active.id !== id)) return;
77 const previous = active;
78 active = null;
79 if (previous.mode === "touch" && previous.screen) options.touch("end", previous.screen);
80 options.onInteractionActive?.(false, previous.mode);
81 },
82 };
83}
packages/client-runtime/src/device/phoneScene.test.ts created+150
...@@ -0,0 +1,150 @@
1import { Box3, PerspectiveCamera, Texture, Vector3 } from "three";
2import { describe, expect, it } from "vite-plus/test";
3import { createPhoneScene, phoneDisplayLayout } from "./phoneScene.ts";
4import { IOS_TABLET_SHAPE, IOS_PHONE_SHAPE, ANDROID_PHONE_SHAPE } from "./shapeProfile.ts";
5
6const orientations = [
7 "portrait",
8 "landscape_left",
9 "landscape_right",
10 "portrait_upside_down",
11] as const;
12
13describe("3D phone input", () => {
14 it.each([IOS_TABLET_SHAPE, IOS_PHONE_SHAPE, ANDROID_PHONE_SHAPE])(
15 "preserves input projection for the $id silhouette in every orientation",
16 (profile) => {
17 for (const orientation of orientations) {
18 const texture = new Texture();
19 const aspect = profile.id === "ios-tablet" ? 0.75 : 0.45;
20 const layout = phoneDisplayLayout(
21 { width: 1200 * aspect, height: 1200, orientation },
22 1200 * aspect,
23 1200,
24 );
25 const phone = createPhoneScene(texture, layout, profile);
26 const camera = new PerspectiveCamera(32, 1, 0.1, 30);
27 camera.position.z = 6;
28 camera.updateMatrixWorld(true);
29 phone.root.rotation.set(0.2, -0.3, 0, "YXZ");
30 phone.orientation.rotation.z = layout.rotation;
31 phone.root.updateMatrixWorld(true);
32 const point = new Vector3((-2.2 * aspect) / 4, -2.2 / 4, 0.043);
33 phone.orientation.localToWorld(point);
34 point.project(camera);
35 const hit = phone.screenPoint((point.x + 1) / 2, (1 - point.y) / 2, camera);
36 const expected =
37 orientation === "landscape_left"
38 ? { x: 0.25, y: 0.25 }
39 : orientation === "landscape_right"
40 ? { x: 0.75, y: 0.75 }
41 : orientation === "portrait_upside_down"
42 ? { x: 0.75, y: 0.25 }
43 : { x: 0.25, y: 0.75 };
44 expect(hit?.x).toBeCloseTo(expected.x);
45 expect(hit?.y).toBeCloseTo(expected.y);
46 phone.dispose();
47 texture.dispose();
48 }
49 },
50 );
51 it.each([0.6, 2.2])("keeps device coordinates accurate at %s camera zoom", (zoom) => {
52 const texture = new Texture();
53 const layout = phoneDisplayLayout(null, 900, 1950);
54 const phone = createPhoneScene(texture, layout);
55 const camera = new PerspectiveCamera(32, 0.65, 0.1, 30);
56 camera.position.z = 6 / zoom;
57 camera.updateMatrixWorld(true);
58 phone.root.rotation.set(0.2, -0.3, 0, "YXZ");
59 phone.root.updateMatrixWorld(true);
60 const point = new Vector3((-2.2 * layout.aspect) / 4, -2.2 / 4, 0.043);
61 phone.orientation.localToWorld(point);
62 point.project(camera);
63 const hit = phone.screenPoint((point.x + 1) / 2, (1 - point.y) / 2, camera);
64 expect(hit?.x).toBeCloseTo(0.25);
65 expect(hit?.y).toBeCloseTo(0.75);
66 phone.dispose();
67 texture.dispose();
68 });
69
70 it.each(orientations)("maps perspective hits to displayed coordinates in %s", (orientation) => {
71 const texture = new Texture();
72 const layout = phoneDisplayLayout({ width: 1170, height: 2532, orientation }, 1170, 2532);
73 const phone = createPhoneScene(texture, layout);
74 const camera = new PerspectiveCamera(32, 0.65, 0.1, 30);
75 camera.position.z = 6;
76 camera.updateMatrixWorld(true);
77 phone.root.rotation.set(0.3, -0.45, 0, "YXZ");
78 phone.orientation.rotation.z = layout.rotation;
79 phone.root.updateMatrixWorld(true);
80 // A point one quarter across and three quarters down the portrait display.
81 const point = new Vector3((-2.2 * layout.aspect) / 4, -2.2 / 4, 0.043);
82 phone.orientation.localToWorld(point);
83 point.project(camera);
84 const hit = phone.screenPoint((point.x + 1) / 2, (1 - point.y) / 2, camera);
85 const expected =
86 orientation === "landscape_left"
87 ? { x: 0.25, y: 0.25 }
88 : orientation === "landscape_right"
89 ? { x: 0.75, y: 0.75 }
90 : orientation === "portrait_upside_down"
91 ? { x: 0.75, y: 0.25 }
92 : { x: 0.25, y: 0.75 };
93 expect(hit?.x).toBeCloseTo(expected.x);
94 expect(hit?.y).toBeCloseTo(expected.y);
95 phone.dispose();
96 texture.dispose();
97 });
98
99 it("rejects new gestures outside the display, but clamps captured drags at its edge", () => {
100 const texture = new Texture();
101 const phone = createPhoneScene(texture, phoneDisplayLayout(null, 900, 1950));
102 const camera = new PerspectiveCamera(32, 1, 0.1, 30);
103 camera.position.z = 5;
104 expect(phone.screenPoint(1, 0.5, camera)).toBeNull();
105 expect(phone.screenPoint(1, 0.5, camera, true)).toEqual({ x: 1, y: 0.5 });
106 phone.root.rotation.y = Math.PI;
107 expect(phone.screenPoint(0.5, 0.5, camera)).toBeNull();
108 phone.dispose();
109 texture.dispose();
110 });
111
112 it.each(["landscape_left", "landscape_right"] as const)(
113 "unrotates an already-landscape framebuffer in %s",
114 (orientation) => {
115 const texture = new Texture();
116 const layout = phoneDisplayLayout({ width: 1950, height: 900, orientation }, 1950, 900);
117 const phone = createPhoneScene(texture, layout);
118 phone.orientation.rotation.z = layout.rotation;
119 const camera = new PerspectiveCamera(32, 1, 0.1, 30);
120 camera.position.z = 5;
121 expect(phone.screenPoint(0.5, 0.5, camera)).toEqual({ x: 0.5, y: 0.5 });
122 phone.dispose();
123 texture.dispose();
124 },
125 );
126});
127
128it.each([IOS_PHONE_SHAPE, IOS_TABLET_SHAPE, ANDROID_PHONE_SHAPE])(
129 "attaches the $id rear camera plate to the back and seats its lenses on the housing",
130 (profile) => {
131 const texture = new Texture();
132 const phone = createPhoneScene(texture, phoneDisplayLayout(null, 900, 1950), profile);
133 phone.root.updateMatrixWorld(true);
134 const back = new Box3().setFromObject(phone.root.getObjectByName("device-back")!);
135 const plate = new Box3().setFromObject(phone.root.getObjectByName("camera-plate")!);
136 expect(plate.min.z).toBeLessThan(back.min.z);
137 expect(plate.max.z).toBeGreaterThanOrEqual(back.min.z);
138 const assembly = phone.root.getObjectByName("rear-camera")!;
139 const rings = assembly.children.filter((part) => part.name === "camera-ring");
140 const lenses = assembly.children.filter((part) => part.name === "camera-lens");
141 for (const [index, ring] of rings.entries()) {
142 const ringBounds = new Box3().setFromObject(ring);
143 expect(ringBounds.intersectsBox(plate)).toBe(true);
144 const lensBounds = new Box3().setFromObject(lenses[index]!);
145 expect(Math.abs(lensBounds.max.z - ringBounds.min.z)).toBeLessThan(0.001);
146 }
147 phone.dispose();
148 texture.dispose();
149 },
150);
packages/client-runtime/src/device/phoneScene.ts created+302
...@@ -0,0 +1,302 @@
1import {
2 BoxGeometry,
3 CircleGeometry,
4 CylinderGeometry,
5 ExtrudeGeometry,
6 Float32BufferAttribute,
7 Group,
8 Mesh,
9 MeshBasicMaterial,
10 MeshPhysicalMaterial,
11 MeshStandardMaterial,
12 Plane,
13 Raycaster,
14 Shape,
15 ShapeGeometry,
16 Vector2,
17 Vector3,
18 type Camera,
19 type BufferGeometry,
20 type Texture,
21} from "three";
22import type { DeviceScreenSize } from "./stream.ts";
23import { IOS_PHONE_SHAPE, type DeviceShapeProfile } from "./shapeProfile.ts";
24
25const SCREEN_HEIGHT = 2.2;
26
27function roundedPath(width: number, height: number, radius: number, path = new Shape()) {
28 const x = -width / 2;
29 const y = -height / 2;
30 path.moveTo(x + radius, y);
31 path.lineTo(x + width - radius, y);
32 path.quadraticCurveTo(x + width, y, x + width, y + radius);
33 path.lineTo(x + width, y + height - radius);
34 path.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
35 path.lineTo(x + radius, y + height);
36 path.quadraticCurveTo(x, y + height, x, y + height - radius);
37 path.lineTo(x, y + radius);
38 path.quadraticCurveTo(x, y, x + radius, y);
39 return path;
40}
41
42/** Display orientation is independent of orbit. Texture coordinates stay in raw framebuffer space. */
43export function phoneDisplayLayout(
44 screen: DeviceScreenSize | null,
45 rawWidth: number,
46 rawHeight: number,
47) {
48 const width = rawWidth || screen?.width || 900;
49 const height = rawHeight || screen?.height || 1950;
50 const landscape =
51 screen?.orientation === "landscape_left" || screen?.orientation === "landscape_right";
52 const rotation =
53 screen?.orientation === "landscape_left"
54 ? -Math.PI / 2
55 : screen?.orientation === "landscape_right"
56 ? Math.PI / 2
57 : screen?.orientation === "portrait_upside_down"
58 ? Math.PI
59 : 0;
60 return {
61 aspect: Math.min(width, height) / Math.max(width, height),
62 rotation,
63 landscape,
64 rawLandscape: width > height,
65 };
66}
67
68/** An original procedural device body. It makes no claim to reproduce a particular hardware model. */
69export function createPhoneScene(
70 texture: Texture,
71 layout: ReturnType<typeof phoneDisplayLayout>,
72 profile: DeviceShapeProfile = IOS_PHONE_SHAPE,
73) {
74 const { aspect } = layout;
75 const root = new Group();
76 const orientation = new Group();
77 root.add(orientation);
78 const screenWidth = SCREEN_HEIGHT * aspect;
79 const width = screenWidth + profile.bezel * 2;
80 const height = SCREEN_HEIGHT + profile.bezel * 2;
81 const backZ = 0.01 - profile.depth;
82 const metal = new MeshStandardMaterial({ color: 0xb5bcc7, metalness: 0.88, roughness: 0.27 });
83 const glass = new MeshPhysicalMaterial({
84 color: 0x141820,
85 metalness: 0.15,
86 roughness: 0.2,
87 clearcoat: 1,
88 });
89 const backMaterial = new MeshStandardMaterial({
90 color: profile.backColor,
91 metalness: 0.45,
92 roughness: 0.32,
93 });
94 const lensMaterial = new MeshPhysicalMaterial({
95 color: 0x071326,
96 metalness: 0.6,
97 roughness: 0.12,
98 clearcoat: 1,
99 });
100 const materials = [metal, glass, backMaterial, lensMaterial];
101
102 const body = new Mesh(
103 new ExtrudeGeometry(roundedPath(width, height, profile.bodyRadius), {
104 depth: profile.depth,
105 bevelEnabled: true,
106 bevelSize: 0.012,
107 bevelThickness: 0.012,
108 bevelSegments: 3,
109 steps: 1,
110 curveSegments: 12,
111 }),
112 metal,
113 );
114 body.position.z = 0.025 - profile.depth;
115 orientation.add(body);
116 const face = new Mesh(
117 new ShapeGeometry(roundedPath(width - 0.014, height - 0.014, profile.bodyRadius - 0.01), 16),
118 glass,
119 );
120 face.position.z = 0.04;
121 orientation.add(face);
122 const back = new Mesh(
123 new ShapeGeometry(roundedPath(width - 0.012, height - 0.012, profile.bodyRadius - 0.01), 16),
124 backMaterial,
125 );
126 back.name = "device-back";
127 back.rotation.y = Math.PI;
128 back.position.z = backZ;
129 orientation.add(back);
130
131 const screenGeometry = new ShapeGeometry(
132 roundedPath(screenWidth, SCREEN_HEIGHT, profile.screenRadius),
133 20,
134 );
135 updateDisplayUv(screenGeometry, screenWidth, SCREEN_HEIGHT, layout);
136 const screenMaterial = new MeshBasicMaterial({ map: texture, toneMapped: false });
137 const display = new Mesh(screenGeometry, screenMaterial);
138 display.position.z = 0.043;
139 orientation.add(display);
140
141 for (const { edge, offset, length } of profile.buttons) {
142 const button = new Mesh(
143 new BoxGeometry(
144 edge === "top" ? length : 0.026,
145 edge === "top" ? 0.026 : length,
146 profile.depth * 0.65,
147 ),
148 metal,
149 );
150 button.position.set(
151 edge === "top" ? offset : (edge === "left" ? -1 : 1) * (width / 2 + 0.015),
152 edge === "top" ? height / 2 + 0.015 : offset,
153 (0.025 + backZ) / 2,
154 );
155 orientation.add(button);
156 }
157 // Rear components share a surface-relative coordinate system, with outward positive Z.
158 const rearCamera = new Group();
159 rearCamera.name = "rear-camera";
160 rearCamera.position.set(
161 width / 2 - profile.camera.insetX,
162 height / 2 - profile.camera.insetY,
163 back.position.z + 0.001,
164 );
165 rearCamera.rotation.y = Math.PI;
166 orientation.add(rearCamera);
167 const plateFront = 0.025 + 0.007;
168 const ringDepth = 0.025;
169 const cameraPlate = new Mesh(
170 new ExtrudeGeometry(
171 roundedPath(
172 profile.camera.width,
173 profile.camera.height,
174 Math.min(profile.camera.width, profile.camera.height) / 4,
175 ),
176 {
177 depth: 0.025,
178 bevelEnabled: true,
179 bevelSize: 0.009,
180 bevelThickness: 0.007,
181 bevelSegments: 2,
182 },
183 ),
184 backMaterial,
185 );
186 cameraPlate.name = "camera-plate";
187 rearCamera.add(cameraPlate);
188 for (const [x, y] of profile.camera.lenses) {
189 const radius = profile.camera.lensRadius;
190 const ring = new Mesh(new CylinderGeometry(radius + 0.014, radius + 0.014, 0.025, 32), metal);
191 ring.rotation.x = Math.PI / 2;
192 ring.name = "camera-ring";
193 ring.position.set(x, y, plateFront + ringDepth / 2 - 0.003);
194 rearCamera.add(ring);
195 const lens = new Mesh(new CircleGeometry(radius, 32), lensMaterial);
196 lens.name = "camera-lens";
197 lens.position.set(x, y, ring.position.z + ringDepth / 2 + 0.0005);
198 rearCamera.add(lens);
199 }
200 const flashMaterial = new MeshBasicMaterial({ color: 0xf2ead6 });
201 if (profile.camera.flash) {
202 const flash = new Mesh(new CircleGeometry(0.022, 20), flashMaterial);
203 flash.position.set(profile.camera.flash[0], profile.camera.flash[1], plateFront + 0.0005);
204 rearCamera.add(flash);
205 }
206
207 let activeLayout = layout;
208 const screenPoint = createDisplayProjection(
209 display,
210 orientation,
211 screenWidth,
212 SCREEN_HEIGHT,
213 () => activeLayout,
214 );
215 return {
216 root,
217 orientation,
218 width,
219 height,
220 screenPoint,
221 setDisplay(nextTexture: Texture, nextLayout: PhoneDisplayLayout) {
222 activeLayout = nextLayout;
223 screenMaterial.map = nextTexture;
224 updateDisplayUv(screenGeometry, screenWidth, SCREEN_HEIGHT, activeLayout);
225 },
226 dispose() {
227 root.traverse((object) => {
228 if (object instanceof Mesh) object.geometry.dispose();
229 });
230 for (const material of materials) material.dispose();
231 screenMaterial.dispose();
232 flashMaterial.dispose();
233 },
234 };
235}
236
237export type PhoneDisplayLayout = ReturnType<typeof phoneDisplayLayout>;
238
239/** Canonical portrait geometry maps to raw framebuffer coordinates in every OS orientation. */
240export function updateDisplayUv(
241 geometry: BufferGeometry,
242 width: number,
243 height: number,
244 layout: PhoneDisplayLayout,
245) {
246 const position = geometry.getAttribute("position");
247 if (!geometry.hasAttribute("uv")) {
248 geometry.setAttribute(
249 "uv",
250 new Float32BufferAttribute(new Float32Array(position.count * 2), 2),
251 );
252 }
253 const uv = geometry.getAttribute("uv");
254 for (let i = 0; i < position.count; i++) {
255 const u = (position.getX(i) + width / 2) / width;
256 const v = (position.getY(i) + height / 2) / height;
257 uv.setXY(
258 i,
259 layout.rawLandscape ? (layout.rotation > 0 ? 1 - v : v) : u,
260 layout.rawLandscape ? (layout.rotation > 0 ? u : 1 - u) : v,
261 );
262 }
263 uv.needsUpdate = true;
264}
265
266/** New touches hit only the front display; captured drags project onto its plane and clamp. */
267export function createDisplayProjection(
268 display: Mesh,
269 orientation: Group,
270 width: number,
271 height: number,
272 getLayout: () => PhoneDisplayLayout,
273) {
274 const raycaster = new Raycaster();
275 const pointer = new Vector2();
276 const local = new Vector3();
277 display.geometry.computeBoundingBox();
278 const z = display.position.z + (display.geometry.boundingBox?.max.z ?? 0);
279 const plane = new Plane(new Vector3(0, 0, 1), -z);
280 return (x: number, y: number, camera: Camera, captured = false) => {
281 orientation.updateWorldMatrix(true, true);
282 camera.updateMatrixWorld(true);
283 pointer.set(x * 2 - 1, 1 - y * 2);
284 raycaster.setFromCamera(pointer, camera);
285 if (!captured) {
286 const hit = raycaster.intersectObject(display, false)[0];
287 if (!hit) return null;
288 local.copy(hit.point);
289 orientation.worldToLocal(local);
290 } else {
291 const ray = raycaster.ray.clone().applyMatrix4(orientation.matrixWorld.clone().invert());
292 if (!ray.intersectPlane(plane, local)) return null;
293 }
294 const u = Math.min(1, Math.max(0, (local.x + width / 2) / width));
295 const v = Math.min(1, Math.max(0, (local.y + height / 2) / height));
296 const { rotation } = getLayout();
297 if (rotation === -Math.PI / 2) return { x: v, y: u };
298 if (rotation === Math.PI / 2) return { x: 1 - v, y: 1 - u };
299 if (rotation === Math.PI) return { x: 1 - u, y: v };
300 return { x: u, y: 1 - v };
301 };
302}
packages/client-runtime/src/device/phoneViewer.test.ts created+350
...@@ -0,0 +1,350 @@
1import type { PerspectiveCamera, Scene, Object3D } from "three";
2import { afterEach, expect, it, vi } from "vite-plus/test";
3
4const gpu = vi.hoisted(() => ({
5 instances: [] as {
6 blank: boolean;
7 allocations: number;
8 disposed: boolean;
9 size: { width: number; height: number; pixelRatio: number };
10 frames: {
11 scene: Scene;
12 phone: Object3D | undefined;
13 rotation: Quaternion | undefined;
14 yaw: number | undefined;
15 cameraZ: number;
16 }[];
17 }[],
18}));
19
20vi.mock("three", async () => {
21 const actual = await vi.importActual<typeof import("three")>("three");
22 return {
23 ...actual,
24 WebGLRenderer: class {
25 outputColorSpace = "";
26 state: (typeof gpu.instances)[number] = {
27 blank: true,
28 allocations: 0,
29 disposed: false,
30 size: { width: 0, height: 0, pixelRatio: 1 },
31 frames: [],
32 };
33 constructor() {
34 gpu.instances.push(this.state);
35 }
36 setDrawingBufferSize(width: number, height: number, pixelRatio: number) {
37 this.state.size = { width, height, pixelRatio };
38 this.state.allocations++;
39 this.state.blank = true;
40 }
41 setSize(width: number, height: number) {
42 this.setDrawingBufferSize(width, height, this.state.size.pixelRatio);
43 }
44 setPixelRatio(pixelRatio: number) {
45 this.setDrawingBufferSize(this.state.size.width, this.state.size.height, pixelRatio);
46 }
47 render(scene: Scene, camera: PerspectiveCamera) {
48 const phone = scene.children.find((child) => child.type === "Group");
49 this.state.frames.push({
50 scene,
51 phone,
52 rotation: phone?.quaternion.clone(),
53 yaw: phone?.rotation.y,
54 cameraZ: camera.position.z,
55 });
56 this.state.blank = false;
57 }
58 dispose() {
59 this.state.disposed = true;
60 }
61 forceContextLoss() {}
62 },
63 };
64});
65
66const models = vi.hoisted(() => ({
67 pending: [] as {
68 signal: AbortSignal;
69 resolve: (model: { asset: import("three").Group; dispose: () => void }) => void;
70 }[],
71}));
72vi.mock("./modelScene.ts", async () => {
73 const actual = await vi.importActual<typeof import("./modelScene.ts")>("./modelScene.ts");
74 return {
75 ...actual,
76 loadDeviceModel: (_source: unknown, signal: AbortSignal) =>
77 new Promise((resolve) => models.pending.push({ signal, resolve })),
78 };
79});
80
81import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PlaneGeometry, Quaternion } from "three";
82import { disposeDeviceModel } from "./modelScene.ts";
83import { createPhoneViewer } from "./phoneViewer.ts";
84import { IOS_TABLET_SHAPE } from "./shapeProfile.ts";
85
86afterEach(() => {
87 vi.restoreAllMocks();
88 vi.unstubAllGlobals();
89 gpu.instances.length = 0;
90 models.pending.length = 0;
91});
92
93function fixture() {
94 const pending = new Map<number, FrameRequestCallback>();
95 let id = 0;
96 let now = 0;
97 vi.spyOn(performance, "now").mockImplementation(() => now);
98 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
99 pending.set(++id, callback);
100 return id;
101 });
102 vi.stubGlobal("cancelAnimationFrame", (id: number) => pending.delete(id));
103 const canvas = Object.assign(new EventTarget(), { width: 0, height: 0 }) as HTMLCanvasElement;
104 const source = { width: 1206, height: 2622 } as HTMLCanvasElement;
105 const onUnavailable = vi.fn();
106 const onFramingAspect = vi.fn();
107 const viewer = createPhoneViewer({ canvas, source, onUnavailable, onFramingAspect });
108 const draw = (time = now) => {
109 now = time;
110 const callbacks = [...pending.values()];
111 pending.clear();
112 callbacks.forEach((callback) => callback(0));
113 };
114 viewer.resize(400, 700, 2);
115 draw();
116 return {
117 viewer,
118 draw,
119 pending,
120 source,
121 onUnavailable,
122 onFramingAspect,
123 state: gpu.instances[0]!,
124 };
125}
126
127it("retains the drawn phone until resize and redraw commit together, without replacing scene or pose", () => {
128 const { viewer, draw, pending, source, state, onUnavailable } = fixture();
129 viewer.orbit(0.08, 0.04);
130 draw();
131 const previous = state.frames.at(-1)!;
132 const allocations = state.allocations;
133 viewer.resize(500, 700, 2);
134 viewer.resize(450, 700, 2);
135 viewer.frameUpdated();
136 // ResizeObserver runs after rAF. A cleared buffer here would reach the browser's next paint.
137 expect(state.blank).toBe(false);
138 expect(state.allocations).toBe(allocations);
139 expect(pending.size).toBe(1);
140 draw();
141 expect(state.size).toEqual({ width: 450, height: 700, pixelRatio: 2 });
142 expect(state.blank).toBe(false);
143 expect(state.allocations).toBe(allocations + 1);
144 expect(state.frames.at(-1)?.scene).toBe(previous.scene);
145 expect(state.frames.at(-1)?.phone).toBe(previous.phone);
146 expect(state.frames.at(-1)?.yaw).toBe(previous.yaw);
147 viewer.resize(400, 700, 2);
148 draw();
149 expect(state.frames.at(-1)?.cameraZ).toBeCloseTo(previous.cameraZ);
150 expect(source).toMatchObject({ width: 1206, height: 2622 });
151 expect(gpu.instances).toHaveLength(1);
152 expect(onUnavailable).not.toHaveBeenCalled();
153 viewer.dispose();
154});
155
156it("ignores redundant and invalid sizes and combines a DPR change into one allocation and draw", () => {
157 const { viewer, draw, pending, state } = fixture();
158 const allocations = state.allocations;
159 viewer.resize(400, 700, 2);
160 viewer.resize(400, 700, 5);
161 viewer.resize(0, 700, 2);
162 viewer.resize(NaN, 700, 2);
163 expect(pending.size).toBe(0);
164 viewer.resize(400, 700, 1);
165 expect(state.blank).toBe(false);
166 draw();
167 expect(state.size.pixelRatio).toBe(1);
168 expect(state.allocations).toBe(allocations + 1);
169 viewer.resize(450, 700, 1);
170 viewer.dispose();
171 draw();
172 expect(state.allocations).toBe(allocations + 1);
173 expect(state.disposed).toBe(true);
174});
175
176it("changes device shape without replacing the renderer, decoded source or pose", () => {
177 const { viewer, draw, source, state } = fixture();
178 viewer.orbit(0.1, 0.05);
179 draw();
180 const previous = state.frames.at(-1)!;
181 const allocations = state.allocations;
182 viewer.setScreen(null, IOS_TABLET_SHAPE);
183 draw();
184 expect(state.frames.at(-1)?.phone).not.toBe(previous.phone);
185 expect(state.frames.at(-1)?.yaw).toBe(previous.yaw);
186 expect(state.frames.at(-1)?.scene).toBe(previous.scene);
187 expect(state.allocations).toBe(allocations);
188 expect(source.width).toBe(1206);
189 expect(gpu.instances).toHaveLength(1);
190 const tablet = state.frames.at(-1)?.phone;
191 viewer.frameUpdated();
192 draw();
193 expect(state.frames.at(-1)?.phone).toBe(tablet);
194 viewer.dispose();
195});
196
197it("retains the loaded model and pose through rotation and framebuffer resolution changes, then releases it once", async () => {
198 const { viewer, draw, source, state } = fixture();
199 viewer.orbit(0.08, 0.04);
200 draw();
201 const yaw = state.frames.at(-1)!.yaw;
202 viewer.setModel({ id: "iphone-18-pro", url: "/pro.glb" });
203 const asset = new Group();
204 const body = new Mesh(new BoxGeometry(1.15, 2.3, 0.1), new MeshBasicMaterial());
205 body.position.z = -0.02;
206 const display = new Mesh(new PlaneGeometry(1, 2.2), new MeshBasicMaterial());
207 display.geometry.translate(0, 0, 0.043);
208 display.name = "device-screen";
209 asset.add(body, display);
210 const release = vi.spyOn(body.geometry, "dispose");
211 const dispose = vi.fn(() => disposeDeviceModel(asset));
212 models.pending[0]!.resolve({ asset, dispose });
213 await Promise.resolve();
214 draw();
215 const loaded = state.frames.at(-1)!.phone;
216 expect(loaded?.getObjectByName("device-screen")).toBe(display);
217 expect(state.frames.at(-1)!.yaw).toBe(yaw);
218 source.width = 2622;
219 source.height = 1206;
220 viewer.setScreen({ width: 2622, height: 1206, orientation: "landscape_left" });
221 viewer.frameUpdated();
222 viewer.resize(700, 400, 1);
223 draw();
224 expect(state.frames.at(-1)!.phone).toBe(loaded);
225 expect(state.frames.at(-1)!.yaw).toBe(yaw);
226 expect(display.material.map?.image).toBe(source);
227 expect(release).not.toHaveBeenCalled();
228 expect(gpu.instances).toHaveLength(1);
229 viewer.dispose();
230 viewer.dispose();
231 expect(dispose).toHaveBeenCalledOnce();
232 expect(release).toHaveBeenCalledOnce();
233});
234
235it("attaches and detaches an accessory without replacing the device, stream texture, renderer or pose", async () => {
236 const { viewer, draw, source, state, onFramingAspect } = fixture();
237 viewer.setModel({ id: "ipad-pro-13-m5", url: "/ipad.glb" });
238 const asset = new Group();
239 const body = new Mesh(new BoxGeometry(1.8, 2.3, 0.1), new MeshBasicMaterial());
240 const display = new Mesh(new PlaneGeometry(1.7, 2.2), new MeshBasicMaterial());
241 display.geometry.translate(0, 0, 0.043);
242 display.name = "device-screen";
243 asset.add(body, display);
244 const releaseBody = vi.fn(() => disposeDeviceModel(asset));
245 models.pending[0]!.resolve({ asset, dispose: releaseBody });
246 await Promise.resolve();
247 viewer.orbit(0.1, 0.05);
248 draw();
249 const before = state.frames.at(-1)!;
250 const bareAspect = onFramingAspect.mock.lastCall![0];
251 const keyboard = new Group();
252 const deck = new Mesh(new BoxGeometry(2, 1, 1), new MeshBasicMaterial());
253 deck.position.set(1, 0, 0.5);
254 keyboard.add(deck);
255 const releaseKeyboard = vi.fn(() => disposeDeviceModel(keyboard));
256 viewer.setAccessory({
257 id: "ipad-pro-13-m5-magic-keyboard",
258 modelId: "ipad-pro-13-m5",
259 url: "/keyboard.glb",
260 });
261 models.pending[1]!.resolve({ asset: keyboard, dispose: releaseKeyboard });
262 await Promise.resolve();
263 draw();
264 expect(state.frames.at(-1)!.phone).toBe(before.phone);
265 expect(keyboard.parent).toBe(asset.parent);
266 expect(state.frames.at(-1)!.yaw).toBe(before.yaw);
267 expect(display.material.map?.image).toBe(source);
268 expect(state.frames.at(-1)!.cameraZ).toBeGreaterThan(before.cameraZ);
269 expect(onFramingAspect.mock.lastCall![0]).toBeGreaterThan(bareAspect);
270 const calls = onFramingAspect.mock.calls.length;
271 viewer.orbit(0.1, 0.05);
272 viewer.frameUpdated();
273 draw();
274 expect(onFramingAspect).toHaveBeenCalledTimes(calls);
275 viewer.setAccessory(null);
276 draw();
277 expect(keyboard.parent).toBeNull();
278 expect(onFramingAspect.mock.lastCall![0]).toBeCloseTo(bareAspect);
279 expect(releaseKeyboard).toHaveBeenCalledOnce();
280 expect(releaseBody).not.toHaveBeenCalled();
281 expect(state.frames.at(-1)!.phone).toBe(before.phone);
282 expect(state.frames.at(-1)!.cameraZ).toBeCloseTo(before.cameraZ);
283 expect(gpu.instances).toHaveLength(1);
284 viewer.dispose();
285 expect(releaseBody).toHaveBeenCalledOnce();
286 expect(releaseKeyboard).toHaveBeenCalledOnce();
287});
288
289it("rejects incompatible accessories and releases an accessory that finishes after detaching", async () => {
290 const { viewer } = fixture();
291 const keyboard = {
292 id: "ipad-pro-13-m5-magic-keyboard",
293 modelId: "ipad-pro-13-m5",
294 url: "/keyboard.glb",
295 } as const;
296 viewer.setAccessory(keyboard);
297 expect(models.pending).toHaveLength(0);
298 viewer.setModel({ id: "ipad-pro-13-m5", url: "/ipad.glb" });
299 viewer.setAccessory(keyboard);
300 viewer.setAccessory(null);
301 expect(models.pending[1]!.signal.aborted).toBe(true);
302 const dispose = vi.fn();
303 const asset = new Group();
304 models.pending[1]!.resolve({ asset, dispose });
305 await Promise.resolve();
306 expect(asset.parent).toBeNull();
307 expect(dispose).toHaveBeenCalledOnce();
308 viewer.dispose();
309});
310
311it("springs an ordinary device back toward its screen, freezes captured input, and stops rendering at rest", () => {
312 const { viewer, draw, pending, state } = fixture();
313 const initialYaw = state.frames.at(-1)!.yaw!;
314 viewer.setInteractionActive(true, "orbit");
315 viewer.orbit(0.8, 0.4);
316 draw(80);
317 const dragged = state.frames.at(-1)!;
318 expect(Math.abs(dragged.yaw! - initialYaw)).toBeGreaterThan(0.05);
319 viewer.setInteractionActive(false, "orbit");
320 draw(160);
321 viewer.setInteractionActive(true, "touch");
322 draw(180);
323 const captured = state.frames.at(-1)!;
324 viewer.orbit(1, 1);
325 viewer.frameUpdated();
326 draw(1000);
327 expect(state.frames.at(-1)!.yaw).toBe(captured.yaw);
328 expect(state.frames.at(-1)!.cameraZ).toBe(captured.cameraZ);
329 expect(pending.size).toBe(0);
330 viewer.setInteractionActive(false, "touch");
331 for (let time = 1016; time <= 4000 && pending.size; time += 16) draw(time);
332 expect(pending.size).toBe(0);
333 expect(Math.abs(state.frames.at(-1)!.yaw!)).toBeLessThanOrEqual(Math.PI / 3 + 1e-6);
334 expect(state.frames.at(-1)!.phone).toBe(dragged.phone);
335 viewer.dispose();
336});
337
338it("resets the device to a square front view after orbiting", () => {
339 const { viewer, draw, pending, state } = fixture();
340 const front = state.frames.at(-1)!.rotation!;
341 expect(front.angleTo(new Quaternion())).toBeLessThan(1e-6);
342 viewer.orbit(0.8, 0.4);
343 draw(80);
344 expect(state.frames.at(-1)!.rotation!.angleTo(front)).toBeGreaterThan(0.05);
345 viewer.resetPose();
346 for (let time = 96; time <= 3000 && pending.size; time += 16) draw(time);
347 expect(pending.size).toBe(0);
348 expect(state.frames.at(-1)!.rotation!.angleTo(new Quaternion())).toBeLessThan(1e-6);
349 viewer.dispose();
350});
packages/client-runtime/src/device/phoneViewer.ts created+332
...@@ -0,0 +1,332 @@
1import {
2 AmbientLight,
3 Box3,
4 CanvasTexture,
5 DirectionalLight,
6 LinearFilter,
7 Matrix4,
8 PerspectiveCamera,
9 Quaternion,
10 Scene,
11 SRGBColorSpace,
12 Vector3,
13 WebGLRenderer,
14} from "three";
15import {
16 createDeviceModelSlot,
17 type DeviceModelSource,
18 type DeviceAccessorySource,
19} from "./model.ts";
20import { createImportedPhoneScene, loadDeviceModel } from "./modelScene.ts";
21import { createPhoneScene, phoneDisplayLayout } from "./phoneScene.ts";
22import { createRenderScheduler } from "./renderScheduler.ts";
23import { createDeviceMotion } from "./deviceMotion.ts";
24import { createDeviceFraming } from "./deviceFraming.ts";
25import { nearestDeviceView } from "./deviceViewSnap.ts";
26import type { DeviceScreenSize } from "./stream.ts";
27import { IOS_PHONE_SHAPE, type DeviceShapeProfile } from "./shapeProfile.ts";
28
29export interface PhoneViewer {
30 readonly setModel: (source: DeviceModelSource | null) => void;
31 readonly setAccessory: (source: DeviceAccessorySource | null) => void;
32 readonly frameUpdated: () => void;
33 readonly setScreen: (screen: DeviceScreenSize | null, profile?: DeviceShapeProfile) => void;
34 readonly resize: (width: number, height: number, pixelRatio: number) => void;
35 readonly screenPoint: (
36 x: number,
37 y: number,
38 captured?: boolean,
39 ) => { x: number; y: number } | null;
40 readonly orbit: (deltaX: number, deltaY: number) => void;
41 readonly setInteractionActive: (active: boolean, mode: "touch" | "orbit") => void;
42 readonly resetPose: () => void;
43 readonly dispose: () => void;
44}
45
46/** Owns only presentation resources. The caller retains the decoded canvas and the stream connection. */
47export function createPhoneViewer(options: {
48 readonly canvas: HTMLCanvasElement;
49 readonly source: HTMLCanvasElement;
50 readonly onUnavailable: () => void;
51 readonly onModelError?: (cause: unknown) => void;
52 readonly onFramingAspect?: (aspect: number) => void;
53 readonly profile?: DeviceShapeProfile;
54 readonly model?: DeviceModelSource | null;
55 readonly accessory?: DeviceAccessorySource | null;
56}): PhoneViewer {
57 const renderer = new WebGLRenderer({
58 canvas: options.canvas,
59 alpha: true,
60 antialias: true,
61 powerPreference: "low-power",
62 });
63 renderer.outputColorSpace = SRGBColorSpace;
64 const makeTexture = () => {
65 const next = new CanvasTexture(options.source);
66 next.colorSpace = SRGBColorSpace;
67 next.minFilter = LinearFilter;
68 next.magFilter = LinearFilter;
69 next.generateMipmaps = false;
70 return next;
71 };
72 let texture = makeTexture();
73 let textureWidth = options.source.width;
74 let textureHeight = options.source.height;
75 const scene = new Scene();
76 const camera = new PerspectiveCamera(32, 1, 0.1, 30);
77 camera.position.z = 5.5;
78 const ambient = new AmbientLight(0xffffff, 2.4);
79 const key = new DirectionalLight(0xe4edff, 5);
80 key.position.set(-3, 4, 5);
81 const rim = new DirectionalLight(0xffffff, 4);
82 rim.position.set(3, 1, -3);
83 const fill = new DirectionalLight(0x9facd4, 2);
84 fill.position.set(-2, -2, -4);
85 scene.add(ambient, key, rim, fill);
86
87 let screen: DeviceScreenSize | null = null;
88 let layout = phoneDisplayLayout(screen, options.source.width, options.source.height);
89 let profile = options.profile ?? IOS_PHONE_SHAPE;
90 let imported: Awaited<ReturnType<typeof loadDeviceModel>> | null = null;
91 let modelSource = options.model ?? null;
92 let accessory: Awaited<ReturnType<typeof loadDeviceModel>> | null = null;
93 let accessoryBounds: Box3 | null = null;
94 let phone = createPhoneScene(texture, layout, profile);
95 scene.add(phone.root);
96 let disposed = false;
97 const rest = new Quaternion();
98 const motion = createDeviceMotion({
99 choose: (rotation) =>
100 nearestDeviceView(rotation, [{ rotation: new Quaternion(), yawLimit: Math.PI / 3 }])!
101 .rotation,
102 });
103 motion.setPose(rest, performance.now(), true);
104 const framing = createDeviceFraming();
105 const reducedMotion = () =>
106 globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
107 let viewport = { width: 0, height: 0, pixelRatio: 1 };
108 let drawingBuffer = { width: 0, height: 0, pixelRatio: 0 };
109
110 let framingAspect: number | null = null;
111 const fit = (immediate = false) => {
112 if (!viewport.width || !viewport.height) return;
113 camera.aspect = viewport.width / viewport.height;
114 const bounds = new Box3(
115 new Vector3(-phone.width / 2, -phone.height / 2, 0),
116 new Vector3(phone.width / 2, phone.height / 2, 0),
117 );
118 if (imported && accessoryBounds) bounds.union(accessoryBounds);
119 bounds.applyMatrix4(new Matrix4().makeRotationZ(layout.rotation));
120 const size = bounds.getSize(new Vector3());
121 const aspect = size.x / size.y;
122 if (aspect !== framingAspect) {
123 framingAspect = aspect;
124 options.onFramingAspect?.(aspect);
125 }
126 phone.root.updateMatrixWorld(true);
127 framing.setBounds(
128 new Box3().setFromObject(phone.root),
129 (camera.fov * Math.PI) / 360,
130 camera.aspect,
131 performance.now(),
132 immediate,
133 );
134 applyCamera();
135 };
136 const applyCamera = () => {
137 camera.position.set(framing.center.x, framing.center.y, framing.distance());
138 camera.lookAt(framing.center.x, framing.center.y, 0);
139 camera.updateProjectionMatrix();
140 };
141 const applyPose = () => {
142 phone.root.quaternion.copy(motion.rotation);
143 phone.orientation.rotation.z = layout.rotation;
144 };
145 const scheduler = createRenderScheduler(() => {
146 if (disposed || !viewport.width || !viewport.height) return;
147 try {
148 if (
149 drawingBuffer.width !== viewport.width ||
150 drawingBuffer.height !== viewport.height ||
151 drawingBuffer.pixelRatio !== viewport.pixelRatio
152 ) {
153 // Canvas allocation clears the previous image. Commit it with the redraw,
154 // rather than exposing an empty buffer between ResizeObserver and the next frame.
155 renderer.setDrawingBufferSize(viewport.width, viewport.height, viewport.pixelRatio);
156 drawingBuffer = viewport;
157 }
158 const now = performance.now();
159 if (motion.advance(now, reducedMotion())) {
160 applyPose();
161 fit(reducedMotion());
162 }
163 framing.advance(now, reducedMotion());
164 applyCamera();
165 renderer.render(scene, camera);
166 if (motion.needsFrame() || framing.needsFrame()) scheduler.invalidate();
167 } catch {
168 options.onUnavailable();
169 }
170 });
171 const updateLayout = (nextProfile = profile) => {
172 const next = phoneDisplayLayout(screen, options.source.width, options.source.height);
173 const resized =
174 textureWidth !== options.source.width || textureHeight !== options.source.height;
175 if (
176 resized ||
177 nextProfile !== profile ||
178 next.aspect !== layout.aspect ||
179 next.rawLandscape !== layout.rawLandscape ||
180 next.rotation !== layout.rotation
181 ) {
182 // The model and renderer survive framebuffer rotation and native resolution changes.
183 if (resized) {
184 const previous = texture;
185 texture = makeTexture();
186 textureWidth = options.source.width;
187 textureHeight = options.source.height;
188 phone.setDisplay(texture, next);
189 previous.dispose();
190 }
191 if (!imported && (nextProfile !== profile || next.aspect !== layout.aspect)) {
192 scene.remove(phone.root);
193 phone.dispose();
194 phone = createPhoneScene(texture, next, nextProfile);
195 scene.add(phone.root);
196 } else {
197 phone.setDisplay(texture, next);
198 }
199 layout = next;
200 profile = nextProfile;
201 applyPose();
202 fit(true);
203 }
204 applyPose();
205 };
206 const modelSlot = createDeviceModelSlot({
207 load: loadDeviceModel,
208 onError: options.onModelError,
209 install(model) {
210 // Validate and prepare the next scene before releasing the visible one.
211 const next = disposed
212 ? null
213 : model
214 ? createImportedPhoneScene(model.asset, texture, layout)
215 : createPhoneScene(texture, layout, profile);
216 scene.remove(phone.root);
217 phone.dispose();
218 imported = model;
219 if (!next) return;
220 phone = next;
221 scene.add(phone.root);
222 if (accessory) {
223 if (imported) phone.orientation.add(accessory.asset);
224 else accessory.asset.removeFromParent();
225 }
226 applyPose();
227 fit(true);
228 scheduler.invalidate();
229 },
230 });
231 const accessorySlot = createDeviceModelSlot({
232 load: loadDeviceModel,
233 onError: options.onModelError,
234 install(model) {
235 const bounds = model ? new Box3().setFromObject(model.asset) : null;
236 if (
237 bounds &&
238 (bounds.isEmpty() ||
239 ![...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite))
240 )
241 throw new Error("Device accessory has invalid bounds");
242 accessory?.asset.removeFromParent();
243 accessory = model;
244 accessoryBounds = bounds;
245 if (disposed) return;
246 if (imported && accessory) phone.orientation.add(accessory.asset);
247 fit(true);
248 scheduler.invalidate();
249 },
250 });
251 const setAccessory = (source: DeviceAccessorySource | null) => {
252 accessorySlot.set(source?.modelId === modelSource?.id ? source : null);
253 };
254 const contextLost = (event: Event) => {
255 event.preventDefault();
256 options.onUnavailable();
257 };
258 options.canvas.addEventListener("webglcontextlost", contextLost);
259 applyPose();
260 modelSlot.set(options.model ?? null);
261 setAccessory(options.accessory ?? null);
262 return {
263 setModel(source) {
264 if (source?.id !== modelSource?.id || source?.url !== modelSource?.url)
265 accessorySlot.set(null);
266 modelSource = source;
267 modelSlot.set(source);
268 },
269 setAccessory,
270 frameUpdated() {
271 if (disposed) return;
272 updateLayout();
273 texture.needsUpdate = true;
274 scheduler.invalidate();
275 },
276 setScreen(next, nextProfile = profile) {
277 if (disposed) return;
278 screen = next;
279 updateLayout(nextProfile);
280 scheduler.invalidate();
281 },
282 resize(width, height, pixelRatio) {
283 if (disposed) return;
284 if (![width, height, pixelRatio].every(Number.isFinite) || width <= 0 || height <= 0) return;
285 const ratio = Math.min(2, Math.max(1, pixelRatio));
286 if (viewport.width === width && viewport.height === height && viewport.pixelRatio === ratio)
287 return;
288 viewport = { width, height, pixelRatio: ratio };
289 fit(true);
290 scheduler.invalidate();
291 },
292 screenPoint(x, y, captured = false) {
293 if (disposed) return null;
294 applyPose();
295 return phone.screenPoint(x, y, camera, captured);
296 },
297 orbit(deltaX, deltaY) {
298 if (disposed) return;
299 motion.orbit(deltaX * viewport.width, deltaY * viewport.height, performance.now());
300 scheduler.invalidate();
301 },
302 setInteractionActive(active, mode) {
303 if (disposed) return;
304 const now = performance.now();
305 if (mode === "orbit") motion.dragActive(active, now);
306 else {
307 motion.hold(active, now);
308 framing.hold(active, now);
309 }
310 scheduler.invalidate();
311 },
312 resetPose() {
313 if (disposed) return;
314 motion.reset(rest, performance.now());
315 scheduler.invalidate();
316 },
317 dispose() {
318 if (disposed) return;
319 disposed = true;
320 scheduler.dispose();
321 const hadImported = imported !== null;
322 accessorySlot.dispose();
323 modelSlot.dispose();
324 options.canvas.removeEventListener("webglcontextlost", contextLost);
325 scene.remove(phone.root);
326 if (!hadImported) phone.dispose();
327 texture.dispose();
328 renderer.dispose();
329 renderer.forceContextLoss();
330 },
331 };
332}
packages/client-runtime/src/device/renderScheduler.test.ts created+27
...@@ -0,0 +1,27 @@
1import { expect, it, vi } from "vite-plus/test";
2import { createRenderScheduler } from "./renderScheduler.ts";
3
4it("coalesces frame and pointer invalidations, then stops scheduling when idle or disposed", () => {
5 const callbacks: FrameRequestCallback[] = [];
6 const request = vi.fn((callback: FrameRequestCallback) => {
7 callbacks.push(callback);
8 return callbacks.length;
9 });
10 const cancel = vi.fn();
11 const render = vi.fn();
12 const scheduler = createRenderScheduler(render, request, cancel);
13 scheduler.invalidate();
14 scheduler.invalidate();
15 expect(request).toHaveBeenCalledTimes(1);
16 callbacks[0]?.(0);
17 expect(render).toHaveBeenCalledTimes(1);
18 expect(request).toHaveBeenCalledTimes(1);
19 scheduler.invalidate();
20 scheduler.dispose();
21 scheduler.dispose();
22 scheduler.invalidate();
23 callbacks[1]?.(0);
24 expect(cancel).toHaveBeenCalledExactlyOnceWith(2);
25 expect(render).toHaveBeenCalledTimes(1);
26 expect(request).toHaveBeenCalledTimes(2);
27});
packages/client-runtime/src/device/renderScheduler.ts created+23
...@@ -0,0 +1,23 @@
1/** Coalesces invalidations into one render. No work is scheduled while the view is idle. */
2export function createRenderScheduler(
3 render: () => void,
4 request: (callback: FrameRequestCallback) => number = requestAnimationFrame,
5 cancel: (id: number) => void = cancelAnimationFrame,
6) {
7 let pending: number | null = null;
8 let disposed = false;
9 return {
10 invalidate() {
11 if (disposed || pending !== null) return;
12 pending = request(() => {
13 pending = null;
14 if (!disposed) render();
15 });
16 },
17 dispose() {
18 disposed = true;
19 if (pending !== null) cancel(pending);
20 pending = null;
21 },
22 };
23}
packages/client-runtime/src/device/screenshot.test.ts created+50
...@@ -0,0 +1,50 @@
1import { afterEach, expect, it, vi } from "vite-plus/test";
2import { captureDeviceScreenshot, DeviceScreenshotError } from "./screenshot.ts";
3
4afterEach(() => vi.unstubAllGlobals());
5const target = {
6 platform: "android" as const,
7 deviceId: "phone with spaces",
8 access: {
9 httpBase: "https://remote.example/api/device-hub/hosts/remote",
10 wsBase: "wss://remote.example",
11 query: { ticket: "capture-ticket" },
12 credentials: false,
13 },
14};
15
16it("captures native bytes from the selected remote host with its media credentials", async () => {
17 const fetch = vi.fn(
18 async () =>
19 new Response(new Uint8Array([137, 80, 78, 71]), { headers: { "Content-Type": "image/png" } }),
20 );
21 vi.stubGlobal("fetch", fetch);
22 const image = await captureDeviceScreenshot(target, new AbortController().signal);
23 expect(image.type).toBe("image/png");
24 expect(Array.from(new Uint8Array(await image.arrayBuffer()))).toEqual([137, 80, 78, 71]);
25 expect(fetch).toHaveBeenCalledWith(
26 "https://remote.example/api/device-hub/hosts/remote/vendor/serve-emu/api/screenshot?device=phone%20with%20spaces&ticket=capture-ticket",
27 expect.objectContaining({ method: "POST", credentials: "omit" }),
28 );
29});
30
31it("preserves authorization failures and cancels an in-flight capture when its owner detaches", async () => {
32 vi.stubGlobal(
33 "fetch",
34 vi.fn(async () => new Response("expired", { status: 401 })),
35 );
36 await expect(captureDeviceScreenshot(target, new AbortController().signal)).rejects.toMatchObject(
37 new DeviceScreenshotError(401),
38 );
39 vi.stubGlobal(
40 "fetch",
41 (_url: string, init: RequestInit) =>
42 new Promise((_resolve, reject) => {
43 init.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
44 }),
45 );
46 const controller = new AbortController();
47 const capture = captureDeviceScreenshot(target, controller.signal);
48 controller.abort(new Error("detached"));
49 await expect(capture).rejects.toThrow("detached");
50});
packages/client-runtime/src/device/screenshot.ts created+30
...@@ -0,0 +1,30 @@
1// @effect-diagnostics globalFetch:off - This browser transport runs without an Effect runtime, like the live stream.
2import { withDeviceHubQuery } from "./hubAccess.ts";
3import type { DeviceStreamTarget } from "./stream.ts";
4
5export class DeviceScreenshotError extends Error {
6 readonly status: number;
7
8 constructor(status: number) {
9 super(`Screenshot capture failed (${status}). Try again.`);
10 this.status = status;
11 }
12}
13
14/** Captures native pixels through the same host and credentials as the live stream, in either presentation. */
15export async function captureDeviceScreenshot(target: DeviceStreamTarget, signal: AbortSignal) {
16 const vendor = target.platform === "ios" ? "serve-sim" : "serve-emu";
17 const url = withDeviceHubQuery(
18 `${target.access.httpBase}/vendor/${vendor}/api/screenshot?device=${encodeURIComponent(target.deviceId)}`,
19 target.access,
20 );
21 const response = await fetch(url, {
22 method: "POST",
23 credentials: target.access.credentials ? "include" : "omit",
24 signal: AbortSignal.any([signal, AbortSignal.timeout(20_000)]),
25 });
26 if (!response.ok) throw new DeviceScreenshotError(response.status);
27 const image = await response.blob();
28 if (!image.size) throw new Error("The device returned an empty screenshot. Try again.");
29 return image;
30}
packages/client-runtime/src/device/shapeProfile.test.ts created+27
...@@ -0,0 +1,27 @@
1import { expect, it } from "vite-plus/test";
2import { resolveDeviceShape } from "./shapeProfile.ts";
3
4it("chooses tablet and phone families without mistaking display rotation for device shape", () => {
5 expect(
6 resolveDeviceShape({ platform: "ios", name: "iPad Pro 11-inch (M5)", portraitAspect: 0.75 }).id,
7 ).toBe("ios-tablet");
8 expect(
9 resolveDeviceShape({ platform: "ios", name: "iPhone 18 Pro", portraitAspect: 0.46 }).id,
10 ).toBe("ios-phone");
11 expect(
12 resolveDeviceShape({ platform: "android", name: "Pixel 9", portraitAspect: 0.45 }).id,
13 ).toBe("android-phone");
14 expect(
15 resolveDeviceShape({ platform: "android", name: "Pixel Tablet", portraitAspect: 0.625 }).id,
16 ).toBe("android-tablet");
17});
18
19it("uses the screen shape for renamed devices and a phone fallback before metadata arrives", () => {
20 expect(resolveDeviceShape({ platform: "ios", name: "Julius", portraitAspect: 0.75 }).id).toBe(
21 "ios-tablet",
22 );
23 expect(resolveDeviceShape({ platform: "android", portraitAspect: 0.45 }).id).toBe(
24 "android-phone",
25 );
26 expect(resolveDeviceShape({ platform: "ios", portraitAspect: Number.NaN }).id).toBe("ios-phone");
27});
packages/client-runtime/src/device/shapeProfile.ts created+124
...@@ -0,0 +1,124 @@
1import type { DevicePlatform } from "@t3tools/contracts";
2
3export interface DeviceShapeProfile {
4 readonly id: "ios-phone" | "ios-tablet" | "android-phone" | "android-tablet";
5 readonly bezel: number;
6 readonly bodyRadius: number;
7 readonly screenRadius: number;
8 readonly depth: number;
9 readonly backColor: number;
10 readonly buttons: ReadonlyArray<{
11 readonly edge: "left" | "right" | "top";
12 readonly offset: number;
13 readonly length: number;
14 }>;
15 readonly camera: {
16 readonly width: number;
17 readonly height: number;
18 readonly insetX: number;
19 readonly insetY: number;
20 readonly lensRadius: number;
21 readonly lenses: ReadonlyArray<readonly [number, number]>;
22 readonly flash: readonly [number, number] | null;
23 };
24}
25
26/** Original family silhouettes, rather than claims to reproduce individual hardware models. */
27export const IOS_PHONE_SHAPE: DeviceShapeProfile = {
28 id: "ios-phone",
29 bezel: 0.055,
30 bodyRadius: 0.15,
31 screenRadius: 0.105,
32 depth: 0.085,
33 backColor: 0x424b5d,
34 buttons: [
35 { edge: "right", offset: 0.35, length: 0.3 },
36 { edge: "left", offset: 0.48, length: 0.18 },
37 { edge: "left", offset: 0.22, length: 0.18 },
38 ],
39 camera: {
40 width: 0.39,
41 height: 0.44,
42 insetX: 0.25,
43 insetY: 0.29,
44 lensRadius: 0.068,
45 lenses: [
46 [-0.08, 0.095],
47 [0.08, -0.095],
48 ],
49 flash: [0.085, 0.11],
50 },
51};
52
53export const IOS_TABLET_SHAPE: DeviceShapeProfile = {
54 id: "ios-tablet",
55 bezel: 0.065,
56 bodyRadius: 0.105,
57 screenRadius: 0.045,
58 depth: 0.055,
59 backColor: 0x9ca5af,
60 buttons: [
61 { edge: "top", offset: 0.5, length: 0.15 },
62 { edge: "right", offset: 0.78, length: 0.13 },
63 { edge: "right", offset: 0.58, length: 0.13 },
64 ],
65 camera: {
66 width: 0.19,
67 height: 0.19,
68 insetX: 0.15,
69 insetY: 0.15,
70 lensRadius: 0.045,
71 lenses: [[0, 0]],
72 flash: null,
73 },
74};
75
76export const ANDROID_PHONE_SHAPE: DeviceShapeProfile = {
77 id: "android-phone",
78 bezel: 0.035,
79 bodyRadius: 0.115,
80 screenRadius: 0.08,
81 depth: 0.085,
82 backColor: 0x344449,
83 buttons: [
84 { edge: "right", offset: 0.2, length: 0.24 },
85 { edge: "right", offset: 0.65, length: 0.32 },
86 ],
87 camera: {
88 width: 0.24,
89 height: 0.47,
90 insetX: 0.18,
91 insetY: 0.29,
92 lensRadius: 0.056,
93 lenses: [
94 [0, 0.11],
95 [0, -0.11],
96 ],
97 flash: [0.1, 0],
98 },
99};
100
101const ANDROID_TABLET_SHAPE: DeviceShapeProfile = {
102 ...IOS_TABLET_SHAPE,
103 id: "android-tablet",
104 backColor: 0x697b80,
105};
106
107/** Names identify a family when available; wide unknown screens get a generic tablet shell. */
108export function resolveDeviceShape(options: {
109 platform: DevicePlatform;
110 name?: string;
111 portraitAspect: number;
112}): DeviceShapeProfile {
113 const name = options.name ?? "";
114 const namedTablet = /\b(ipad|tablet)\b/i.test(name);
115 const namedPhone = /\b(iphone|phone)\b/i.test(name);
116 const tablet = namedTablet || (!namedPhone && options.portraitAspect >= 0.6);
117 return options.platform === "ios"
118 ? tablet
119 ? IOS_TABLET_SHAPE
120 : IOS_PHONE_SHAPE
121 : tablet
122 ? ANDROID_TABLET_SHAPE
123 : ANDROID_PHONE_SHAPE;
124}
packages/client-runtime/src/device/stream.test.ts+3
...@@ -111,6 +111,7 @@ describe("native device stream transport", () => {...@@ -111,6 +111,7 @@ describe("native device stream transport", () => {
111 expect(imageUrl.searchParams.get("hostId")).toBe("ssh-host");111 expect(imageUrl.searchParams.get("hostId")).toBe("ssh-host");
112 expect(fetch).toHaveBeenCalledTimes(1);112 expect(fetch).toHaveBeenCalledTimes(1);
113 socket.onopen?.();113 socket.onopen?.();
114 client.setOrientation("landscape_right");
114 client.pressButton("home");115 client.pressButton("home");
115 client.sendTouch("begin", 0.25, 0.75);116 client.sendTouch("begin", 0.25, 0.75);
116 const messages = socket.send.mock.calls.slice(1).map(([bytes]) => {117 const messages = socket.send.mock.calls.slice(1).map(([bytes]) => {
...@@ -121,6 +122,7 @@ describe("native device stream transport", () => {...@@ -121,6 +122,7 @@ describe("native device stream transport", () => {
121 };122 };
122 });123 });
123 expect(messages).toEqual([124 expect(messages).toEqual([
125 { tag: 0x07, body: { orientation: "landscape_right" } },
124 { tag: 0x04, body: { button: "home" } },126 { tag: 0x04, body: { button: "home" } },
125 { tag: 0x03, body: { type: "begin", x: 0.25, y: 0.75 } },127 { tag: 0x03, body: { type: "begin", x: 0.25, y: 0.75 } },
126 ]);128 ]);
...@@ -140,6 +142,7 @@ describe("native device stream transport", () => {...@@ -140,6 +142,7 @@ describe("native device stream transport", () => {
140 client.sendTouch("begin", 0.2, 0.8);142 client.sendTouch("begin", 0.2, 0.8);
141 client.sendTouch("move", 0.2, 0.4);143 client.sendTouch("move", 0.2, 0.4);
142 client.sendTouch("end", 0.2, 0.1);144 client.sendTouch("end", 0.2, 0.1);
145 client.setOrientation("landscape_right");
143 client.pressButton("home");146 client.pressButton("home");
144 client.pressButton("appSwitcher");147 client.pressButton("appSwitcher");
145 expect(148 expect(
packages/client-runtime/src/device/stream.ts+14-11
...@@ -19,6 +19,7 @@...@@ -19,6 +19,7 @@
19 * The decoder only runs while frames arrive and the viewer is attached; a19 * The decoder only runs while frames arrive and the viewer is attached; a
20 * hidden panel calls `stop()` so an idle device costs nothing on the GPU.20 * hidden panel calls `stop()` so an idle device costs nothing on the GPU.
21 */21 */
22import { createCanvasFrameSink, type DeviceFrameSink } from "./frame.ts";
22import { type DeviceHubAccess, withDeviceHubQuery } from "./hubAccess.ts";23import { type DeviceHubAccess, withDeviceHubQuery } from "./hubAccess.ts";
23import type { DevicePlatform } from "@t3tools/contracts";24import type { DevicePlatform } from "@t3tools/contracts";
2425
...@@ -217,6 +218,7 @@ export interface DeviceStreamClient {...@@ -217,6 +218,7 @@ export interface DeviceStreamClient {
217 readonly sendKey: (event: KeyboardEvent, phase: "down" | "up") => void;218 readonly sendKey: (event: KeyboardEvent, phase: "down" | "up") => void;
218 readonly pressButton: (button: DeviceHardwareButton) => void;219 readonly pressButton: (button: DeviceHardwareButton) => void;
219 readonly rotate: () => void;220 readonly rotate: () => void;
221 readonly setOrientation: (orientation: DeviceScreenSize["orientation"]) => void;
220}222}
221223
222const HID_USAGE_BY_CODE: Readonly<Record<string, number>> = {224const HID_USAGE_BY_CODE: Readonly<Record<string, number>> = {
...@@ -282,10 +284,11 @@ const IOS_ORIENTATIONS: ReadonlyArray<DeviceScreenSize["orientation"]> = [...@@ -282,10 +284,11 @@ const IOS_ORIENTATIONS: ReadonlyArray<DeviceScreenSize["orientation"]> = [
282284
283export function createDeviceStreamClient(285export function createDeviceStreamClient(
284 target: DeviceStreamTarget,286 target: DeviceStreamTarget,
285 canvas: HTMLCanvasElement,287 output: HTMLCanvasElement | DeviceFrameSink,
286 events: DeviceStreamEvents,288 events: DeviceStreamEvents,
287): DeviceStreamClient {289): DeviceStreamClient {
288 const { access, platform, deviceId } = target;290 const { access, platform, deviceId } = target;
291 const sink = "present" in output ? output : createCanvasFrameSink(output);
289 const vendor = platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";292 const vendor = platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
290 const device = encodeURIComponent(deviceId);293 const device = encodeURIComponent(deviceId);
291 const httpUrl = (path: string) =>294 const httpUrl = (path: string) =>
...@@ -402,17 +405,14 @@ export function createDeviceStreamClient(...@@ -402,17 +405,14 @@ export function createDeviceStreamClient(
402405
403 const paint = (source: CanvasImageSource, width: number, height: number) => {406 const paint = (source: CanvasImageSource, width: number, height: number) => {
404 if (stopped) return;407 if (stopped) return;
405 if (canvas.width !== width || canvas.height !== height) {408 if (platform === "android" && (screen?.width !== width || screen.height !== height)) {
406 canvas.width = width;409 screen = { width, height, orientation: width > height ? "landscape_left" : "portrait" };
407 canvas.height = height;410 events.onScreen(screen);
408 if (platform === "android") {411 }
409 screen = { width, height, orientation: width > height ? "landscape_left" : "portrait" };412 if (!sink.present(source, width, height)) {
410 events.onScreen(screen);413 fail("Could not display the device stream. Reconnect to try again.");
411 }414 return;
412 }415 }
413 const context = canvas.getContext("2d");
414 if (!context) return fail("Could not display the device stream. Reconnect to try again.");
415 context.drawImage(source, 0, 0, width, height);
416 frameReceived();416 frameReceived();
417 };417 };
418418
...@@ -861,5 +861,8 @@ export function createDeviceStreamClient(...@@ -861,5 +861,8 @@ export function createDeviceStreamClient(
861 IOS_ORIENTATIONS[(IOS_ORIENTATIONS.indexOf(current) + 1) % IOS_ORIENTATIONS.length]!;861 IOS_ORIENTATIONS[(IOS_ORIENTATIONS.indexOf(current) + 1) % IOS_ORIENTATIONS.length]!;
862 send(taggedJson(IOS_MSG_ORIENTATION, { orientation: next }));862 send(taggedJson(IOS_MSG_ORIENTATION, { orientation: next }));
863 },863 },
864 setOrientation: (orientation) => {
865 if (platform === "ios") send(taggedJson(IOS_MSG_ORIENTATION, { orientation }));
866 },
864 };867 };
865}868}
packages/client-runtime/src/device/streamFrames.test.ts created+97
...@@ -0,0 +1,97 @@
1import { afterEach, expect, it, vi } from "vite-plus/test";
2import { createDeviceStreamClient } from "./stream.ts";
3
4afterEach(() => vi.unstubAllGlobals());
5
6it("delivers borrowed decoded frames, reports presentation failure, and discards late output", async () => {
7 let decoderOutput: VideoFrameOutputCallback | undefined;
8 let resolveOutput!: () => void;
9 const outputReady = new Promise<void>((resolve) => {
10 resolveOutput = resolve;
11 });
12 class Decoder {
13 static isConfigSupported = async () => ({ supported: true });
14 state = "unconfigured";
15 constructor(options: VideoDecoderInit) {
16 decoderOutput = options.output;
17 resolveOutput();
18 }
19 configure() {
20 this.state = "configured";
21 }
22 close() {
23 this.state = "closed";
24 }
25 }
26 vi.stubGlobal("VideoDecoder", Decoder);
27 vi.stubGlobal("EncodedVideoChunk", vi.fn());
28 vi.stubGlobal(
29 "WebSocket",
30 class {
31 close() {}
32 },
33 );
34 const envelope = new Uint8Array([0, 0, 0, 5, 1, 1, 0x64, 0, 0x1f]);
35 vi.stubGlobal(
36 "fetch",
37 vi.fn(
38 async (url: string) =>
39 new Response(
40 url.includes("avcc")
41 ? new ReadableStream({
42 start(controller) {
43 controller.enqueue(envelope);
44 },
45 })
46 : "seed",
47 ),
48 ),
49 );
50 const present = vi.fn(() => true);
51 const events = {
52 onStatus: vi.fn(),
53 onScreen: vi.fn(),
54 onUnauthorized: vi.fn(),
55 onMjpegFallback: vi.fn(),
56 onInputConnected: vi.fn(),
57 };
58 const client = createDeviceStreamClient(
59 {
60 platform: "ios",
61 deviceId: "phone",
62 access: {
63 httpBase: "https://test",
64 wsBase: "wss://test",
65 credentials: false,
66 query: {},
67 },
68 },
69 { present },
70 events,
71 );
72 client.start();
73 await outputReady;
74 const frame = {
75 displayWidth: 1170,
76 displayHeight: 2532,
77 close: vi.fn(),
78 } as unknown as VideoFrame;
79 decoderOutput?.(frame);
80 expect(present).toHaveBeenCalledExactlyOnceWith(frame, 1170, 2532);
81 expect(frame.close).toHaveBeenCalledOnce();
82 present.mockImplementationOnce(() => {
83 throw new Error("renderer failed");
84 });
85 expect(() => decoderOutput?.(frame)).toThrow("renderer failed");
86 expect(frame.close).toHaveBeenCalledTimes(2);
87 present.mockReturnValueOnce(false);
88 decoderOutput?.(frame);
89 expect(events.onStatus).toHaveBeenLastCalledWith(
90 "error",
91 "Could not display the device stream. Reconnect to try again.",
92 );
93 expect(frame.close).toHaveBeenCalledTimes(3);
94 decoderOutput?.(frame);
95 expect(present).toHaveBeenCalledTimes(3);
96 expect(frame.close).toHaveBeenCalledTimes(4);
97});
packages/contracts/src/ipc.ts+1
...@@ -1125,6 +1125,7 @@ export interface DesktopBridge {...@@ -1125,6 +1125,7 @@ export interface DesktopBridge {
1125 getClientPlatform?: () => string;1125 getClientPlatform?: () => string;
1126 setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>;1126 setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>;
1127 onNotificationBadgeClear?: (listener: () => void) => () => void;1127 onNotificationBadgeClear?: (listener: () => void) => () => void;
1128 onTrackpadScrollEnd?: (listener: () => void) => () => void;
1128 /**1129 /**
1129 * The OS locale as a BCP-47 tag, which the renderer cannot read for itself:1130 * The OS locale as a BCP-47 tag, which the renderer cannot read for itself:
1130 * the packaged app ships only the `en-US` Chromium locale pak, so1131 * the packaged app ships only the `en-US` Chromium locale pak, so
pnpm-lock.yaml+54
...@@ -874,6 +874,9 @@ importers:...@@ -874,6 +874,9 @@ importers:
874 remark-parse:874 remark-parse:
875 specifier: ^11.0.0875 specifier: ^11.0.0
876 version: 11.0.0876 version: 11.0.0
877 three:
878 specifier: 0.180.0
879 version: 0.180.0
877 unified:880 unified:
878 specifier: ^11.0.5881 specifier: ^11.0.5
879 version: 11.0.5882 version: 11.0.5
...@@ -881,6 +884,9 @@ importers:...@@ -881,6 +884,9 @@ importers:
881 '@effect/vitest':884 '@effect/vitest':
882 specifier: 4.0.0-rc.115885 specifier: 4.0.0-rc.115
883 version: 4.0.0-rc.115(patch_hash=172bd95ef73a13d322888984415d5d18d9d2aa8e13fa529eac235efac837e902)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))886 version: 4.0.0-rc.115(patch_hash=172bd95ef73a13d322888984415d5d18d9d2aa8e13fa529eac235efac837e902)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))
887 '@types/three':
888 specifier: 0.180.0
889 version: 0.180.0
884 micromark-util-types:890 micromark-util-types:
885 specifier: ^2.0.2891 specifier: ^2.0.2
886 version: 2.0.2892 version: 2.0.2
...@@ -2030,6 +2036,9 @@ packages:...@@ -2030,6 +2036,9 @@ packages:
2030 '@types/react':2036 '@types/react':
2031 optional: true2037 optional: true
20322038
2039 '@dimforge/rapier3d-compat@0.12.0':
2040 resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
2041
2033 '@distilled.cloud/aws@1.0.0-rc.12':2042 '@distilled.cloud/aws@1.0.0-rc.12':
2034 resolution: {integrity: sha512-L4TTEwMKB1ymXbYUqvYeoy5UWgmJtqQSzry2qWtAvD2ccwVGy0F7cFViEIYQk7boF4ZIP6SfhAls4+y1fVIi/g==}2043 resolution: {integrity: sha512-L4TTEwMKB1ymXbYUqvYeoy5UWgmJtqQSzry2qWtAvD2ccwVGy0F7cFViEIYQk7boF4ZIP6SfhAls4+y1fVIi/g==}
2035 peerDependencies:2044 peerDependencies:
...@@ -5222,6 +5231,9 @@ packages:...@@ -5222,6 +5231,9 @@ packages:
5222 '@tiptap/starter-kit@3.31.3':5231 '@tiptap/starter-kit@3.31.3':
5223 resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==}5232 resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==}
52245233
5234 '@tweenjs/tween.js@23.1.3':
5235 resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
5236
5225 '@tybys/wasm-util@0.10.3':5237 '@tybys/wasm-util@0.10.3':
5226 resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}5238 resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
52275239
...@@ -5359,9 +5371,15 @@ packages:...@@ -5359,9 +5371,15 @@ packages:
5359 '@types/serve-static@2.2.0':5371 '@types/serve-static@2.2.0':
5360 resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}5372 resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
53615373
5374 '@types/stats.js@0.17.4':
5375 resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
5376
5362 '@types/statuses@2.0.6':5377 '@types/statuses@2.0.6':
5363 resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}5378 resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
53645379
5380 '@types/three@0.180.0':
5381 resolution: {integrity: sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==}
5382
5365 '@types/unist@2.0.11':5383 '@types/unist@2.0.11':
5366 resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}5384 resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
53675385
...@@ -5374,6 +5392,9 @@ packages:...@@ -5374,6 +5392,9 @@ packages:
5374 '@types/webidl-conversions@7.0.3':5392 '@types/webidl-conversions@7.0.3':
5375 resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}5393 resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
53765394
5395 '@types/webxr@0.5.24':
5396 resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
5397
5377 '@types/whatwg-url@11.0.5':5398 '@types/whatwg-url@11.0.5':
5378 resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==}5399 resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==}
53795400
...@@ -5706,6 +5727,9 @@ packages:...@@ -5706,6 +5727,9 @@ packages:
5706 '@vscode/l10n@0.0.18':5727 '@vscode/l10n@0.0.18':
5707 resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==}5728 resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==}
57085729
5730 '@webgpu/types@0.1.74':
5731 resolution: {integrity: sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==}
5732
5709 '@xmldom/xmldom@0.8.15':5733 '@xmldom/xmldom@0.8.15':
5710 resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}5734 resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
5711 engines: {node: '>=10.0.0'}5735 engines: {node: '>=10.0.0'}
...@@ -8463,6 +8487,9 @@ packages:...@@ -8463,6 +8487,9 @@ packages:
8463 merge-stream@2.0.0:8487 merge-stream@2.0.0:
8464 resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}8488 resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
84658489
8490 meshoptimizer@0.22.0:
8491 resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==}
8492
8466 metro-babel-transformer@0.84.4:8493 metro-babel-transformer@0.84.4:
8467 resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==}8494 resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==}
8468 engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}8495 engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
...@@ -10224,6 +10251,9 @@ packages:...@@ -10224,6 +10251,9 @@ packages:
10224 engines: {node: '>=10'}10251 engines: {node: '>=10'}
10225 hasBin: true10252 hasBin: true
1022610253
10254 three@0.180.0:
10255 resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==}
10256
10227 throat@5.0.0:10257 throat@5.0.0:
10228 resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==}10258 resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==}
1022910259
...@@ -12223,6 +12253,8 @@ snapshots:...@@ -12223,6 +12253,8 @@ snapshots:
12223 optionalDependencies:12253 optionalDependencies:
12224 '@types/react': 19.2.1612254 '@types/react': 19.2.16
1222512255
12256 '@dimforge/rapier3d-compat@0.12.0': {}
12257
12226 '@distilled.cloud/aws@1.0.0-rc.12(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))':12258 '@distilled.cloud/aws@1.0.0-rc.12(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))':
12227 dependencies:12259 dependencies:
12228 '@aws-sdk/types': 3.974.512260 '@aws-sdk/types': 3.974.5
...@@ -15438,6 +15470,8 @@ snapshots:...@@ -15438,6 +15470,8 @@ snapshots:
15438 '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)15470 '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
15439 '@tiptap/pm': 3.31.315471 '@tiptap/pm': 3.31.3
1544015472
15473 '@tweenjs/tween.js@23.1.3': {}
15474
15441 '@tybys/wasm-util@0.10.3':15475 '@tybys/wasm-util@0.10.3':
15442 dependencies:15476 dependencies:
15443 tslib: 2.8.115477 tslib: 2.8.1
...@@ -15609,9 +15643,21 @@ snapshots:...@@ -15609,9 +15643,21 @@ snapshots:
15609 '@types/http-errors': 2.0.515643 '@types/http-errors': 2.0.5
15610 '@types/node': 24.12.415644 '@types/node': 24.12.4
1561115645
15646 '@types/stats.js@0.17.4': {}
15647
15612 '@types/statuses@2.0.6':15648 '@types/statuses@2.0.6':
15613 optional: true15649 optional: true
1561415650
15651 '@types/three@0.180.0':
15652 dependencies:
15653 '@dimforge/rapier3d-compat': 0.12.0
15654 '@tweenjs/tween.js': 23.1.3
15655 '@types/stats.js': 0.17.4
15656 '@types/webxr': 0.5.24
15657 '@webgpu/types': 0.1.74
15658 fflate: 0.8.3
15659 meshoptimizer: 0.22.0
15660
15615 '@types/unist@2.0.11': {}15661 '@types/unist@2.0.11': {}
1561615662
15617 '@types/unist@3.0.3': {}15663 '@types/unist@3.0.3': {}
...@@ -15621,6 +15667,8 @@ snapshots:...@@ -15621,6 +15667,8 @@ snapshots:
15621 '@types/webidl-conversions@7.0.3':15667 '@types/webidl-conversions@7.0.3':
15622 optional: true15668 optional: true
1562315669
15670 '@types/webxr@0.5.24': {}
15671
15624 '@types/whatwg-url@11.0.5':15672 '@types/whatwg-url@11.0.5':
15625 dependencies:15673 dependencies:
15626 '@types/webidl-conversions': 7.0.315674 '@types/webidl-conversions': 7.0.3
...@@ -15958,6 +16006,8 @@ snapshots:...@@ -15958,6 +16006,8 @@ snapshots:
1595816006
15959 '@vscode/l10n@0.0.18': {}16007 '@vscode/l10n@0.0.18': {}
1596016008
16009 '@webgpu/types@0.1.74': {}
16010
15961 '@xmldom/xmldom@0.8.15': {}16011 '@xmldom/xmldom@0.8.15': {}
1596216012
15963 '@xmldom/xmldom@0.9.12': {}16013 '@xmldom/xmldom@0.9.12': {}
...@@ -19070,6 +19120,8 @@ snapshots:...@@ -19070,6 +19120,8 @@ snapshots:
1907019120
19071 merge-stream@2.0.0: {}19121 merge-stream@2.0.0: {}
1907219122
19123 meshoptimizer@0.22.0: {}
19124
19073 metro-babel-transformer@0.84.4:19125 metro-babel-transformer@0.84.4:
19074 dependencies:19126 dependencies:
19075 '@babel/core': 7.29.719127 '@babel/core': 7.29.7
...@@ -21599,6 +21651,8 @@ snapshots:...@@ -21599,6 +21651,8 @@ snapshots:
21599 commander: 2.20.321651 commander: 2.20.3
21600 source-map-support: 0.5.2121652 source-map-support: 0.5.21
2160121653
21654 three@0.180.0: {}
21655
21602 throat@5.0.0: {}21656 throat@5.0.0: {}
2160321657
21604 through@2.3.8: {}21658 through@2.3.8: {}
scripts/convert-device-model.py created+108
...@@ -0,0 +1,108 @@
1# Run with Blender 4.5.9:
2# blender --background --python scripts/convert-device-model.py -- <model-id> <source.usdz> <output.glb>
3# Then optimize using @gltf-transform/cli 4.2.1 with --compress false --simplify false
4# --join false --palette false --flatten false --texture-size 1024 --texture-compress webp.
5import hashlib
6import json
7import sys
8from pathlib import Path
9
10import bpy
11import numpy as np
12from mathutils import Matrix, Vector
13
14model_id, source, output = sys.argv[sys.argv.index("--") + 1 :]
15manifest_path = (
16 Path(__file__).resolve().parents[1]
17 / "apps/web/src/components/device/models/sources.json"
18)
19manifest = json.loads(manifest_path.read_text())
20model = next(m for m in manifest["models"] if m["id"] == model_id)
21if hashlib.sha256(Path(source).read_bytes()).hexdigest() != model["sourceSha256"]:
22 raise ValueError("Source checksum differs from the recorded Apple asset")
23body_name, screen_name = model["bodyNode"], model["screenNode"]
24bpy.ops.wm.read_factory_settings(use_empty=True)
25bpy.ops.wm.usd_import(filepath=source, import_materials=True)
26body = bpy.data.objects[model.get("accessoryNode", body_name)]
27screen = bpy.data.objects[screen_name]
28objects = [o for o in body.children_recursive if o.type == "MESH"]
29if body.type == "MESH":
30 objects.append(body)
31uv = screen.data.uv_layers.active
32points = []
33coords = []
34for loop in screen.data.loops:
35 points.append(
36 tuple(screen.matrix_world @ screen.data.vertices[loop.vertex_index].co)
37 )
38 coords.append((*uv.data[loop.index].uv, 1))
39coef = np.linalg.lstsq(np.array(coords), np.array(points), rcond=None)[0]
40right = Vector(coef[0]).normalized()
41up = Vector(coef[1]).normalized()
42normal = right.cross(up).normalized()
43# The authored UVs establish the device's front and its physical portrait axes.
44# No body proportions are changed. The runtime uses a standard planar framebuffer UV.
45rot = Matrix((right, up, normal)).to_4x4()
46world = [rot @ Vector(p) for p in points]
47lo = Vector(tuple(min(p[i] for p in world) for i in range(3)))
48hi = Vector(tuple(max(p[i] for p in world) for i in range(3)))
49center = (lo + hi) / 2
50scale = 2.2 / (hi.y - lo.y)
51transform = (
52 Matrix.Translation(Vector((0, 0, 0.043)))
53 @ Matrix.Diagonal(Vector((scale, scale, scale, 1)))
54 @ Matrix.Translation(-center)
55 @ rot
56)
57for o in objects:
58 matrix = transform @ o.matrix_world
59 o.parent = None
60 o.matrix_world = Matrix.Identity(4)
61 o.data.transform(matrix)
62 # Blender is Z-up; glTF export converts to Y-up. Store canonical Y-up geometry as Blender Z-up.
63 o.data.transform(Matrix.Rotation(np.pi / 2, 4, "X"))
64 if o == screen:
65 o.name = "device-screen"
66 o.data.name = "device-screen"
67 for loop in o.data.loops:
68 p = o.data.vertices[loop.vertex_index].co
69 uv.data[loop.index].uv = (
70 (p.x / ((hi.x - lo.x) * scale)) + 0.5,
71 p.z / 2.2 + 0.5,
72 )
73 # This material is replaced by the viewer and must not retain the marketing screenshot.
74 mat = bpy.data.materials.new("device-screen")
75 mat.diffuse_color = (0, 0, 0, 1)
76 o.data.materials.clear()
77 o.data.materials.append(mat)
78for o in list(bpy.data.objects):
79 if o not in objects:
80 bpy.data.objects.remove(o, do_unlink=True)
81for image in bpy.data.images:
82 if image.size[0] > 1024 or image.size[1] > 1024:
83 k = 1024 / max(image.size)
84 image.scale(max(1, round(image.size[0] * k)), max(1, round(image.size[1] * k)))
85for o in objects:
86 o.select_set(True)
87bpy.ops.export_scene.gltf(
88 filepath=output,
89 export_format="GLB",
90 use_selection=True,
91 export_yup=True,
92 export_animations=False,
93 export_extras=False,
94 export_image_format="AUTO",
95 export_copyright="Apple Inc. Original device assets; see models/sources.json for provenance.",
96)
97print(
98 "MODEL_METRICS",
99 json.dumps(
100 {
101 "width": (hi.x - lo.x) * scale,
102 "height": 2.2,
103 "bodyMeshes": len(objects),
104 "screen": screen.name,
105 "source": source,
106 }
107 ),
108)