/** * Host Bridge Client Script * * Runs inside the headless page and re-points the capability APIs that a real * browser answers from the device — geolocation, camera/microphone, screen * capture, speech recognition, clipboard, notifications — at the *viewer's* * browser instead. Headless Chrome has none of those, so without this every one * of them fails outright and the preview stops behaving like a browser the * moment a page asks for anything real. * * Also stands in for the Fullscreen API. Real fullscreen collapses the surface * Chrome composites to the browser *window* and leaves it there, so the preview * comes back cropped and zoomed with no way out — the exit affordance lives in * browser chrome that does not exist here. A CSS-based fullscreen leaves the * captured surface alone and stays exitable, and * the requests Chrome makes on the page's behalf — the button on its own media * controls, which is C++ and never touches these patches — are caught after the * fact and converted. Picture-in-Picture, which has no such equivalent, is * reported as unavailable instead of dropping the video into a window nobody can * see. * * Runs in **every** frame. Cross-origin frames have no binding of their own, so * they relay through the top frame over `postMessage`, keyed by the same * randomised name the binding uses. */ export function hostBridgeScript(bindingName: string) { const scope = window as unknown as Record; if (scope.__clopenHostBridgeReady) return; scope.__clopenHostBridgeReady = true; const RELAY_KEY = `${bindingName}R`; const EVENT_HOOK = `${bindingName}E`; interface BridgeError { name?: string; message?: string; code?: number; } interface BridgeResponse { ok: boolean; result?: unknown; error?: BridgeError; } function namedError(name: string, message: string, code?: number): Error { const error = new Error(message); error.name = name; if (typeof code === 'number') { (error as Error & { code?: number }).code = code; } return error; } function unwrap(response: BridgeResponse): unknown { if (!response.ok) { throw namedError( response.error?.name || 'NotAllowedError', response.error?.message || 'Permission denied', response.error?.code ); } return response.result; } // ── Transport ─────────────────────────────────────────────────────────── const relayWaiters = new Map void>(); let relaySeq = 0; /** * Round-trip one request to the viewer. * * The binding only exists where Chrome installed it — the main frame and any * same-process iframe. Everything else (a cross-origin embed, which is most * of them under site isolation) hops through the top frame instead, which is * why an embedded page could not open a picker or ask for a camera. */ function call(kind: string, payload?: unknown): Promise { const binding = scope[bindingName] as ((raw: string) => Promise) | undefined; if (typeof binding === 'function') { return binding(JSON.stringify({ kind, payload })).then((raw: string) => unwrap(JSON.parse(raw) as BridgeResponse) ); } if (window === window.top) { return Promise.reject(namedError('NotSupportedError', 'Preview host bridge is unavailable')); } relaySeq += 1; const id = `${relaySeq}`; return new Promise((resolve, reject) => { relayWaiters.set(id, (response) => { try { resolve(unwrap(response)); } catch (error) { reject(error); } }); try { window.top?.postMessage({ [RELAY_KEY]: { id, kind, payload } }, '*'); } catch { relayWaiters.delete(id); reject(namedError('NotSupportedError', 'Preview host bridge is unreachable')); } setTimeout(() => { if (!relayWaiters.delete(id)) return; reject(namedError('AbortError', 'Preview host bridge timed out')); }, 180000); }); } window.addEventListener('message', (event: MessageEvent) => { const data = event.data as Record | null; if (!data || typeof data !== 'object') return; // Top frame: forward a child's request and post the answer back. const request = data[RELAY_KEY]; if (request && window === window.top) { const binding = scope[bindingName] as ((raw: string) => Promise) | undefined; const source = event.source as WindowProxy | null; if (!source) return; const reply = (response: BridgeResponse) => { try { source.postMessage({ [`${RELAY_KEY}Reply`]: { id: request.id, response } }, '*'); } catch { // Frame went away mid-flight. } }; if (typeof binding !== 'function') { reply({ ok: false, error: { name: 'NotSupportedError', message: 'Bridge unavailable' } }); return; } binding(JSON.stringify({ kind: request.kind, payload: request.payload })) .then((raw: string) => reply(JSON.parse(raw) as BridgeResponse)) .catch((error: Error) => reply({ ok: false, error: { name: error.name, message: error.message } })); return; } // Child frame: settle the promise the relay is holding. const replyPayload = data[`${RELAY_KEY}Reply`]; if (replyPayload) { const waiter = relayWaiters.get(replyPayload.id); if (waiter) { relayWaiters.delete(replyPayload.id); waiter(replyPayload.response); } return; } // Top frame broadcasts host events down to children. const forwarded = data[`${RELAY_KEY}Event`]; if (forwarded) { deliverEvent(forwarded.kind, forwarded.payload, false); } }); // ── Host → page events ────────────────────────────────────────────────── // // The request/response channel cannot carry a stream, and speech recognition // is a stream: results keep arriving until the page stops listening. const eventListeners = new Map void>>(); function onHostEvent(kind: string, listener: (payload: any) => void): () => void { let listeners = eventListeners.get(kind); if (!listeners) { listeners = new Set(); eventListeners.set(kind, listeners); } listeners.add(listener); return () => listeners?.delete(listener); } function deliverEvent(kind: string, payload: unknown, broadcast: boolean): void { const listeners = eventListeners.get(kind); if (listeners) { for (const listener of Array.from(listeners)) { try { listener(payload); } catch { // A page listener throwing must not stop the rest. } } } // Only the top frame receives the injected call, so it repeats the event // to every child — the frame that asked may be several levels down. if (!broadcast) return; for (let i = 0; i < window.frames.length; i += 1) { try { window.frames[i].postMessage({ [`${RELAY_KEY}Event`]: { kind, payload } }, '*'); } catch { // Cross-origin child that refused the post; nothing to do. } } } // Entry point the backend calls into with `page.evaluate`. scope[EVENT_HOOK] = (raw: string) => { try { const parsed = JSON.parse(raw) as { kind: string; payload: unknown }; deliverEvent(parsed.kind, parsed.payload, true); } catch { // Malformed event — ignore. } }; /** * Wrap a replacement so it reports itself as the built-in it stands in for. * Bot-detection scripts read `Function.prototype.toString` far more often * than they probe behaviour. */ function nativeLike unknown>(fn: T, name: string): T { try { Object.defineProperty(fn, 'name', { value: name, configurable: true }); return new Proxy(fn, { get(target, prop, receiver) { if (prop === 'toString') { return function toString() { return `function ${name}() { [native code] }`; }; } return Reflect.get(target, prop, receiver); } }) as T; } catch { return fn; } } function define(target: object, key: string, value: unknown): void { try { Object.defineProperty(target, key, { value, configurable: true, writable: true }); } catch { try { (target as Record)[key] = value; } catch { // Frozen prototype — leave the original in place. } } } // ── Geolocation ───────────────────────────────────────────────────────── // Answered by the viewer's device, so the page sees the location of the // person actually looking at it rather than the server's datacentre. const geolocation = navigator.geolocation; if (geolocation) { const watchers = new Map>(); let watchSeq = 0; // Fixed cadence rather than honouring `maximumAge`: each poll may surface // a permission prompt on the viewer, so a page asking for 100ms updates // must not be able to drive that. const WATCH_INTERVAL_MS = 15000; const toPosition = (raw: Record) => ({ coords: { latitude: raw.latitude, longitude: raw.longitude, accuracy: raw.accuracy, altitude: raw.altitude ?? null, altitudeAccuracy: raw.altitudeAccuracy ?? null, heading: raw.heading ?? null, speed: raw.speed ?? null }, timestamp: raw.timestamp || Date.now() }); const toPositionError = (error: Error & { code?: number }) => ({ code: typeof error.code === 'number' ? error.code : 1, message: error.message || 'User denied Geolocation', PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3 }); const requestPosition = ( success?: (position: unknown) => void, failure?: (error: unknown) => void, options?: PositionOptions ) => { call('geolocation', { enableHighAccuracy: !!options?.enableHighAccuracy, timeout: options?.timeout, maximumAge: options?.maximumAge }) .then((raw) => { if (typeof success === 'function') success(toPosition(raw)); }) .catch((error: Error & { code?: number }) => { if (typeof failure === 'function') failure(toPositionError(error)); }); }; define( geolocation, 'getCurrentPosition', nativeLike(function getCurrentPosition( success?: (position: unknown) => void, failure?: (error: unknown) => void, options?: PositionOptions ) { requestPosition(success, failure, options); } as never, 'getCurrentPosition') ); define( geolocation, 'watchPosition', nativeLike(function watchPosition( success?: (position: unknown) => void, failure?: (error: unknown) => void, options?: PositionOptions ) { watchSeq += 1; const id = watchSeq; requestPosition(success, failure, options); watchers.set(id, setInterval(() => requestPosition(success, failure, options), WATCH_INTERVAL_MS)); return id; } as never, 'watchPosition') ); define( geolocation, 'clearWatch', nativeLike(function clearWatch(id: number) { const handle = watchers.get(id); if (handle !== undefined) { clearInterval(handle); watchers.delete(id); } } as never, 'clearWatch') ); } // ── Camera / microphone / screen ──────────────────────────────────────── const mediaDevices = navigator.mediaDevices; if (mediaDevices) { const ICE_SERVERS = [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ]; /** Resolve once ICE gathering settles, or give up and send what we have. */ function waitForIce(pc: RTCPeerConnection): Promise { if (pc.iceGatheringState === 'complete') return Promise.resolve(); return new Promise((resolve) => { let settled = false; const finish = () => { if (settled) return; settled = true; pc.removeEventListener('icegatheringstatechange', onChange); resolve(); }; const onChange = () => { if (pc.iceGatheringState === 'complete') finish(); }; pc.addEventListener('icegatheringstatechange', onChange); // Non-trickle signalling: a slow or unreachable STUN server must // not stall the request forever, host candidates alone are enough // whenever the viewer and the server share a network. setTimeout(finish, 2500); }); } async function requestHostMedia( constraints: MediaStreamConstraints, display: boolean ): Promise { const wantsVideo = display ? true : !!constraints?.video; const wantsAudio = !!constraints?.audio; if (!wantsVideo && !wantsAudio) { throw namedError('TypeError', 'At least one of audio and video must be requested'); } const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); const stream = new MediaStream(); const expectedTracks = (wantsVideo ? 1 : 0) + (wantsAudio ? 1 : 0); let markReady: () => void = () => {}; const tracksReady = new Promise((resolve) => { markReady = resolve; }); pc.ontrack = (event) => { stream.addTrack(event.track); if (stream.getTracks().length >= expectedTracks) markReady(); }; if (wantsAudio) pc.addTransceiver('audio', { direction: 'recvonly' }); if (wantsVideo) pc.addTransceiver('video', { direction: 'recvonly' }); let sessionId = ''; try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); await waitForIce(pc); const answer = await call('media-request', { video: wantsVideo, audio: wantsAudio, display, constraints: JSON.parse(JSON.stringify(constraints ?? {})), sdp: pc.localDescription?.sdp || '' }); sessionId = answer.sessionId; await pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }); await Promise.race([ tracksReady, new Promise((_, reject) => setTimeout(() => reject(namedError('NotReadableError', 'Timed out waiting for host media')), 15000) ) ]); } catch (error) { try { pc.close(); } catch { // Already closed. } if (sessionId) call('media-stop', { sessionId }).catch(() => {}); throw error; } // Tear the relay down when the page releases the last track, the way // a real capture releases the device. let released = false; const releaseIfDone = () => { if (released) return; if (!stream.getTracks().every((track) => track.readyState === 'ended')) return; released = true; try { pc.close(); } catch { // Already closed. } call('media-stop', { sessionId }).catch(() => {}); }; for (const track of stream.getTracks()) { const nativeStop = track.stop.bind(track); define( track, 'stop', nativeLike(function stop() { nativeStop(); releaseIfDone(); } as never, 'stop') ); track.addEventListener('ended', releaseIfDone); } return stream; } define( mediaDevices, 'getUserMedia', nativeLike(function getUserMedia(constraints: MediaStreamConstraints) { return requestHostMedia(constraints, false); } as never, 'getUserMedia') ); // The preview's own capture pipeline calls getDisplayMedia to grab this // tab's compositor output. Keep the real implementation reachable under a // private name *before* replacing it — routing that internal call at the // viewer would ask for their camera on every single page load. const nativeGetDisplayMedia = mediaDevices.getDisplayMedia?.bind(mediaDevices); if (nativeGetDisplayMedia) { scope.__clopenNativeGetDisplayMedia = nativeGetDisplayMedia; } // Screen sharing is the same relay with a different source on the viewer's // side, so a page that offers "share your screen" works end to end. define( mediaDevices, 'getDisplayMedia', nativeLike(function getDisplayMedia(constraints: MediaStreamConstraints) { return requestHostMedia(constraints ?? { video: true }, true); } as never, 'getDisplayMedia') ); const originalEnumerateDevices = mediaDevices.enumerateDevices?.bind(mediaDevices); define( mediaDevices, 'enumerateDevices', nativeLike(function enumerateDevices() { return call('media-devices') .then((devices: MediaDeviceInfo[]) => devices.map((device) => ({ deviceId: device.deviceId, groupId: device.groupId, kind: device.kind, label: device.label, toJSON() { return device; } })) ) .catch(() => (originalEnumerateDevices ? originalEnumerateDevices() : [])); } as never, 'enumerateDevices') ); } // ── Speech recognition ────────────────────────────────────────────────── // Headless Chrome ships no speech engine, so the API exists and always fails // with `not-allowed`. The viewer's browser has both the engine and the // microphone permission, so recognition runs there and streams back. { let sessionSeq = 0; class HostSpeechRecognition extends EventTarget { lang = ''; continuous = false; interimResults = false; maxAlternatives = 1; onresult: ((event: Event) => void) | null = null; onerror: ((event: Event) => void) | null = null; onend: ((event: Event) => void) | null = null; onstart: ((event: Event) => void) | null = null; onaudiostart: ((event: Event) => void) | null = null; onspeechstart: ((event: Event) => void) | null = null; #sessionId = ''; #unsubscribe: Array<() => void> = []; #running = false; #emit(type: string, init: Record = {}) { const event = new Event(type) as Event & Record; Object.assign(event, init); this.dispatchEvent(event); const handler = (this as unknown as Record void) | null>)[`on${type}`]; if (typeof handler === 'function') handler(event); } #teardown() { for (const off of this.#unsubscribe) off(); this.#unsubscribe = []; this.#running = false; } start() { if (this.#running) { throw namedError('InvalidStateError', 'recognition has already started'); } sessionSeq += 1; this.#sessionId = `speech-${sessionSeq}-${Date.now()}`; this.#running = true; const matches = (payload: any) => payload?.sessionId === this.#sessionId; this.#unsubscribe.push( onHostEvent('speech-result', (payload) => { if (!matches(payload)) return; // SpeechRecognitionEvent shape: results is an indexed, // array-like collection of alternatives with a final flag. const results = (payload.results ?? []).map((entry: any) => { const alternatives = (entry.alternatives ?? []).map((alt: any) => ({ transcript: alt.transcript ?? '', confidence: alt.confidence ?? 0 })); return Object.assign(alternatives, { isFinal: !!entry.isFinal, length: alternatives.length, item: (index: number) => alternatives[index] }); }); this.#emit('result', { resultIndex: payload.resultIndex ?? 0, results: Object.assign(results, { length: results.length, item: (index: number) => results[index] }) }); }) ); this.#unsubscribe.push( onHostEvent('speech-error', (payload) => { if (!matches(payload)) return; this.#emit('error', { error: payload.error || 'aborted', message: payload.message || '' }); }) ); this.#unsubscribe.push( onHostEvent('speech-end', (payload) => { if (!matches(payload)) return; this.#teardown(); this.#emit('end'); }) ); call('speech-start', { sessionId: this.#sessionId, lang: this.lang, continuous: this.continuous, interimResults: this.interimResults, maxAlternatives: this.maxAlternatives }) .then(() => { this.#emit('start'); this.#emit('audiostart'); }) .catch((error: Error) => { this.#teardown(); this.#emit('error', { error: error.name === 'NotAllowedError' ? 'not-allowed' : 'service-not-allowed', message: error.message }); this.#emit('end'); }); } stop() { if (!this.#running) return; call('speech-stop', { sessionId: this.#sessionId, abort: false }).catch(() => {}); } abort() { if (!this.#running) return; call('speech-stop', { sessionId: this.#sessionId, abort: true }).catch(() => {}); this.#teardown(); this.#emit('end'); } } try { Object.defineProperty(HostSpeechRecognition, 'name', { value: 'SpeechRecognition', configurable: true }); define(scope, 'SpeechRecognition', HostSpeechRecognition); define(scope, 'webkitSpeechRecognition', HostSpeechRecognition); } catch { // Locked down — leave the (non-functional) original in place. } } // ── Fullscreen ────────────────────────────────────────────────────────── // Chrome's real fullscreen resizes the surface it composites down to the // browser window, and leaves it there once the fullscreen ends. The page goes // on being laid out against the emulated viewport the whole time, so what the // preview captures is that layout seen through a window-sized hole: zoomed, // cropped at the right and the bottom, and stuck that way until a reload, // because nothing in the page ever noticed. A CSS fullscreen gives the page // what it asked for and leaves the surface alone. // // Which means *every* spelling has to be covered, not just the standard one. // A single unpatched entry point — `video.webkitEnterFullscreen()`, which // media players still reach for — hands the renderer straight to Chrome, and // that is exactly the cropped state that then survives the exit. { const STYLE_ID = '__clopen-fullscreen-style'; const MAX_Z = 2147483647; let fullscreenElement: Element | null = null; // Captured before the patches further down shadow them. Every replacement // below is installed as an *own* property of `document`, while the real // `fullscreenElement` and `exitFullscreen` live on `Document.prototype` — so // the originals stay reachable, and reading them is the only way to tell an // actual browser fullscreen from the one this shim fakes. const readNativeElement = ((): (() => Element | null) => { for (const key of ['fullscreenElement', 'webkitFullscreenElement']) { const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, key); if (descriptor?.get) return descriptor.get.bind(document) as () => Element | null; } return () => null; })(); const nativeExit = ((): (() => unknown) | null => { const target = document as Document & { webkitExitFullscreen?: () => unknown }; const fn = target.exitFullscreen || target.webkitExitFullscreen; return typeof fn === 'function' ? fn.bind(document) : null; })(); const ensureStyle = () => { if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; // `100%` of the fixed containing block, not `100vw`/`100vh`: viewport // units include the scrollbar gutter, so the element overhung the // visible area by however wide that is. // // The black background is scoped to replaced content, which is the only // thing `object-fit: contain` letterboxes and therefore the only place // bars need filling. Applied to everything, it repainted a fullscreened // container black over whatever background the page had given it. style.textContent = ` [data-clopen-fullscreen] { position: fixed !important; inset: 0 !important; width: 100% !important; height: 100% !important; max-width: none !important; max-height: none !important; min-width: 0 !important; min-height: 0 !important; margin: 0 !important; z-index: ${MAX_Z} !important; } video[data-clopen-fullscreen], img[data-clopen-fullscreen], canvas[data-clopen-fullscreen] { object-fit: contain !important; background: #000 !important; } html.clopen-fullscreen-active, body.clopen-fullscreen-active { overflow: hidden !important; } `; (document.head || document.documentElement).appendChild(style); }; // No in-page exit affordance is drawn any more. There used to be an // "Exit full screen (Esc)" badge here, which put two buttons saying the // same thing over the same frame — the page's, painted into the captured // picture, and the app's, drawn over it. Only one can be the way out, and // the app-side control is the one that survives a page re-render, reaches // every viewer, and still works when Chrome granted a fullscreen from its // own C++ that this shim never saw. All the page draws now is the style // rule that fakes the fullscreen in the first place. /** * Chrome fires this *on the element*; listeners on `document` only see it * because it bubbles. Dispatching it on `document` alone therefore never * reached a player that listens on its own container — which left the page * convinced it was still fullscreen after the exit, laying out its content * for a viewport it no longer had. */ const notify = (target: EventTarget | null) => { const on = target && (target as Node).isConnected ? target : document; for (const type of ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange']) { on.dispatchEvent(new Event(type, { bubbles: true, composed: true })); } }; const clearMarks = (element: Element | null) => { if (!element) return; element.removeAttribute('data-clopen-fullscreen'); }; /** * Tell the host whether this page is showing something full screen. * * The in-page exit affordance is drawn by the page, which means the page * can lose it — a re-render that empties the body, a stacking context * that buries it, a fullscreen Chrome granted from C++ that this shim * never saw. The viewer needs its own way out, and it can only offer one * if it knows. Fire-and-forget: a frame with no route to the host still * has a working CSS fullscreen, it just cannot be rescued from outside. */ const reportState = (active: boolean) => { void call('fullscreen-state', { active }).catch(() => {}); }; const enter = (element: Element) => { ensureStyle(); if (fullscreenElement && fullscreenElement !== element) clearMarks(fullscreenElement); const wasActive = fullscreenElement !== null; fullscreenElement = element; // The root and the body already fill the viewport; pinning them as // fixed boxes only breaks their own layout. if (element !== document.documentElement && element !== document.body) { element.setAttribute('data-clopen-fullscreen', ''); } document.documentElement.classList.add('clopen-fullscreen-active'); document.body?.classList.add('clopen-fullscreen-active'); notify(element); if (!wasActive) reportState(true); return Promise.resolve(); }; const exit = () => { const previous = fullscreenElement; if (!previous) return Promise.resolve(); clearMarks(previous); fullscreenElement = null; document.documentElement.classList.remove('clopen-fullscreen-active'); document.body?.classList.remove('clopen-fullscreen-active'); notify(previous); reportState(false); return Promise.resolve(); }; /** * Leave full screen no matter how the page got there. * * `exit()` alone is not enough as a rescue: it returns immediately when * this shim holds no element, which is exactly the case that strands the * preview — Chrome granted a fullscreen from its own C++ (a `