import type { JsonObject } from "../protocol"; import type { SurfaceInstance } from "./surface-instance-manager"; export type SurfaceHostCallbacks = Readonly<{ ready: (instanceID: string) => void; event: (instanceID: string, event: "cancel") => void; }>; type HostedSurface = { readonly instance: SurfaceInstance; readonly frame: HTMLIFrameElement; ready: boolean; cancelSent: boolean; }; const BRIDGE_VERSION = 1; const READY_TYPE = "lineup.surface.v1.ready"; const STATE_TYPE = "lineup.surface.v1.state"; /** * M2-03 Web Reference Host. The frame is deliberately an opaque sandbox * origin: it receives no same-origin access to the chat page, its storage, * the Tauri bridge, or browser network credentials. Its only connection to * the parent is the narrow postMessage bridge below. */ export class IsolatedSurfaceHost { private readonly surfaces = new Map(); public constructor(private readonly mountPoint: HTMLElement, private readonly callbacks: SurfaceHostCallbacks) { window.addEventListener("message", this.receiveMessage); } public mount(instance: SurfaceInstance): void { const current = this.surfaces.get(instance.instance_id); if (current) { current.frame.dataset.surfaceState = JSON.stringify(instance.state); if (current.ready) this.sendState(current, instance.state); return; } const frame = document.createElement("iframe"); frame.className = "isolated-surface-frame"; frame.title = `LineUp Surface ${instance.app_id}`; frame.setAttribute("sandbox", "allow-scripts"); frame.setAttribute("referrerpolicy", "no-referrer"); frame.setAttribute("aria-label", "受限扩展界面"); // srcdoc comes only from this module's static source. No Envelope field, // manifest field, Agent content or remote response can alter it. frame.srcdoc = surfaceDocument(instance.instance_id); const hosted: HostedSurface = { instance, frame, ready: false, cancelSent: false }; this.surfaces.set(instance.instance_id, hosted); this.mountPoint.append(frame); } public update(instance: SurfaceInstance): void { const hosted = this.surfaces.get(instance.instance_id); if (!hosted || hosted.instance.app_id !== instance.app_id || hosted.instance.version !== instance.version) return; if (hosted.ready) this.sendState(hosted, instance.state); } public unmount(instanceID: string): void { const hosted = this.surfaces.get(instanceID); if (!hosted) return; this.surfaces.delete(instanceID); hosted.frame.remove(); } public destroy(): void { window.removeEventListener("message", this.receiveMessage); for (const instanceID of [...this.surfaces.keys()]) this.unmount(instanceID); } private readonly receiveMessage = (event: MessageEvent): void => { const message = parseBridgeMessage(event.data); if (!message) return; const hosted = this.surfaces.get(message.instance_id); // An opaque sandbox needs targetOrigin="*" to receive a postMessage, so // the source Window identity and instance id are mandatory checks here. if (!hosted || event.source !== hosted.frame.contentWindow) return; if (message.type === READY_TYPE) { if (hosted.ready) return; hosted.ready = true; this.sendState(hosted, hosted.instance.state); this.callbacks.ready(hosted.instance.instance_id); return; } if (!hosted.ready || hosted.cancelSent) return; hosted.cancelSent = true; this.callbacks.event(hosted.instance.instance_id, "cancel"); }; private sendState(hosted: HostedSurface, state: JsonObject): void { // The sandbox has an opaque origin; Window identity was captured at mount // and all incoming traffic is checked above. No privileged data is sent. hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: STATE_TYPE, instance_id: hosted.instance.instance_id, state }, "*"); } } type SurfaceReadyMessage = Readonly<{ v: 1; type: typeof READY_TYPE; instance_id: string }>; type SurfaceEventMessage = Readonly<{ v: 1; type: "lineup.surface.v1.event"; instance_id: string; event: "cancel" }>; export function parseReadyMessage(value: unknown): SurfaceReadyMessage | undefined { const parsed = parseBridgeMessage(value); return parsed?.type === READY_TYPE ? parsed : undefined; } function parseBridgeMessage(value: unknown): SurfaceReadyMessage | SurfaceEventMessage | undefined { if (!isPlainObject(value) || value.v !== BRIDGE_VERSION || (value.type !== READY_TYPE && value.type !== "lineup.surface.v1.event") || typeof value.instance_id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(value.instance_id)) return undefined; if (value.type === READY_TYPE && Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id")) return { v: 1, type: READY_TYPE, instance_id: value.instance_id }; if (value.type === "lineup.surface.v1.event" && value.event === "cancel" && Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id" || key === "event")) return { v: 1, type: "lineup.surface.v1.event", instance_id: value.instance_id, event: "cancel" }; return undefined; } /** A static boot document for the future host-local Task Dashboard bundle. */ export function surfaceDocument(instanceID: string): string { const safeID = JSON.stringify(instanceID); return `

任务面板

等待状态

    `; } function isPlainObject(value: unknown): value is Record { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; }