1- import { useContext , useEffect , useMemo , useRef , useState } from 'react' ;
1+ import { useContext , useEffect , useRef , useState } from 'react' ;
22import type { IDockviewPanelProps } from 'dockview-react' ;
33import { 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' ;
48import { usePaneChrome } from './use-pane-chrome' ;
59import { 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+
1241export 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+ }
0 commit comments