Skip to content
Source
Sign inSign up

DeviceStreamWebView.tsx

213 lines7.2 KB

1import { Button } from "../../components/ui/Button";
2import deviceStreamScript from "@t3tools/mobile-device-stream";
3import {
4 useEffectEvent,
5 useImperativeHandle,
6 useLayoutEffect,
7 useRef,
8 useState,
9 type Ref,
10} from "react";
11import { Platform, View } from "react-native";
12import { WebView } from "react-native-webview";
13import type { DeviceStreamStatus } from "@t3tools/client-runtime/device/stream";
14​
15import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
16import { AppText } from "../../components/ui/AppText";
17​
18import {
19 deviceStreamBaseUrl,
20 deviceStreamDocument,
21 deviceStreamMessage,
22 type DeviceStreamConfiguration,
23} from "./device-stream-document";
24​
25export interface DeviceStreamRef {
26 home: () => void;
27 back: () => void;
28 appSwitcher: () => void;
29 rotate: () => void;
31​
32type NativeStreamBridge = {
33 readonly ref?: Ref<DeviceStreamRef>;
34 readonly onUnauthorized: () => Promise<void>;
35 readonly onInputConnected: (connected: boolean) => Promise<void>;
36};
37​
38export function DeviceStreamWebView({
39 ref,
40 ...props
41}: DeviceStreamConfiguration & NativeStreamBridge) {
42 const [attempt, setAttempt] = useState(0);
43 const processRetried = useRef(false);
44 const configuration = JSON.stringify({
45 access: props.access,
46 platform: props.platform,
47 deviceId: props.deviceId,
48 ...(props.serveSimOnly ? { serveSimOnly: true } : {}),
49 colors: props.colors,
50 });
51 return (
52 <DeviceStreamDocumentView
53 key={`${attempt}:${configuration}`}
54 ref={ref}
55 configuration={configuration}
56 baseUrl={deviceStreamBaseUrl(Platform.OS, props.platform, props.access.httpBase)}
57 background={props.colors.background}
58 onUnauthorized={props.onUnauthorized}
59 onInputConnected={props.onInputConnected}
60 onRetry={() => {
61 processRetried.current = false;
62 setAttempt((attempt) => attempt + 1);
63 void props.onUnauthorized();
64 }}
65 onStreaming={() => {
66 processRetried.current = false;
67 }}
68 onRecoverProcess={() => {
69 if (processRetried.current) return false;
70 processRetried.current = true;
71 setAttempt((attempt) => attempt + 1);
72 void props.onUnauthorized();
73 return true;
74 }}
75 />
76 );
78​
79function DeviceStreamDocumentView({
80 ref,
81 configuration,
82 baseUrl,
83 background,
84 onUnauthorized,
85 onInputConnected,
86 onRetry,
87 onStreaming,
88 onRecoverProcess,
89}: NativeStreamBridge & {
90 readonly configuration: string;
91 readonly baseUrl: string;
92 readonly background: string;
93 readonly onRetry: () => void;
94 readonly onStreaming: () => void;
95 readonly onRecoverProcess: () => boolean;
96}) {
97 const webView = useRef<WebView<object>>(null);
98 const active = useRef(false);
99 const failed = useRef(false);
100 const bootstrapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
101 const [status, setStatus] = useState<DeviceStreamStatus>("connecting");
102 const [error, setError] = useState<string | null>(null);
103 const clearBootstrapTimer = () => {
104 if (bootstrapTimer.current === null) return;
105 clearTimeout(bootstrapTimer.current);
106 bootstrapTimer.current = null;
107 };
108 const fail = (message: string) => {
109 if (!active.current || failed.current) return;
110 failed.current = true;
111 clearBootstrapTimer();
112 void onInputConnected(false);
113 webView.current?.injectJavaScript("window.T3DeviceStream?.stop(); true;");
114 setError(message);
115 setStatus("error");
116 };
117 const bootstrapTimedOut = useEffectEvent(() =>
118 fail("Device viewer could not start. Reconnect to try again."),
119 );
120 const resetInput = useEffectEvent(() => void onInputConnected(false));
121 // The stream lives exactly as long as this view.
122 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts the bootstrap timeout and stops the WebView device stream when this view unmounts
123 useLayoutEffect(() => {
124 active.current = true;
125 resetInput();
126 const view = webView.current;
127 bootstrapTimer.current = setTimeout(bootstrapTimedOut, 15_000);
128 return () => {
129 if (bootstrapTimer.current !== null) clearTimeout(bootstrapTimer.current);
130 bootstrapTimer.current = null;
131 active.current = false;
132 view?.injectJavaScript("window.T3DeviceStream?.stop(); true;");
133 resetInput();
134 };
135 }, []);
136 const source = {
137 html: deviceStreamDocument(configuration, deviceStreamScript),
138 baseUrl,
139 };
140 const command = (button: keyof DeviceStreamRef) => {
141 webView.current?.injectJavaScript(
142 `window.T3DeviceStream?.command(${JSON.stringify(button)}); true;`,
143 );
144 };
145 useImperativeHandle(ref, () => ({
146 home: () => command("home"),
147 back: () => command("back"),
148 appSwitcher: () => command("appSwitcher"),
149 rotate: () => command("rotate"),
150 }));
151 const processTerminated = () => {
152 if (!active.current || failed.current) return;
153 void onInputConnected(false);
154 if (!onRecoverProcess()) fail("Device viewer stopped. Reconnect to try again.");
155 };
156 return (
157 <View className="flex-1" style={{ backgroundColor: background }}>
158 <WebView<object>
159 ref={webView}
160 source={source}
161 originWhitelist={["*"]}
162 scrollEnabled={false}
163 bounces={false}
164 mixedContentMode="always"
165 allowUniversalAccessFromFileURLs
166 contentInsetAdjustmentBehavior="never"
167 setSupportMultipleWindows={false}
168 style={{ flex: 1, backgroundColor: background }}
169 onError={() => fail("Device viewer could not load. Reconnect to try again.")}
170 onHttpError={() => fail("Device viewer could not load. Reconnect to try again.")}
171 onContentProcessDidTerminate={processTerminated}
172 onRenderProcessGone={processTerminated}
173 onShouldStartLoadWithRequest={(request) =>
174 request.url === "about:blank" || request.url === source.baseUrl
175 }
176 onMessage={(event) => {
177 if (!active.current || failed.current) return;
178 const message = deviceStreamMessage(event.nativeEvent.data);
179 if (message?.type === "unauthorized") void onUnauthorized();
180 else if (message?.type === "input") void onInputConnected(message.connected);
181 else if (message?.type === "retry") onRetry();
182 else if (message?.type === "status") {
183 clearBootstrapTimer();
184 if (message.status === "error") fail(message.detail ?? "Device stream failed.");
185 else {
186 setStatus(message.status);
187 if (message.status === "streaming") onStreaming();
188 }
189 }
190 }}
191 />
192 {status !== "streaming" ? (
193 <View
194 className="absolute inset-0 items-center justify-center gap-4 px-6"
195 style={{ backgroundColor: background }}
196 >
197 {status === "connecting" ? <AppActivityIndicator colorClassName="accent-icon" /> : null}
198 <AppText
199 accessibilityLiveRegion="polite"
200 className="text-center text-sm text-foreground-muted"
201 >
202 {status === "error" ? error : "Connecting to device..."}
203 </AppText>
204 {status === "error" ? (
205 <View className="items-center">
206 <Button label="Reconnect" tone="secondary" onPress={onRetry} />
207 </View>
208 ) : null}
209 </View>
210 ) : null}
211 </View>
212 );