Skip to content

Commit bd4139b

Browse files
authored
dor iframe: transparent proxy for the iframe surface (#142)
2 parents 818c920 + 2d0ee43 commit bd4139b

14 files changed

Lines changed: 955 additions & 265 deletions

docs/specs/dor-iframe.md

Lines changed: 211 additions & 216 deletions
Large diffs are not rendered by default.

lib/src/components/Wall.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,13 @@ export function Wall({
598598
}, []);
599599

600600
useEffect(() => {
601-
const handleBlur = () => clearSessionAttention();
601+
// An iframe surface taking focus blurs this window without backgrounding the
602+
// app (document.hasFocus() stays true). Only clear cross-session attention
603+
// on a real blur, else focusing an iframe wipes attention (spec → "#2").
604+
const handleBlur = () => {
605+
if (document.hasFocus()) return;
606+
clearSessionAttention();
607+
};
602608
window.addEventListener('blur', handleBlur);
603609
return () => window.removeEventListener('blur', handleBlur);
604610
}, []);
Lines changed: 152 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
1+
import { useContext, useEffect, useRef, useState } from 'react';
22
import type { IDockviewPanelProps } from 'dockview-react';
33
import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../design';
4+
import { getPlatform } from '../../lib/platform';
5+
import { registerProxyOrigin } from '../../lib/iframe-proxy-registry';
6+
import { registerSurfaceFocusHandle } from '../../lib/terminal-registry';
7+
import type { IframeProxyResult } from '../../lib/platform/types';
48
import { usePaneChrome } from './use-pane-chrome';
59
import { WallActionsContext } from './wall-context';
610

@@ -9,62 +13,171 @@ type IframePanelParams = {
913
url?: string;
1014
};
1115

16+
// Sandbox the proxied frame so a tool's `if (top !== self) top.location = …`
17+
// framebust cannot navigate the Wall away — allow-top-navigation is omitted on
18+
// purpose (docs/specs/dor-iframe.md → "Anti-framebust"). Everything else a local
19+
// dev tool needs is granted; allow-same-origin is safe here because the frame's
20+
// origin (the loopback proxy) is never same-origin with the host webview.
21+
const PROXY_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads';
22+
const IFRAME_ALLOW = 'autoplay; clipboard-read; clipboard-write; fullscreen; geolocation; microphone; camera';
23+
24+
type Resolution =
25+
| { kind: 'empty' }
26+
| { kind: 'resolving' }
27+
| { kind: 'proxied'; src: string; origin: string }
28+
// The host can't run a proxy (e.g. the web host) — keep the blind raw-iframe
29+
// fallback rather than hiding the surface (Open Decision #4).
30+
| { kind: 'raw'; src: string }
31+
| { kind: 'error'; reason: 'frame-refused' | 'unreachable' | 'scheme'; detail?: string };
32+
33+
function originOf(url: string): string {
34+
try {
35+
return new URL(url).origin;
36+
} catch {
37+
return '';
38+
}
39+
}
40+
1241
export function IframePanel({ api, params }: IDockviewPanelProps<IframePanelParams>) {
1342
const actions = useContext(WallActionsContext);
1443
const elRef = useRef<HTMLDivElement>(null);
44+
const iframeRef = useRef<HTMLIFrameElement>(null);
1545
usePaneChrome(api, elRef);
1646
const url = typeof params?.url === 'string' ? params.url : '';
17-
const origin = useMemo(() => {
18-
try {
19-
return url ? new URL(url).origin : '';
20-
} catch {
21-
return '';
47+
48+
// Ask the host to front the target with its transparent proxy. The returned
49+
// URL is a loopback origin that serves the page's bytes (instrumented for
50+
// loopback) so Dormouse — now the server — gets a keyboard side-channel, an
51+
// accurate focus model, and real error pages. Reachability/frame-refusal are
52+
// diagnosed by the proxy and shown as a served page inside the frame.
53+
const [resolution, setResolution] = useState<Resolution>(() => (url ? { kind: 'resolving' } : { kind: 'empty' }));
54+
useEffect(() => {
55+
if (!url) {
56+
setResolution({ kind: 'empty' });
57+
return;
2258
}
59+
const createProxy = getPlatform().createIframeProxyUrl;
60+
if (!createProxy) {
61+
setResolution({ kind: 'raw', src: url });
62+
return;
63+
}
64+
let cancelled = false;
65+
setResolution({ kind: 'resolving' });
66+
createProxy(url).then(
67+
(result: IframeProxyResult) => {
68+
if (cancelled) return;
69+
if (result.ok) setResolution({ kind: 'proxied', src: result.url, origin: originOf(result.url) });
70+
else setResolution({ kind: 'error', reason: result.reason, detail: result.detail });
71+
},
72+
() => {
73+
if (!cancelled) setResolution({ kind: 'error', reason: 'unreachable' });
74+
},
75+
);
76+
return () => { cancelled = true; };
2377
}, [url]);
2478

25-
// A cross-origin iframe never reports HTTP errors or CSP/X-Frame-Options
26-
// blocks to us — onError doesn't fire, and onLoad fires even for a blocked
27-
// frame. So we can't detect failure directly; instead we surface a hint if
28-
// the frame hasn't reported a load within a few seconds, which covers the
29-
// common dead ends (server down, wrong scheme, refused framing) without
30-
// hiding a slow-but-fine page once it loads.
31-
const [loaded, setLoaded] = useState(false);
32-
const [stalled, setStalled] = useState(false);
79+
// Trust postMessage from this frame's origin (validated by the Wall's
80+
// keyboard/focus channel) only while the proxied surface is live.
81+
const proxyOrigin = resolution.kind === 'proxied' ? resolution.origin : null;
3382
useEffect(() => {
34-
setLoaded(false);
35-
setStalled(false);
36-
if (!url) return;
37-
const timer = setTimeout(() => setStalled(true), 5000);
38-
return () => clearTimeout(timer);
39-
}, [url]);
83+
if (!proxyOrigin) return;
84+
return registerProxyOrigin(proxyOrigin);
85+
}, [proxyOrigin]);
86+
87+
// Register a focus handle so onClickPanel → enterTerminalMode can focus the
88+
// frame like any other surface (spec → "#3"). Focusing the element moves
89+
// keyboard focus into the frame; the shim then reports focus back to the Wall.
90+
useEffect(() => {
91+
if (resolution.kind !== 'proxied' && resolution.kind !== 'raw') return;
92+
return registerSurfaceFocusHandle(api.id, {
93+
focus: () => iframeRef.current?.focus(),
94+
blur: () => iframeRef.current?.blur(),
95+
});
96+
}, [api.id, resolution.kind]);
97+
98+
// Clicking *into* a cross-origin frame doesn't bubble a mousedown to the pane,
99+
// so the onMouseDown below never fires and the surface never enters
100+
// passthrough. Detect the frame taking focus (window blurs while our iframe
101+
// becomes activeElement, app still focused) and adopt it as entering the pane,
102+
// so mode/selection stay consistent and the leader chord can round-trip out.
103+
useEffect(() => {
104+
if (resolution.kind !== 'proxied' && resolution.kind !== 'raw') return;
105+
const onWindowBlur = () => {
106+
if (document.hasFocus() && document.activeElement === iframeRef.current) {
107+
actions.onClickPanel(api.id);
108+
}
109+
};
110+
window.addEventListener('blur', onWindowBlur);
111+
return () => window.removeEventListener('blur', onWindowBlur);
112+
}, [api.id, resolution.kind, actions]);
113+
114+
const src = resolution.kind === 'proxied' || resolution.kind === 'raw' ? resolution.src : '';
40115

41116
return (
42117
<div
43118
ref={elRef}
44119
className={`relative h-full w-full overflow-hidden bg-terminal-bg ${TERMINAL_BOTTOM_RADIUS_CLASS}`}
120+
// A cross-origin iframe is an out-of-process frame; Chromium maps pointer
121+
// events to it relative to its nearest compositing/containing ancestor.
122+
// Dockview's root (.dv-dockview) sets `contain: layout`, so without this
123+
// the frame's reference is that far-away root and clicks land offset by the
124+
// pane's distance from it. translateZ(0) gives this container its own layer
125+
// co-located with the frame, collapsing the offset to ~0. It's identity, so
126+
// getBoundingClientRect (overlay measurement) is unaffected.
127+
style={{ transform: 'translateZ(0)' }}
45128
onMouseDown={() => actions.onClickPanel(api.id)}
46129
>
47-
{url ? (
48-
<>
49-
<iframe
50-
className="block h-full w-full border-0 bg-white"
51-
src={url}
52-
title={api.title ?? url}
53-
allow="autoplay; clipboard-read; clipboard-write; fullscreen; geolocation; microphone; camera"
54-
referrerPolicy={origin ? 'strict-origin-when-cross-origin' : undefined}
55-
onLoad={() => setLoaded(true)}
56-
/>
57-
{!loaded && stalled && (
58-
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-terminal-bg/90 px-4 py-2 text-xs text-muted">
59-
Still loading <span className="font-semibold">{url}</span> — if it stays blank, the server may be down, on a different scheme (http vs https), or refusing to be embedded in a frame.
60-
</div>
61-
)}
62-
</>
130+
{src ? (
131+
<iframe
132+
ref={iframeRef}
133+
className="block h-full w-full border-0 bg-white"
134+
src={src}
135+
title={api.title ?? url}
136+
allow={IFRAME_ALLOW}
137+
{...(resolution.kind === 'proxied' ? { sandbox: PROXY_SANDBOX, 'data-dormouse-proxy': 'true' } : {})}
138+
referrerPolicy="strict-origin-when-cross-origin"
139+
/>
63140
) : (
64-
<div className="flex h-full w-full items-center justify-center bg-terminal-bg px-4 text-sm text-muted">
65-
No iframe URL was provided.
66-
</div>
141+
<PanelMessage resolution={resolution} url={url} />
67142
)}
68143
</div>
69144
);
70145
}
146+
147+
function PanelMessage({ resolution, url }: { resolution: Resolution; url: string }) {
148+
const base = 'flex h-full w-full items-center justify-center bg-terminal-bg px-6 text-center text-sm text-muted';
149+
150+
if (resolution.kind === 'resolving') {
151+
return <div className={base}>Connecting to <span className="ml-1 font-semibold">{url}</span></div>;
152+
}
153+
if (resolution.kind === 'empty') {
154+
return <div className={base}>No iframe URL was provided.</div>;
155+
}
156+
// proxied/raw render the iframe itself, never this fallback.
157+
if (resolution.kind !== 'error') return null;
158+
// 'error' — the proxy turned a dead end into something actionable. (Most
159+
// unreachable/frame-refused cases are served as a page inside the frame; this
160+
// covers the synchronous ones, chiefly an unproxyable scheme.)
161+
return (
162+
<div className={`${base} flex-col gap-2`}>
163+
<div>{messageFor(resolution)}</div>
164+
<div className="text-xs text-muted/80">
165+
For arbitrary web pages, use <code className="rounded bg-app-bg px-1 py-0.5">dor ab open {url}</code>
166+
</div>
167+
</div>
168+
);
169+
}
170+
171+
function messageFor(resolution: Extract<Resolution, { kind: 'error' }>): string {
172+
switch (resolution.reason) {
173+
case 'scheme':
174+
return resolution.detail
175+
? `Can’t frame this URL — ${resolution.detail}.`
176+
: 'The iframe surface only frames local http:// servers.';
177+
case 'frame-refused':
178+
return 'This page refuses to be embedded in a frame.';
179+
case 'unreachable':
180+
default:
181+
return resolution.detail ? `Couldn’t reach the server — ${resolution.detail}.` : 'Couldn’t reach the server.';
182+
}
183+
}

lib/src/components/wall/use-wall-keyboard.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { handleMouseSelectionKeys } from './keyboard/handle-mouse-selection-keys
44
import { handleKillConfirm } from './keyboard/handle-kill-confirm';
55
import { handlePaneShortcuts } from './keyboard/handle-pane-shortcuts';
66
import { handlePaneNavigation } from './keyboard/handle-pane-navigation';
7+
import { isProxyOrigin } from '../../lib/iframe-proxy-registry';
78
import type { NavHistoryRef, WallKeyboardCtx } from './keyboard/types';
89

910
export function useWallKeyboard(ctx: WallKeyboardCtx): void {
@@ -36,7 +37,24 @@ export function useWallKeyboard(ctx: WallKeyboardCtx): void {
3637
handlePaneNavigation(e, c, navHistory);
3738
};
3839

40+
// A focused cross-origin iframe owns the keyboard, so its keystrokes never
41+
// reach the capturing window listener above. The proxy shim posts our
42+
// reserved leader chord back out (docs/specs/dor-iframe.md → "The keyboard
43+
// side-channel"); feed it into the same dispatch the in-document dual-tap
44+
// would, after validating the message came from a live proxy origin.
45+
const onMessage = (e: MessageEvent) => {
46+
const data = e.data as { __dormouse?: unknown } | null;
47+
if (!data || data.__dormouse !== 'leader') return;
48+
if (!isProxyOrigin(e.origin)) return;
49+
const c = ctxRef.current;
50+
if (c.modeRef.current === 'passthrough') c.exitTerminalMode();
51+
};
52+
3953
window.addEventListener('keydown', handler, true);
40-
return () => window.removeEventListener('keydown', handler, true);
54+
window.addEventListener('message', onMessage);
55+
return () => {
56+
window.removeEventListener('keydown', handler, true);
57+
window.removeEventListener('message', onMessage);
58+
};
4159
}, []);
4260
}

lib/src/components/wall/use-window-focused.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ export function useWindowFocused(): boolean {
44
const [focused, setFocused] = useState(() => document.hasFocus());
55
useEffect(() => {
66
const onFocus = () => setFocused(true);
7-
const onBlur = () => setFocused(false);
7+
// Focusing one of our own iframe surfaces fires `blur` on this window even
8+
// though the app hasn't been backgrounded — the focused element is just an
9+
// <iframe> *inside* this document, so `document.hasFocus()` stays true.
10+
// Reading it instead of blindly setting false keeps headers/attention live
11+
// when an iframe takes focus (docs/specs/dor-iframe.md → "#2").
12+
const onBlur = () => setFocused(document.hasFocus());
813
window.addEventListener('focus', onFocus);
914
window.addEventListener('blur', onBlur);
1015
return () => {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Tracks the loopback proxy origins of live iframe surfaces so the Wall's
3+
* keyboard/focus channel can trust `postMessage` events from instrumented
4+
* frames (docs/specs/dor-iframe.md → "The keyboard side-channel"). The shim we
5+
* inject calls `parent.postMessage(...)`, which is cross-origin-safe by design;
6+
* the Wall validates `event.origin` against this set before acting on a
7+
* forwarded leader chord or focus/blur, so only a frame Dormouse itself served
8+
* can drive those paths.
9+
*
10+
* Reference-counted because a surface can briefly re-register the same origin
11+
* across a webview reload (mount of the new panel before unmount of the old).
12+
*/
13+
const proxyOriginCounts = new Map<string, number>();
14+
15+
export function registerProxyOrigin(origin: string): () => void {
16+
proxyOriginCounts.set(origin, (proxyOriginCounts.get(origin) ?? 0) + 1);
17+
let released = false;
18+
return () => {
19+
if (released) return;
20+
released = true;
21+
const next = (proxyOriginCounts.get(origin) ?? 1) - 1;
22+
if (next <= 0) proxyOriginCounts.delete(origin);
23+
else proxyOriginCounts.set(origin, next);
24+
};
25+
}
26+
27+
export function isProxyOrigin(origin: string): boolean {
28+
return proxyOriginCounts.has(origin);
29+
}

lib/src/lib/platform/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ export interface AgentBrowserEditResult {
7171
error?: string;
7272
}
7373

74+
/**
75+
* Result of asking the host to front a `dor iframe` target with its transparent
76+
* proxy (docs/specs/dor-iframe.md → "The Transparent Proxy"). On `ok` the panel
77+
* points the `<iframe>` at `url` — a loopback proxy origin that fetches the
78+
* target, strips frame-blocking headers (loopback only), and injects the
79+
* Dormouse shim. On failure `reason` says why there is nothing to frame:
80+
* `scheme` (not a proxyable `http://` upstream — e.g. an `https://` target,
81+
* which v1 defers), `unreachable` (nothing answered), or `frame-refused` (a
82+
* remote that forbids embedding — use `dor ab` instead). Reachability and
83+
* frame-refusal are normally diagnosed lazily and surfaced as a served error
84+
* *page* inside the frame, so v1 mostly returns `ok` or `scheme` here.
85+
*/
86+
export type IframeProxyResult =
87+
| { ok: true; url: string }
88+
| { ok: false; reason: 'frame-refused' | 'unreachable' | 'scheme'; detail?: string };
89+
7490
export interface PlatformAdapter {
7591
// Lifecycle
7692
init(): Promise<void>;
@@ -132,6 +148,13 @@ export interface PlatformAdapter {
132148
// URL; absent or null falls back to ws://127.0.0.1:<port>.
133149
getAgentBrowserStreamUrl?(port: number): Promise<string | null>;
134150

151+
// iframe surface support (see docs/specs/dor-iframe.md → "The Transparent
152+
// Proxy"). Stands up a loopback proxy in front of a `dor iframe` target and
153+
// returns the proxy URL the panel should frame, or a structured reason it
154+
// could not. Absent on hosts with no process to run a proxy (e.g. the web
155+
// host), where the panel falls back to a raw, uninstrumented `<iframe>`.
156+
createIframeProxyUrl?(targetUrl: string): Promise<IframeProxyResult>;
157+
135158
// PTY event listeners
136159
onPtyData(handler: (detail: { id: string; data: string }) => void): void;
137160
offPtyData(handler: (detail: { id: string; data: string }) => void): void;

lib/src/lib/platform/vscode-adapter.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserScreenshotResult, AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types';
1+
import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserScreenshotResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyInfo } from './types';
22
import { OPEN_PORT_TIMEOUT_MS } from './types';
33
import { setDefaultShellOpts } from '../shell-defaults';
44
import {
@@ -32,6 +32,7 @@ export class VSCodeAdapter implements PlatformAdapter {
3232
this.agentBrowserEdit = this.agentBrowserEdit.bind(this);
3333
this.agentBrowserScreenshot = this.agentBrowserScreenshot.bind(this);
3434
this.getAgentBrowserStreamUrl = this.getAgentBrowserStreamUrl.bind(this);
35+
this.createIframeProxyUrl = this.createIframeProxyUrl.bind(this);
3536

3637
// Seed the default shell from the extension-injected global so that
3738
// the first terminal on startup (which spawns synchronously on Wall
@@ -262,6 +263,18 @@ export class VSCodeAdapter implements PlatformAdapter {
262263
);
263264
}
264265

266+
async createIframeProxyUrl(url: string): Promise<IframeProxyResult> {
267+
// The extension host stands up the loopback proxy and serves the bytes (see
268+
// iframe-proxy-host.ts). On timeout, report unreachable so the panel shows a
269+
// hint rather than hanging on a never-loading frame.
270+
const result = await this.requestResponse<IframeProxyResult>(
271+
'iframe:createProxyUrl', 'iframe:proxyUrl', { url },
272+
(msg) => msg.result,
273+
5000,
274+
);
275+
return result ?? { ok: false, reason: 'unreachable', detail: 'iframe proxy request timed out' };
276+
}
277+
265278
onPtyData(handler: (detail: { id: string; data: string }) => void): void {
266279
this.dataHandlers.add(handler);
267280
}

0 commit comments

Comments
 (0)