Skip to content
Source
Sign inSign up

DeviceWorkspace.tsx

129 lines4.9 KB

1import * as Schema from "effect/Schema";
2import type { DeviceHostSummary, DeviceSummary, EnvironmentId } from "@t3tools/contracts";
3import { useRef, useState } from "react";
4import {
5 captureDeviceScreenshot,
6 DeviceScreenshotError,
7} from "@t3tools/client-runtime/device/screenshot";
8import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";
9import { DeviceControlsRail } from "./DeviceControlsRail";
10import { DeviceStreamView } from "./DeviceStreamView";
11import { DeviceToolsPanel } from "./DeviceToolsPanel";
12import { useDeviceControls } from "./useDeviceControls";
13​
14const isDeviceScreenshotError = Schema.is(DeviceScreenshotError);
15​
16const DOWNLOAD_URL_LIFETIME_MS = 60_000;
17​
18export function DeviceWorkspace(props: {
19 environmentId: EnvironmentId;
20 device: DeviceSummary;
21 hostLabel: string;
22 hostKind: DeviceHostSummary["kind"] | undefined;
23 hostDiagnostics: string | undefined;
24 visible: boolean;
25 onFloat: () => void;
26 onClose: () => void;
27 onPowerOff: () => void;
28}) {
29 const [toolsOpen, setToolsOpen] = useState(false);
30 const [axOverlay, setAxOverlay] = useState(false);
31 const [screenshotPending, setScreenshotPending] = useState(false);
32 const [screenshotError, setScreenshotError] = useState<string | null>(null);
33 // A capture always finishes, even if the panel hides or closes meanwhile; the user asked for it.
34 const capturing = useRef(false);
35 const access = useDeviceHubAccess(props.environmentId, props.device.hostId);
36 // Settings and tools run commands on the device's own machine, which EAS and Simlock do not expose.
37 const runsHostCommands = props.hostKind === "local" || props.hostKind === "ssh";
38 const controls = useDeviceControls({
39 ...props,
40 access,
41 visible: props.visible && runsHostCommands,
42 });
43 const saveScreenshot = () => {
44 if (!access || capturing.current || !props.visible) return;
45 capturing.current = true;
46 setScreenshotPending(true);
47 setScreenshotError(null);
48 return captureDeviceScreenshot(
49 { access, platform: props.device.platform, deviceId: props.device.id },
50 // Never aborted: see `capturing` above.
51 new AbortController().signal,
52 )
53 .then((image) => {
54 const url = URL.createObjectURL(image);
55 const link = document.createElement("a");
56 link.href = url;
57 link.download = `${props.device.name.replace(/[^a-z0-9-]/gi, "-")}-${Date.now()}.png`;
58 document.body.append(link);
59 link.click();
60 link.remove();
61 // The browser reads the blob after the click returns.
62 window.setTimeout(() => URL.revokeObjectURL(url), DOWNLOAD_URL_LIFETIME_MS);
63 })
64 .catch((cause: unknown) => {
65 if (isDeviceScreenshotError(cause) && cause.status === 401)
66 refreshDeviceHubAccess(props.environmentId);
67 setScreenshotError(
68 cause instanceof Error ? cause.message : "Screenshot capture failed. Try again.",
69 );
70 })
71 .finally(() => {
72 capturing.current = false;
73 setScreenshotPending(false);
74 });
75 };
76 return (
77 <>
78 <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
79 {screenshotError || (controls.error && !toolsOpen) ? (
80 <p role="alert" className="px-3 py-2 text-xs text-destructive">
81 {screenshotError ?? controls.error}
82 </p>
83 ) : null}
84 <div className="min-h-0 flex-1">
85 <DeviceStreamView
86 environmentId={props.environmentId}
87 platform={props.device.platform}
88 deviceName={props.device.name}
89 deviceDescription={`${props.hostLabel} · ${props.device.version}`}
90 deviceId={props.device.id}
91 hostId={props.device.hostId}
92 visible={props.visible}
93 serveSimOnly={props.hostKind === "simlock"}
94 axOverlay={axOverlay}
95 allowPhoneView
96 renderControls={(view) => (
97 <DeviceControlsRail
98 platform={props.device.platform}
99 hostKind={props.hostKind}
100 handle={view.handle}
101 view={view}
102 controls={controls}
103 screenshotPending={screenshotPending}
104 onScreenshot={() => void saveScreenshot()}
105 toolsOpen={toolsOpen}
106 onTools={() => setToolsOpen(!toolsOpen)}
107 onFloat={props.onFloat}
108 onClose={props.onClose}
109 onPowerOff={props.onPowerOff}
110 />
111 )}
112 />
113 </div>
114 </div>
115 {toolsOpen ? (
116 <DeviceToolsPanel
117 device={props.device}
118 controls={controls}
119 hostDiagnostics={props.hostDiagnostics}
120 access={access}
121 axOverlay={axOverlay}
122 onAxOverlayChange={setAxOverlay}
123 onClose={() => setToolsOpen(false)}
124 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"
125 />
126 ) : null}
127 </>
128 );