1import {
2 createDeviceStreamClient,
3 type DeviceScreenSize,
4} from "@t3tools/client-runtime/device/stream";
5
6import type { DeviceStreamConfiguration } from "./device-stream-document";
7
8declare global {
9 interface Window {
10 ReactNativeWebView: { postMessage: (message: string) => void };
11 }
12}
13
14let activeClient: ReturnType<typeof createDeviceStreamClient> | null = null;
15
16export function stop() {
17 activeClient?.stop();
18 activeClient = null;
19}
20
21export function command(button: "home" | "back" | "appSwitcher" | "rotate") {
22 if (button === "rotate") activeClient?.rotate();
23 else activeClient?.pressButton(button);
24}
25
26/** Bundled into the existing native WebView without React or Expo's web runtime. */
27export function start(configuration: DeviceStreamConfiguration) {
28 stop();
29 // oxlint-disable-next-line unicorn/require-post-message-target-origin -- The native WebView bridge takes one string.
30 const post = (message: object) => window.ReactNativeWebView.postMessage(JSON.stringify(message));
31 const { colors, platform } = configuration;
32 Object.assign(document.documentElement.style, { height: "100%", overflow: "hidden" });
33 Object.assign(document.body.style, {
34 margin: "0",
35 height: "100%",
36 overflow: "hidden",
37 background: colors.background,
38 color: colors.foreground,
39 fontFamily: "system-ui",
40 });
41 const container = document.createElement("div");
42 Object.assign(container.style, {
43 position: "fixed",
44 inset: "0",
45 containerType: "size",
46 display: "flex",
47 alignItems: "center",
48 justifyContent: "center",
49 });
50 const frame = document.createElement("div");
51 frame.setAttribute("role", "application");
52 frame.setAttribute(
53 "aria-label",
54 platform === "ios" ? "iOS Simulator screen" : "Android Emulator screen",
55 );
56 frame.tabIndex = 0;
57 Object.assign(frame.style, {
58 position: "relative",
59 touchAction: "none",
60 userSelect: "none",
61 webkitUserSelect: "none",
62 webkitTouchCallout: "none",
63 outline: "none",
64 });
65 const canvas = document.createElement("canvas");
66 const image = document.createElement("img");
67 image.alt = "";
68 image.draggable = false;
69 image.style.display = "none";
70 const inputStatus = document.createElement("div");
71 inputStatus.setAttribute("role", "status");
72 inputStatus.textContent = "Reconnecting device controls...";
73 Object.assign(inputStatus.style, {
74 position: "fixed",
75 bottom: "12px",
76 left: "0",
77 right: "0",
78 textAlign: "center",
79 pointerEvents: "none",
80 fontSize: "13px",
81 color: colors.muted,
82 background: colors.background,
83 });
84 frame.append(canvas, image);
85 container.append(frame);
86 document.body.replaceChildren(container, inputStatus);
87
88 let pointerId: number | null = null;
89 let inputConnected = false;
90 let streaming = false;
91 const reportStatus = (status: "connecting" | "streaming" | "error", detail?: string) => {
92 if (activeClient !== client) return;
93 streaming = status === "streaming";
94 inputStatus.style.display = streaming && !inputConnected ? "block" : "none";
95 post({ type: "status", status, detail });
96 };
97 const layout = (screen: DeviceScreenSize | null) => {
98 const landscape =
99 screen?.orientation === "landscape_left" || screen?.orientation === "landscape_right";
100 const aspect = screen
101 ? landscape
102 ? Math.max(screen.width, screen.height) / Math.min(screen.width, screen.height)
103 : Math.min(screen.width, screen.height) / Math.max(screen.width, screen.height)
104 : 9 / 19.5;
105 const rotation =
106 platform === "ios" && screen && screen.width <= screen.height
107 ? screen.orientation === "landscape_left"
108 ? 90
109 : screen.orientation === "landscape_right"
110 ? -90
111 : screen.orientation === "portrait_upside_down"
112 ? 180
113 : 0
114 : 0;
115 const sideways = Math.abs(rotation) === 90;
116 frame.style.width = `min(100cqw, ${aspect * 100}cqh)`;
117 frame.style.height = `min(100cqh, ${100 / aspect}cqw)`;
118 for (const media of [canvas, image]) {
119 Object.assign(media.style, {
120 position: "absolute",
121 width: sideways ? `${100 / aspect}%` : "100%",
122 height: sideways ? `${100 * aspect}%` : "100%",
123 left: "50%",
124 top: "50%",
125 transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
126 pointerEvents: "none",
127 });
128 }
129 };
130 layout(null);
131 const unauthorized = () => {
132 if (activeClient === client) post({ type: "unauthorized" });
133 };
134 const client = createDeviceStreamClient(
135 { ...configuration, preferMjpeg: platform === "ios" },
136 canvas,
137 {
138 onStatus: reportStatus,
139 onScreen: layout,
140 onMjpegFallback: () => {
141 canvas.style.display = "none";
142 image.style.display = "block";
143 },
144 onUnauthorized: unauthorized,
145 onInputConnected: (connected) => {
146 inputConnected = connected;
147 inputStatus.style.display = streaming && !connected ? "block" : "none";
148 post({ type: "input", connected });
149 },
150 },
151 );
152 activeClient = client;
153 client.setMjpegImage(image);
154 const touch = (event: PointerEvent, phase: "begin" | "move" | "end") => {
155 const rect = frame.getBoundingClientRect();
156 client.sendTouch(
157 phase,
158 Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
159 Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)),
160 );
161 };
162 frame.addEventListener("pointerdown", (event) => {
163 if (!inputConnected || pointerId !== null) return;
164 event.preventDefault();
165 pointerId = event.pointerId;
166 frame.setPointerCapture(event.pointerId);
167 frame.focus();
168 touch(event, "begin");
169 });
170 frame.addEventListener("pointermove", (event) => {
171 if (pointerId === event.pointerId) touch(event, "move");
172 });
173 const endTouch = (event: PointerEvent) => {
174 if (pointerId !== event.pointerId) return;
175 pointerId = null;
176 touch(event, "end");
177 };
178 frame.addEventListener("pointerup", endTouch);
179 frame.addEventListener("pointercancel", endTouch);
180 frame.addEventListener("lostpointercapture", endTouch);
181 frame.addEventListener("keydown", (event) => {
182 event.preventDefault();
183 client.sendKey(event, "down");
184 });
185 frame.addEventListener("keyup", (event) => client.sendKey(event, "up"));
186 window.addEventListener("pagehide", stop, { once: true });
187 post({ type: "input", connected: false });
188 client.start();
189}