1import previewStreamScript from "@t3tools/mobile-preview-stream";
2import {
3 previewStreamControlLabel,
4 previewStreamHostSetupMessage,
5 type PreviewStreamControl,
6 type PreviewStreamDownload,
7 type PreviewStreamFileChooser,
8 type PreviewStreamInput,
9} from "@t3tools/client-runtime/preview/server-browser-stream";
10import type { EnvironmentId, PreviewStreamHostSetup } from "@t3tools/contracts";
11import {
12 useEffect,
13 useEffectEvent,
14 useImperativeHandle,
15 useLayoutEffect,
16 useMemo,
17 useRef,
18 useState,
19 type Ref,
20} from "react";
21import { ActivityIndicator, Alert, Platform, Pressable, TextInput, View } from "react-native";
22import { WebView } from "react-native-webview";
23import * as Clipboard from "expo-clipboard";
24
25import { AppText } from "../../components/AppText";
26import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
27import { beginForegroundHandoff } from "../../lib/foreground-handoff";
28import { usePreviewStreamAccess } from "../../state/preview";
29import { previewPromptText } from "./previewPromptText";
30
31import {
32 previewStreamDocument,
33 previewStreamMessage,
34 type PreviewStreamConfiguration,
35} from "./preview-stream-document";
36
37export interface PreviewStreamRef {
38 /** Navigation requires current control of the browser. */
39 readonly command: (input: PreviewStreamInput) => void;
40 readonly togglePictureInPicture: () => void;
41}
42
43export interface PreviewPictureInPictureState {
44 readonly supported: boolean;
45 readonly active: boolean;
46}
47
48type NativeStreamBridge = {
49 readonly ref?: Ref<PreviewStreamRef>;
50 /** Refresh stream access; new access remounts the document with a fresh ticket. */
51 readonly onUnauthorized: () => void;
52 /** The tab was closed on the server. */
53 readonly onGone?: () => void;
54 readonly onViewport?: (viewport: { readonly width: number; readonly height: number }) => void;
55 readonly onControl?: (control: PreviewStreamControl | null) => void;
56 readonly onPictureInPicture?: (state: PreviewPictureInPictureState, detail?: string) => void;
57 /** True while frames show, so commands reach the page. Pass a stable function. */
58 readonly onStreamingChange?: (streaming: boolean) => void;
59 /** The floating player shows a spinner without text or a reconnect button. */
60 readonly compact?: boolean;
61};
62
63/** Picks files on this device and sends them to the page's open picker; none cancels it. */
64async function sendFilesToPage(chooser: PreviewStreamFileChooser, pick: boolean) {
65 const body = new FormData();
66 if (pick) {
67 const { getDocumentAsync } = await import("expo-document-picker");
68 const endHandoff = beginForegroundHandoff();
69 let result: Awaited<ReturnType<typeof getDocumentAsync>>;
70 try {
71 result = await getDocumentAsync({ multiple: chooser.multiple, copyToCacheDirectory: true });
72 } finally {
73 endHandoff();
74 }
75 for (const asset of result.canceled ? [] : result.assets) {
76 // React Native's FormData uploads a file part from its URI.
77 body.append("file", {
78 uri: asset.uri,
79 name: asset.name,
80 type: asset.mimeType ?? "application/octet-stream",
81 } as unknown as Blob);
82 }
83 }
84 const response = await fetch(chooser.uploadUrl, {
85 method: "POST",
86 body,
87 credentials: chooser.credentials ? "include" : "omit",
88 });
89 if (!response.ok) throw new Error((await response.text()) || "The upload was refused.");
90}
91
92/** The file is on the environment; saving it here goes through the share sheet. */
93function offerDownload(download: PreviewStreamDownload) {
94 Alert.alert(`Downloaded ${download.fileName}`, undefined, [
95 { text: "Not now", style: "cancel" },
96 {
97 text: "Save or share",
98 onPress: () =>
99 void downloadAndShareAttachment({
100 url: download.url,
101 attachment: { name: download.fileName, mimeType: "application/octet-stream" },
102 signal: new AbortController().signal,
103 }).catch((cause: unknown) =>
104 Alert.alert(
105 "Could not save the download",
106 cause instanceof Error ? cause.message : undefined,
107 ),
108 ),
109 },
110 ]);
111}
112
113// Consecutive refused tickets before the view stops and offers Reconnect, e.g. a
114// session without operate scope.
115const MAX_REFUSALS = 3;
116
117export function PreviewStreamWebView(
118 props: Omit<PreviewStreamConfiguration, "access"> &
119 Omit<NativeStreamBridge, "onUnauthorized"> & {
120 readonly environmentId: EnvironmentId;
121 readonly paused?: boolean;
122 },
123) {
124 const { access, error, refresh } = usePreviewStreamAccess(props.environmentId);
125 if (access && !props.paused) {
126 return <AuthorizedPreviewStream {...props} access={access} onUnauthorized={refresh} />;
127 }
128 return (
129 <View
130 className={
131 props.compact
132 ? "flex-1 items-center justify-center"
133 : "flex-1 items-center justify-center gap-4 px-6"
134 }
135 >
136 {props.compact || !error ? <ActivityIndicator colorClassName="accent-icon" /> : null}
137 {!props.compact && (
138 <>
139 <AppText
140 selectable={!!error}
141 className={
142 error ? "text-center text-sm text-foreground-muted" : "text-sm text-foreground-muted"
143 }
144 >
145 {error || "Connecting to browser..."}
146 </AppText>
147 {error && (
148 <Pressable
149 accessibilityRole="button"
150 className="rounded-full border border-secondary-border bg-secondary px-6 py-3"
151 onPress={refresh}
152 >
153 <AppText className="text-secondary-foreground">Retry</AppText>
154 </Pressable>
155 )}
156 </>
157 )}
158 </View>
159 );
160}
161
162function AuthorizedPreviewStream({
163 ref,
164 ...props
165}: PreviewStreamConfiguration & NativeStreamBridge) {
166 const [attempt, setAttempt] = useState(0);
167 const [previousAccess, setPreviousAccess] = useState(props.access);
168 // Wait for refreshed access, including cookie credentials with unchanged JSON.
169 if (props.access !== previousAccess) {
170 setPreviousAccess(props.access);
171 setAttempt((current) => current + 1);
172 }
173 const processRetried = useRef(false);
174 const unauthorized = useRef(0);
175 const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
176 useEffect(
177 () => () => {
178 if (refreshTimer.current !== null) clearTimeout(refreshTimer.current);
179 },
180 [],
181 );
182 const configuration = JSON.stringify({
183 access: props.access,
184 threadId: props.threadId,
185 tabId: props.tabId,
186 interactive: props.interactive,
187 background: props.background,
188 } satisfies PreviewStreamConfiguration);
189 return (
190 <PreviewStreamDocumentView
191 key={`${attempt}:${configuration}`}
192 {...props}
193 ref={ref}
194 configuration={configuration}
195 onUnauthorized={() => {
196 // The client has stopped. Restart it with a fresh ticket, backing off between refusals.
197 const refusals = ++unauthorized.current;
198 if (refusals >= MAX_REFUSALS) return false;
199 if (refreshTimer.current !== null) clearTimeout(refreshTimer.current);
200 refreshTimer.current = setTimeout(
201 () => {
202 refreshTimer.current = null;
203 props.onUnauthorized();
204 },
205 refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1),
206 );
207 return true;
208 }}
209 onRetry={() => {
210 processRetried.current = false;
211 unauthorized.current = 0;
212 props.onUnauthorized();
213 }}
214 onStreaming={() => {
215 processRetried.current = false;
216 unauthorized.current = 0;
217 }}
218 onRecoverProcess={() => {
219 if (processRetried.current) return false;
220 processRetried.current = true;
221 setAttempt((current) => current + 1);
222 return true;
223 }}
224 />
225 );
226}
227
228function PreviewStreamDocumentView({
229 ref,
230 configuration,
231 background,
232 compact,
233 onUnauthorized,
234 onGone,
235 onViewport,
236 onControl,
237 onPictureInPicture,
238 onStreamingChange,
239 onRetry,
240 onStreaming,
241 onRecoverProcess,
242}: Omit<NativeStreamBridge, "onUnauthorized"> & {
243 readonly configuration: string;
244 readonly background: string;
245 /** False when the view should stop retrying and fail. */
246 readonly onUnauthorized: () => boolean;
247 readonly onRetry: () => void;
248 readonly onStreaming: () => void;
249 readonly onRecoverProcess: () => boolean;
250}) {
251 const webView = useRef<WebView<object>>(null);
252 const active = useRef(true);
253 const failed = useRef(false);
254 const [status, setStatus] = useState<"connecting" | "streaming" | "error">("connecting");
255 const [error, setError] = useState<string | null>(null);
256 const [gone, setGone] = useState(false);
257 const [hostSetup, setHostSetup] = useState<PreviewStreamHostSetup | null>(null);
258 const [started, setStarted] = useState(false);
259 const [control, setControl] = useState<PreviewStreamControl | null>(null);
260 const dialogRef = useRef<PreviewStreamControl["dialog"]>(null);
261 const [promptText, setPromptText] = useState("");
262 const [fileChooser, setFileChooser] = useState<PreviewStreamFileChooser | null>(null);
263 const answerFileChooser = (pick: boolean) => {
264 const chooser = fileChooser;
265 if (!chooser) return;
266 setFileChooser(null);
267 void sendFilesToPage(chooser, pick).catch((cause: unknown) =>
268 Alert.alert(
269 "Could not send the files to the page",
270 cause instanceof Error ? cause.message : undefined,
271 ),
272 );
273 };
274 const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next));
275 const command = (input: PreviewStreamInput) =>
276 webView.current?.injectJavaScript(
277 `window.T3PreviewStream?.command(${JSON.stringify(input)}); true;`,
278 );
279 const fail = (message: string) => {
280 if (!active.current || failed.current) return;
281 failed.current = true;
282 webView.current?.injectJavaScript("window.T3PreviewStream?.stop(); true;");
283 onStreamingChange?.(false);
284 setControl(null);
285 setFileChooser(null);
286 onControl?.(null);
287 setError(message);
288 setStatus("error");
289 };
290 // The shared transport owns reconnects once the document acknowledges startup.
291 const bootstrapTimedOut = useEffectEvent(() =>
292 fail("Browser viewer could not start. Reconnect to try again."),
293 );
294 useEffect(() => {
295 if (started) return;
296 const timer = setTimeout(bootstrapTimedOut, 15_000);
297 return () => clearTimeout(timer);
298 }, [started]);
299 const source = useMemo(
300 () => ({
301 html: previewStreamDocument(configuration, previewStreamScript),
302 baseUrl: Platform.OS === "android" ? "https://localhost/" : "file:///",
303 }),
304 [configuration],
305 );
306 useImperativeHandle(ref, () => ({
307 command,
308 togglePictureInPicture: () =>
309 webView.current?.injectJavaScript("window.T3PreviewStream?.pictureInPicture(); true;"),
310 }));
311 useLayoutEffect(() => {
312 active.current = true;
313 const view = webView.current;
314 return () => {
315 active.current = false;
316 view?.injectJavaScript("window.T3PreviewStream?.stop(); true;");
317 };
318 }, []);
319 useEffect(() => () => onStreamingChange?.(false), [onStreamingChange]);
320 useEffect(() => () => controlChanged(null), []);
321 const processTerminated = () => {
322 if (!active.current || failed.current) return;
323 if (!onRecoverProcess()) fail("Browser viewer stopped. Reconnect to try again.");
324 };
325 return (
326 <View className="flex-1" style={{ backgroundColor: background }}>
327 {!compact ? (
328 <View className="flex-row items-center justify-between gap-2 border-b border-secondary-border px-3 py-2">
329 <AppText className="text-xs text-foreground-muted">
330 {previewStreamControlLabel(control)}
331 </AppText>
332 {control?.canOperate ? (
333 <Pressable
334 accessibilityRole="button"
335 disabled={control.controller === "another-viewer"}
336 accessibilityState={{ disabled: control.controller === "another-viewer" }}
337 className="rounded-full border border-secondary-border bg-secondary px-3 py-2"
338 onPress={() =>
339 command({ type: control.controller === "you" ? "releaseControl" : "takeControl" })
340 }
341 >
342 <AppText className="text-xs text-secondary-foreground">
343 {control.controller === "you" ? "Release control" : "Take control"}
344 </AppText>
345 </Pressable>
346 ) : null}
347 </View>
348 ) : null}
349 <WebView<object>
350 ref={webView}
351 source={source}
352 originWhitelist={["*"]}
353 scrollEnabled={false}
354 bounces={false}
355 mixedContentMode="always"
356 allowUniversalAccessFromFileURLs
357 contentInsetAdjustmentBehavior="never"
358 setSupportMultipleWindows={false}
359 // Picture in picture plays the canvas as an inline muted video, started from native chrome.
360 allowsInlineMediaPlayback
361 allowsPictureInPictureMediaPlayback
362 mediaPlaybackRequiresUserAction={false}
363 style={{ flex: 1, backgroundColor: background }}
364 onError={() => fail("Browser viewer could not load. Reconnect to try again.")}
365 onHttpError={() => fail("Browser viewer could not load. Reconnect to try again.")}
366 onContentProcessDidTerminate={processTerminated}
367 onRenderProcessGone={processTerminated}
368 onShouldStartLoadWithRequest={(request) =>
369 request.url === "about:blank" || request.url === source.baseUrl
370 }
371 onMessage={(event) => {
372 if (!active.current || failed.current) return;
373 const message = previewStreamMessage(event.nativeEvent.data);
374 if (message === null) return;
375 switch (message.type) {
376 case "control": {
377 setControl(message);
378 const previousDialog = dialogRef.current;
379 dialogRef.current = message.dialog;
380 setPromptText((draft) => previewPromptText(previousDialog, message.dialog, draft));
381 onControl?.(message);
382 return;
383 }
384 case "unauthorized":
385 if (!onUnauthorized()) {
386 fail("This session can't open the browser stream. Reconnect to try again.");
387 }
388 return;
389 case "gone":
390 setGone(true);
391 fail("This tab was closed.");
392 onGone?.();
393 return;
394 case "hostSetup":
395 setHostSetup({ need: message.need, command: message.command });
396 fail(previewStreamHostSetupMessage(message));
397 return;
398 case "viewport":
399 onViewport?.(message);
400 return;
401 case "clipboard":
402 void Clipboard.setStringAsync(message.text).catch(() => undefined);
403 return;
404 case "download":
405 offerDownload(message);
406 return;
407 case "fileChooser":
408 setFileChooser(message.chooser);
409 return;
410 case "pictureInPicture":
411 onPictureInPicture?.(message, message.detail);
412 return;
413 case "status":
414 setStarted(true);
415 if (message.status === "error") {
416 fail(message.detail ?? "Browser stream failed.");
417 return;
418 }
419 setStatus(message.status);
420 if (message.status === "connecting") {
421 // Dialogs may have closed and reopened while this viewer was disconnected.
422 dialogRef.current = null;
423 setControl(null);
424 onControl?.(null);
425 }
426 onStreamingChange?.(message.status === "streaming");
427 if (message.status === "streaming") onStreaming();
428 }
429 }}
430 />
431 {!compact && fileChooser && control?.controller === "you" ? (
432 <View className="absolute inset-x-3 top-16 gap-3 rounded-xl border border-secondary-border bg-secondary p-4">
433 <AppText className="text-sm text-secondary-foreground">
434 The page asks for {fileChooser.multiple ? "files" : "a file"}.
435 </AppText>
436 <View className="flex-row justify-end gap-3">
437 <Pressable
438 accessibilityRole="button"
439 className="px-3 py-2"
440 onPress={() => answerFileChooser(false)}
441 >
442 <AppText className="text-secondary-foreground">Cancel</AppText>
443 </Pressable>
444 <Pressable
445 accessibilityRole="button"
446 className="px-3 py-2"
447 onPress={() => answerFileChooser(true)}
448 >
449 <AppText className="text-secondary-foreground">
450 Choose {fileChooser.multiple ? "files" : "file"}
451 </AppText>
452 </Pressable>
453 </View>
454 </View>
455 ) : null}
456 {!compact && control?.dialog ? (
457 <View className="absolute inset-x-3 top-16 gap-3 rounded-xl border border-secondary-border bg-secondary p-4">
458 <AppText className="text-sm text-secondary-foreground">{control.dialog.message}</AppText>
459 {control.controller === "you" ? (
460 <>
461 {control.dialog.type === "prompt" ? (
462 <TextInput
463 accessibilityLabel="Dialog response"
464 className="rounded-lg border border-input-border bg-input px-3 py-2 text-foreground"
465 placeholderTextColorClassName="accent-placeholder"
466 selectionColorClassName="accent-focus/32"
467 value={promptText}
468 onChangeText={setPromptText}
469 />
470 ) : null}
471 <View className="flex-row justify-end gap-3">
472 <Pressable
473 accessibilityRole="button"
474 className="px-3 py-2"
475 onPress={() => command({ type: "dialog", accept: false })}
476 >
477 <AppText className="text-secondary-foreground">Dismiss</AppText>
478 </Pressable>
479 <Pressable
480 accessibilityRole="button"
481 className="px-3 py-2"
482 onPress={() =>
483 command({
484 type: "dialog",
485 accept: true,
486 ...(control.dialog?.type === "prompt" ? { promptText } : {}),
487 })
488 }
489 >
490 <AppText className="text-secondary-foreground">Accept</AppText>
491 </Pressable>
492 </View>
493 </>
494 ) : (
495 <AppText className="text-xs text-foreground-muted">Take control to respond.</AppText>
496 )}
497 </View>
498 ) : null}
499 {status !== "streaming" ? (
500 <View
501 className="absolute inset-0 items-center justify-center gap-4 px-6"
502 style={{ backgroundColor: background }}
503 >
504 {status === "connecting" ? <ActivityIndicator colorClassName="accent-icon" /> : null}
505 {compact ? null : (
506 <AppText
507 accessibilityLiveRegion="polite"
508 className="text-center text-sm text-foreground-muted"
509 >
510 {status === "error" ? error : "Connecting to browser..."}
511 </AppText>
512 )}
513 {status === "error" && hostSetup && !compact ? (
514 <Pressable
515 accessibilityRole="button"
516 accessibilityLabel={`Copy ${hostSetup.command}`}
517 className="w-full flex-row items-center gap-2 rounded-xl border border-secondary-border bg-secondary px-4 py-3"
518 onPress={() => {
519 void Clipboard.setStringAsync(hostSetup.command).catch(() => undefined);
520 }}
521 >
522 <AppText selectable className="flex-1 font-mono text-sm text-secondary-foreground">
523 {hostSetup.command}
524 </AppText>
525 <AppText className="text-xs text-foreground-muted">Copy</AppText>
526 </Pressable>
527 ) : null}
528 {status === "error" && !gone && !compact ? (
529 <Pressable
530 accessibilityRole="button"
531 className="rounded-full border border-secondary-border bg-secondary px-6 py-3"
532 onPress={onRetry}
533 >
534 <AppText className="text-secondary-foreground">
535 {hostSetup ? "Try again" : "Reconnect"}
536 </AppText>
537 </Pressable>
538 ) : null}
539 </View>
540 ) : null}
541 </View>
542 );
543}