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 /
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 = ''
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>
}
const TOKEN_NAME_PATTERN = /^--[a-z0-9-]+$/
/** Token values land inside a
${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
}
}