import type { ToolWidgetStatus } from './toolWidgetUtils'; /** * Pure helpers for the native artifact WebView preview — mirrors the web's * inject-editor-theme.ts / artifact-theme-*.ts posture (frontend/apps/builder/ * src/pages/agent-editor/components/): the model markup is ALWAYS nested inside * a host-controlled document whose starts with the CSP, so the policy is * parsed before any model byte can run. */ // Same directives as the web's CSP_META: resource loading stays permissive // (https/inline/eval for Tailwind Play + CDNs) while programmatic egress is // blocked — `connect-src 'none'` kills fetch/XHR/beacon/WebSocket, // `form-action 'none'` kills form posts, `base-uri 'none'` blocks // hijacking. const CSP_META = ''; const VIEWPORT_META = ''; // Google Fonts copy of the editor typeface — the WebView document can't reach // the app's bundled fonts, same as the web's null-origin iframe. const FONT_LINK = '' + '' + ''; // shadcn HSL palettes — verbatim from the web's artifact-theme-palette.ts so // artifacts written against the editor's `bg-background text-foreground` // conventions render with the same colors on native. const LIGHT_VARS = `--background: 38 27% 94%; --foreground: 0 0% 4%; --card: 0 0% 100%; --card-foreground: 0 0% 4%; --popover: 0 0% 100%; --popover-foreground: 0 0% 4%; --primary: 21 42% 55%; --primary-foreground: 0 0% 100%; --secondary: 0 0% 96%; --secondary-foreground: 0 0% 9%; --muted: 39 17% 93%; --muted-foreground: 0 0% 45%; --accent: 0 0% 96%; --accent-foreground: 0 0% 9%; --destructive: 0 84% 60%; --destructive-foreground: 0 0% 98%; --border: 0 0% 98%; --input: 0 0% 98%; --ring: 21 42% 55%;`; const DARK_VARS = `--background: 220 5% 11%; --foreground: 0 0% 99%; --card: 0 1% 14%; --card-foreground: 0 0% 99%; --popover: 0 1% 14%; --popover-foreground: 0 0% 99%; --primary: 21 42% 55%; --primary-foreground: 0 0% 99%; --secondary: 0 0% 18%; --secondary-foreground: 0 0% 99%; --muted: 0 0% 18%; --muted-foreground: 0 0% 65%; --accent: 0 0% 18%; --accent-foreground: 0 0% 99%; --destructive: 0 62% 30%; --destructive-foreground: 0 0% 99%; --border: 0 0% 16%; --input: 0 0% 16%; --ring: 21 42% 55%;`; const FONT_SANS = "'Wix Madefor Text', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; // Base style: theme variables + the web styleBlock's opaque-document variant // (the WebView is its own surface, not transparent chat inline like the web // card). Bare `border` defaults to the theme token, as in the web base layer. const STYLE_BLOCK = ``; // Tailwind Play CDN + the same shadcn variable mapping as the web's // TAILWIND_BLOCK, so the model's `bg-card rounded-md border` just works. const TAILWIND_BLOCK = ` `; // Popups are inert at the WebView layer (no `onOpenWindow` handler, multiple // windows kept ON so Android's `target="_blank"`-bypasses-the-nav-guard bug // can't apply) — this neutralizes them inside the document too, as defense in // depth: `window.open` returns null and `target="_blank"` clicks are cancelled. const POPUP_BLOCK = ``; // Emitted in AND after the model markup: a model doc shipping // `` is parsed as a duplicate token whose attributes // get adopted onto our root AFTER the head copy ran — the post-body // copy re-syncs so the host scheme wins (same trick as the web). function darkClassScript(isDark: boolean): string { return ``; } // Honor an explicit model ``, else `auto` so RTL content lays out // via the browser's bidi auto-detect (web resolveDocDir parity). function resolveDocDir(html: string): string { const htmlTag = html.match(/]*>/i)?.[0]; return htmlTag?.match(/\bdir\s*=\s*["']?(rtl|ltr|auto)\b/i)?.[1]?.toLowerCase() ?? 'auto'; } /** * SVG artifacts need an HTML shell. Trust an explicit `type` arg; sniff the * markup only when the type is missing. */ export function isSvgArtifact(type: string | undefined, source: string): boolean { if (type) return type === 'image/svg+xml'; return /^\s*(?:<\?xml[^>]*>\s*)?(?:\s*)*]/i.test(source); } /** * Wrap artifact markup in the host-controlled preview document (CSP first, * theme vars + Tailwind for HTML, minimal centering shell for SVG). The result * must ONLY ever be rendered inside the sandboxed artifact WebView. */ export function buildArtifactPreviewHtml( markup: string, { isDark, isSvg }: { isDark: boolean; isSvg?: boolean }, ): string { if (isSvg) { const head = [CSP_META, VIEWPORT_META, POPUP_BLOCK, STYLE_BLOCK, darkClassScript(isDark)].join('\n'); const body = `
${markup}
`; return `${head}${body}`; } const head = [CSP_META, VIEWPORT_META, POPUP_BLOCK, darkClassScript(isDark), FONT_LINK, STYLE_BLOCK, TAILWIND_BLOCK].join('\n'); return `${head}${markup}${darkClassScript(isDark)}`; } /** Live preview only for settled successful artifacts with content. */ export function canPreviewArtifact(status: ToolWidgetStatus, source: string | null | undefined): boolean { return status === 'success' && Boolean(source?.trim()); } /** * Whether a recorded WebView load failure still applies. Failures are keyed * to the markup that failed (mirroring the per-html navigation guard), so a * later `client_artifact` update on the same tool call gets a fresh render * attempt instead of a permanently dead preview. */ export function previewFailureApplies( failedSource: string | null, currentSource: string | null | undefined, ): boolean { return failedSource !== null && failedSource === currentSource; } // What the initial `source={{ html }}` document looks like to the guard: // iOS loads it via `loadHTMLString` with an `about:blank` base URL and the // policy delegate surfaces that load as "about:blank"; Android loads it via // `loadDataWithBaseURL` and documents that onShouldStartLoadWithRequest is // NOT called on the first load at all. const INITIAL_DOCUMENT_URL = 'about:blank'; /** * Per-WebView, platform-aware `onShouldStartLoadWithRequest` guard (pass * `Platform.OS`). iOS: exactly one admission — the first callback, and only * when it is the initial inline document reported as `about:blank`. Android * (and any other platform): the initial load never consults the guard, so * every callback is already artifact-initiated and the guard starts CLOSED — * a platform-blind single admission would let scripted * `window.location = 'about:blank'` mint a CSP-less document there. Once * closed, everything is blocked: http(s), file, custom schemes, `data:` * documents, `about:` re-navs — any of which would produce a document * without our CSP shell, escaping `connect-src 'none'`. */ export function createArtifactNavigationGuard(platform: string): (url: string) => boolean { let admissionOpen = platform === 'ios'; return (url: string): boolean => { if (!admissionOpen) return false; admissionOpen = false; return (url || '').toLowerCase() === INITIAL_DOCUMENT_URL; }; }