Files
orca/src/shared/native-chat-visual-shell.ts
T

328 lines
13 KiB
TypeScript

import { PANEL_DESIGN_TOKEN_ALLOWLIST } from './plugins/plugin-panel-shell'
/**
* The document wrapped around an agent-written chat visual before it enters its sandboxed frame.
* Same placement rule as the plugin panel shell: the CSP meta parses before any visual markup, so
* nothing later in the document can loosen it, and the visual's own <html>/<head> merge into ours.
*
* Network: scripts, styles, fonts and images may load from a pinned set of public CDNs; nothing may
* fetch, open sockets, frame, or submit. A page can still encode data in a request to an allowed
* CDN — an accepted, disclosed risk, not a guarantee that no data leaves.
*
* Electron-free string builder: desktop, the web client and mobile build the same document.
*/
export const NATIVE_CHAT_VISUAL_CDN_ORIGINS = [
'https://cdn.jsdelivr.net',
'https://unpkg.com',
'https://cdnjs.cloudflare.com',
'https://esm.sh',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com'
] as const
const ASSET_SOURCES = `'unsafe-inline' data: blob: ${NATIVE_CHAT_VISUAL_CDN_ORIGINS.join(' ')}`
const MEDIA_SOURCES = `data: blob: ${NATIVE_CHAT_VISUAL_CDN_ORIGINS.join(' ')}`
// A srcdoc page otherwise resolves '#x' against Orca's own URL, so an in-page link would leave it.
// It precedes the CSP, whose `base-uri 'none'` then refuses any base the visual declares.
const INERT_BASE = '<base href="about:srcdoc">'
const POLICY_WITHOUT_BASE = [
"default-src 'none'",
`script-src ${ASSET_SOURCES}`,
`style-src ${ASSET_SOURCES}`,
`font-src ${MEDIA_SOURCES}`,
`img-src ${MEDIA_SOURCES}`,
"connect-src 'none'",
"frame-src 'none'",
"child-src 'none'",
"worker-src 'none'",
"object-src 'none'",
"media-src 'none'",
"manifest-src 'none'",
"form-action 'none'"
]
export const NATIVE_CHAT_VISUAL_CSP = [...POLICY_WITHOUT_BASE, "base-uri 'none'"].join('; ')
/**
* For a page that hosts the visual frame and has no URL of its own (mobile). The srcdoc child
* inherits this policy before its own meta parses, so it must admit the shell's `about:srcdoc` base
* (CSP cannot name that URL exactly); the child's own policy then refuses every other base.
*/
export const NATIVE_CHAT_VISUAL_HOST_CSP = [...POLICY_WITHOUT_BASE, 'base-uri about:'].join('; ')
/** Frame names carry this prefix so the main process registers them before content runs. */
export const NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX = 'orca-chat-visual:'
export const NATIVE_CHAT_VISUAL_SIZE_TYPE = 'orca-visual-size'
export const NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE = 'orca-visual-open-link'
export const NATIVE_CHAT_VISUAL_THEME_TYPE = 'orca-visual-theme'
export const NATIVE_CHAT_VISUAL_PING_TYPE = 'orca-visual-ping'
export const NATIVE_CHAT_VISUAL_PONG_TYPE = 'orca-visual-pong'
export const NATIVE_CHAT_VISUAL_READER_SCROLL_TYPE = 'orca-visual-reader-scroll'
/** Visuals' own wait for an answer: at 1 s, a busy visual in WebKit answered too late. */
export const NATIVE_CHAT_VISUAL_PONG_TIMEOUT_MS = 5_000
export const NATIVE_CHAT_VISUAL_MIN_HEIGHT = 80
export const NATIVE_CHAT_VISUAL_MAX_HEIGHT = 2000
const MAX_LINK_LENGTH = 2048
/** Theme variables a visual may style against: the plugin panel set plus the chart series. */
export const NATIVE_CHAT_VISUAL_THEME_TOKENS = [
...PANEL_DESIGN_TOKEN_ALLOWLIST,
'--chart-1',
'--chart-2',
'--chart-3',
'--chart-4',
'--chart-5',
'--font-sans',
'--font-mono'
] as const
export type NativeChatVisualTheme = {
colorScheme: 'light' | 'dark'
tokens: Readonly<Record<string, string>>
}
const TOKEN_NAME_PATTERN = /^--[a-z0-9-]+$/
/** Token values land inside a <style> block, so structural characters are stripped. */
function sanitizeTokenValue(value: string): string {
return value.replaceAll(/[{}<>;\\]/g, '').trim()
}
export function nativeChatVisualThemeCss(theme: NativeChatVisualTheme): string {
const declarations = [`color-scheme:${theme.colorScheme === 'dark' ? 'dark' : 'light'}`]
for (const [name, value] of Object.entries(theme.tokens)) {
const clean = sanitizeTokenValue(value)
if (TOKEN_NAME_PATTERN.test(name) && clean.length > 0) {
declarations.push(`${name}:${clean}`)
}
}
return `:root{${declarations.join(';')}}`
}
// A visual sits in the reply on the thread's own background; its scrollbar stays hidden because the
// frame grows to fit it. The visual's own CSS comes later and wins.
const BASE_CSS =
'html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);' +
'font-size:14px;line-height:1.5;scrollbar-width:none}html::-webkit-scrollbar{display:none}' +
'body{margin:0}code,pre,kbd,samp{font-family:var(--font-mono)}'
function bootstrapScript(channel: string): string {
const constants = JSON.stringify({
channel,
size: NATIVE_CHAT_VISUAL_SIZE_TYPE,
link: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE,
theme: NATIVE_CHAT_VISUAL_THEME_TYPE,
ping: NATIVE_CHAT_VISUAL_PING_TYPE,
pong: NATIVE_CHAT_VISUAL_PONG_TYPE,
scroll: NATIVE_CHAT_VISUAL_READER_SCROLL_TYPE
})
// Plain ES5 so it runs before, and independent of, anything the visual loads.
return `(function () {
'use strict'
var C = ${constants}
var host = window.parent
function send(message) {
message.channel = C.channel
try { host.postMessage(message, '*') } catch (_) {}
}
// Containment: a visual is a document, never a browsing context. The host also refuses navigation.
if (window.navigation && typeof window.navigation.addEventListener === 'function') {
window.navigation.addEventListener('navigate', function (event) {
if (!event.hashChange && event.cancelable) event.preventDefault()
})
}
// Keep open configurable for visual declarations; the iframe sandbox blocks popups.
try { window.open = function () { return null } } catch (_) {}
document.addEventListener('click', function (event) {
var node = event.target
while (node && node.nodeType !== 1) node = node.parentNode
var anchor = node && node.closest ? node.closest('a[href]') : null
if (!anchor) return
var href = anchor.getAttribute('href') || ''
if (href.charAt(0) === '#') return
event.preventDefault()
if (!event.isTrusted) return
var url
try { url = new URL(href, document.baseURI) } catch (_) { return }
if (url.protocol === 'http:' || url.protocol === 'https:') send({ type: C.link, url: url.href })
}, true)
document.addEventListener('submit', function (event) { event.preventDefault() }, true)
// Only the host window may restyle the visual, or ask whether this document still runs the shell.
var themeStyle = document.getElementById('orca-visual-theme')
window.addEventListener('message', function (event) {
var data = event.data
if (event.source !== host || !data || data.channel !== C.channel) return
if (data.type === C.ping) return send({ type: C.pong, id: data.id })
if (data.type !== C.theme || typeof data.css !== 'string' || !themeStyle) return
themeStyle.textContent = data.css
document.documentElement.classList.toggle('dark', data.colorScheme === 'dark')
})
// The host pairs uncancelled input with actual transcript movement; only the browser knows
// whether a visual's nested scroller takes it. Wait for handlers that cancel chart gestures.
window.addEventListener('wheel', function (event) {
if (event.ctrlKey || !event.deltaY) return
setTimeout(function () {
if (!event.defaultPrevented) send({ type: C.scroll, input: 'wheel', deltaY: event.deltaY })
}, 0)
}, { capture: true, passive: true })
window.addEventListener('touchmove', function () { send({ type: C.scroll, input: 'touch' }) }, { capture: true, passive: true })
// Height: report the document's own height whenever layout changes; the host fits the frame.
var reported = -1
var pending = false
function measure() {
pending = false
var height = document.documentElement.getBoundingClientRect().height
var body = document.body
// A page pinned to the frame (html/body height 100%) overflows its body instead of growing it;
// add only what overflows, so a padded full-height body does not grow the frame by itself.
if (body && body.scrollHeight > body.clientHeight) height += body.scrollHeight - body.clientHeight
height = Math.ceil(height)
if (height !== reported && height > 0) {
reported = height
send({ type: C.size, height: height })
}
}
function schedule() {
if (pending) return
pending = true
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(measure)
else setTimeout(measure, 16)
}
if (typeof ResizeObserver === 'function') {
var observer = new ResizeObserver(schedule)
observer.observe(document.documentElement)
document.addEventListener('DOMContentLoaded', function () { if (document.body) observer.observe(document.body) })
}
// Content that overflows a fixed-size body changes no observed box, so watch the content too.
if (typeof MutationObserver === 'function') {
document.addEventListener('DOMContentLoaded', function () {
if (document.body) new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true })
})
}
document.addEventListener('DOMContentLoaded', schedule)
window.addEventListener('load', schedule)
if (document.fonts && document.fonts.ready) document.fonts.ready.then(schedule)
})()`
}
/**
* The full srcdoc for one visual. `channel` is unique per built document and rides on every message
* the shell sends, so the host can tell this document's messages from anything else.
*/
export function buildNativeChatVisualDocument(args: {
html: string
channel: string
theme: NativeChatVisualTheme
}): string {
const scheme = args.theme.colorScheme === 'dark' ? 'dark' : 'light'
return `<!doctype html>
<html class="${scheme}">
<head>
<meta charset="utf-8">
${INERT_BASE}
<meta http-equiv="Content-Security-Policy" content="${NATIVE_CHAT_VISUAL_CSP}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style id="orca-visual-theme">${nativeChatVisualThemeCss(args.theme)}</style>
<style>${BASE_CSS}</style>
<script>${bootstrapScript(args.channel)}</script>
</head>
${args.html}`
}
export type NativeChatVisualFrameMessage =
| { kind: 'size'; height: number }
| { kind: 'open-link'; url: string }
/** Uncancelled input; the host checks whether the transcript actually moved. */
| { kind: 'reader-scroll'; input: 'wheel'; deltaY: number }
| { kind: 'reader-scroll'; input: 'touch' }
/** A message from a visual's shell, validated; null for anything else. */
export function readNativeChatVisualFrameMessage(
data: unknown,
channel: string
): NativeChatVisualFrameMessage | null {
if (typeof data !== 'object' || data === null) {
return null
}
if (!('channel' in data) || data.channel !== channel || !('type' in data)) {
return null
}
if (data.type === NATIVE_CHAT_VISUAL_SIZE_TYPE) {
const height = 'height' in data ? data.height : undefined
return typeof height === 'number' && Number.isFinite(height) && height > 0
? { kind: 'size', height }
: null
}
if (data.type === NATIVE_CHAT_VISUAL_READER_SCROLL_TYPE) {
const input = 'input' in data ? data.input : undefined
if (input === 'touch') {
return { kind: 'reader-scroll', input }
}
const deltaY = 'deltaY' in data ? data.deltaY : undefined
return input === 'wheel' &&
typeof deltaY === 'number' &&
Number.isFinite(deltaY) &&
deltaY !== 0
? { kind: 'reader-scroll', input, deltaY }
: null
}
if (data.type === NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE) {
const url = 'url' in data ? data.url : undefined
if (typeof url !== 'string' || url.length > MAX_LINK_LENGTH) {
return null
}
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? { kind: 'open-link', url: parsed.href }
: null
} catch {
return null
}
}
return null
}
/** The ping id a visual's shell answered on `channel`, or null for anything else. */
export function readNativeChatVisualPong(data: unknown, channel: string): number | null {
if (typeof data !== 'object' || data === null || !('channel' in data) || !('type' in data)) {
return null
}
if (data.channel !== channel || data.type !== NATIVE_CHAT_VISUAL_PONG_TYPE) {
return null
}
const id = 'id' in data ? data.id : undefined
return typeof id === 'number' && Number.isSafeInteger(id) ? id : null
}
export function clampNativeChatVisualHeight(height: number): number {
return Math.min(
NATIVE_CHAT_VISUAL_MAX_HEIGHT,
Math.max(NATIVE_CHAT_VISUAL_MIN_HEIGHT, Math.round(height))
)
}
export function nativeChatVisualPingMessage(
id: number,
channel: string
): { type: string; channel: string; id: number } {
return { type: NATIVE_CHAT_VISUAL_PING_TYPE, channel, id }
}
export function nativeChatVisualThemeMessage(
theme: NativeChatVisualTheme,
channel: string
): { type: string; channel: string; css: string; colorScheme: 'light' | 'dark' } {
return {
type: NATIVE_CHAT_VISUAL_THEME_TYPE,
channel,
css: nativeChatVisualThemeCss(theme),
colorScheme: theme.colorScheme
}
}