| 1 | import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access"; |
| 2 | import type { PreviewStreamHostSetup } from "@t3tools/contracts"; |
| 3 | import type { |
| 4 | PreviewStreamControl, |
| 5 | PreviewStreamDownload, |
| 6 | PreviewStreamFileChooser, |
| 7 | } from "@t3tools/client-runtime/preview/server-browser-stream"; |
| 8 | |
| 9 | export 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. */ |
| 19 | export 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 | |
| 40 | export 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 | |
| 77 | export 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 | } |