1import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access";
2import type { PreviewStreamHostSetup } from "@t3tools/contracts";
3import type {
4 PreviewStreamControl,
5 PreviewStreamDownload,
6 PreviewStreamFileChooser,
7} from "@t3tools/client-runtime/preview/server-browser-stream";
8
9export interface PreviewStreamConfiguration {
10 readonly access: DeviceHubAccess;
11 readonly threadId: string;
12 readonly tabId: string;
13 /** Taps, scrolls, keys, and `resize` to the view size. The floating player only watches. */
14 readonly interactive: boolean;
15 readonly background: string;
16}
17
18/** Messages the WebView document posts to the native view. */
19export type PreviewStreamMessage =
20 | ({ readonly type: "control" } & PreviewStreamControl)
21 | {
22 readonly type: "status";
23 readonly status: "connecting" | "streaming" | "error";
24 readonly detail?: string;
25 }
26 | { readonly type: "unauthorized" }
27 | { readonly type: "gone" }
28 | ({ readonly type: "hostSetup" } & PreviewStreamHostSetup)
29 | { readonly type: "viewport"; readonly width: number; readonly height: number }
30 | { readonly type: "clipboard"; readonly text: string }
31 | ({ readonly type: "download" } & PreviewStreamDownload)
32 | { readonly type: "fileChooser"; readonly chooser: PreviewStreamFileChooser | null }
33 | {
34 readonly type: "pictureInPicture";
35 readonly supported: boolean;
36 readonly active: boolean;
37 readonly detail?: string;
38 };
39
40export function previewStreamDocument(configuration: string, script: string) {
41 // Tickets and URLs are data, including any HTML delimiter characters.
42 const safeConfiguration = configuration.replace(/</g, "\\u003c");
43 const safeScript = script.replace(/<\/script/gi, "<\\/script");
44 const failure = `window.ReactNativeWebView.postMessage(JSON.stringify({type:"status",status:"error",detail:"Browser viewer stopped unexpectedly."}));`;
45 return `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
46<style>
47 html, body { height: 100%; overflow: hidden; }
48 body { margin: 0; }
49 body > div { position: fixed; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden; }
50 canvas, video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
51 canvas { touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
52 video { pointer-events: none; }
53 /* The agent's pointer, positioned over the letterboxed frame by the viewer script. */
54 .agent-cursor {
55 position: absolute; left: 0; top: 0; width: 20px; height: 20px; pointer-events: none;
56 opacity: 0; transition: opacity 150ms ease-out, transform 150ms ease-out;
57 }
58 .agent-cursor svg { position: relative; display: block; filter: drop-shadow(0 1px 1px rgba(0,0,0,.35)); }
59 .agent-cursor .ping {
60 position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; border-radius: 50%;
61 background: rgba(59,130,246,.3); animation: agent-cursor-ping 600ms ease-out forwards;
62 }
63 @keyframes agent-cursor-ping { from { transform: scale(.6); opacity: 1; } to { transform: scale(2.2); opacity: 0; } }
64 @media (prefers-reduced-motion: reduce) {
65 .agent-cursor { transition: none; }
66 .agent-cursor .ping { animation: none; opacity: 0; }
67 }
68 /* Pinned so focus never scrolls; 16px keeps iOS from zooming on focus. */
69 textarea {
70 position: fixed; left: 0; top: 0; width: 1px; height: 1px;
71 padding: 0; margin: -1px; border: 0; overflow: hidden;
72 clip: rect(0, 0, 0, 0); white-space: nowrap; font-size: 16px;
73 }
74</style></head><body><script>window.addEventListener("error",function(){${failure}});window.addEventListener("unhandledrejection",function(){${failure}});\n${safeScript}\ntry{T3PreviewStream.start(${safeConfiguration});}catch{${failure}}</script></body></html>`;
75}
76
77export function previewStreamMessage(data: string): PreviewStreamMessage | null {
78 try {
79 // Only our bundled viewer runs in this WebView; the remote page never does.
80 return JSON.parse(data) as PreviewStreamMessage;
81 } catch {
82 return null;
83 }
84}