1"use client";
2
3import type {
4 DesktopPreviewColorScheme,
5 PreviewViewportSetting,
6 ScopedThreadRef,
7} from "@t3tools/contracts";
8import { useShallow } from "zustand/react/shallow";
9import { useCallback, useEffect, useRef, useState } from "react";
10
11import { previewBridge } from "~/components/preview/previewBridge";
12import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
13import { useClientSettingsHydrated } from "~/hooks/useSettings";
14import { cn, isMacPlatform } from "~/lib/utils";
15
16import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
17import { useActiveBrowserRecordingTabIds } from "./browserRecording";
18import {
19 browserViewportSettingKey,
20 resolveBrowserViewportLayout,
21 resolveFittedBrowserViewport,
22} from "./browserViewportLayout";
23import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
24import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
25import { acquireDesktopTab, type AcquiredDesktopTab, withDesktopTab } from "./desktopTabLifetime";
26import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle";
27import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
28import { useBrowserViewportResize } from "./useBrowserViewportResize";
29import {
30 INITIAL_WEBVIEW_CRASH_RECOVERY_STATE,
31 planWebviewCrashRecovery,
32 type WebviewCrashRecoveryState,
33} from "./webviewCrashRecovery";
34
35interface ElectronWebview extends HTMLElement {
36 src: string;
37 partition: string;
38 preload?: string;
39 webpreferences?: string;
40 getWebContentsId: () => number;
41 executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>;
42}
43
44declare global {
45 interface HTMLElementTagNameMap {
46 webview: ElectronWebview;
47 }
48}
49
50export function HostedBrowserWebview(props: {
51 readonly threadRef: ScopedThreadRef;
52 readonly tabId: string;
53 readonly runtimeTabId: string;
54 readonly initialUrl: string | null;
55 readonly viewport: PreviewViewportSetting;
56 readonly pictureInPicture: boolean;
57 /**
58 * Fixed for the tab's lifetime: Electron only honours `partition` before the
59 * guest attaches, so a live change here would not move the tab anyway.
60 */
61 readonly profileId: string | undefined;
62 readonly zoomFactor: number;
63 /** A tab of the desktop's own server; the server drives this webview's page. */
64 readonly serverDriven?: boolean;
65 /**
66 * For a server-driven tab, the appearance and zoom its environment published,
67 * which this webview follows so every client and agent sees one state.
68 */
69 readonly serverRendering?: {
70 readonly colorScheme: DesktopPreviewColorScheme;
71 readonly zoomFactor: number;
72 };
73}) {
74 const {
75 threadRef,
76 tabId,
77 runtimeTabId,
78 initialUrl,
79 viewport,
80 pictureInPicture,
81 zoomFactor,
82 profileId,
83 serverDriven = false,
84 serverRendering,
85 } = props;
86 const clientSettingsHydrated = useClientSettingsHydrated();
87 const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);
88 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
89 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
90 const wrapperRef = useRef<HTMLDivElement | null>(null);
91 const webviewRef = useRef<ElectronWebview | null>(null);
92 const crashRecoveryRef = useRef<WebviewCrashRecoveryState>(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE);
93 const [aspectRatioLocked, setAspectRatioLocked] = useState(false);
94 const presentation = useBrowserSurfaceStore(
95 useShallow((state) => {
96 const current = state.byTabId[runtimeTabId];
97 return {
98 content: current?.content ?? null,
99 cornerRadius: current?.cornerRadius ?? 0,
100 fitSourceContent: current?.fitSourceContent ?? false,
101 fittedSourceContent: current?.fittedSourceContent ?? null,
102 rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId),
103 visible: current?.visible ?? false,
104 zIndex: current?.zIndex ?? 30,
105 };
106 }),
107 );
108 const backgroundActivity = useBrowserSurfaceStore(
109 (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0,
110 );
111 const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId);
112 usePreviewBridge({ threadRef, tabId, runtimeTabId, serverDriven });
113
114 const serverColorScheme = serverRendering?.colorScheme;
115 const serverZoomFactor = serverRendering?.zoomFactor;
116
117 useEffect(() => {
118 if (!clientSettingsHydrated) return;
119 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;
120 const lease = acquireDesktopTab(
121 runtimeTabId,
122 serverDriven ? { threadId: threadRef.threadId, tabId } : undefined,
123 );
124 tabLeaseRef.current = lease;
125 return () => {
126 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;
127 lease.release();
128 };
129 }, [clientSettingsHydrated, runtimeTabId, serverDriven, tabId, threadRef.threadId]);
130
131 // A server tab looks the way its environment published, once the desktop tab exists.
132 useEffect(() => {
133 const bridge = window.desktopBridge?.preview;
134 if (!bridge || serverColorScheme === undefined) return;
135 withDesktopTab(runtimeTabId, () => bridge.setColorScheme(runtimeTabId, serverColorScheme));
136 }, [runtimeTabId, serverColorScheme]);
137 useEffect(() => {
138 const bridge = window.desktopBridge?.preview;
139 if (!bridge || serverZoomFactor === undefined) return;
140 withDesktopTab(runtimeTabId, () => bridge.setZoomFactor(runtimeTabId, serverZoomFactor));
141 }, [runtimeTabId, serverZoomFactor]);
142
143 const [webviewGeneration, setWebviewGeneration] = useState(0);
144 const [recoverySrc, setRecoverySrc] = useState(initialSrc);
145 const latestUrlRef = useRef(initialUrl);
146
147 useEffect(() => {
148 latestUrlRef.current = initialUrl;
149 }, [initialUrl]);
150
151 const setWebviewRef = useCallback((node: HTMLElement | null) => {
152 webviewRef.current = node as ElectronWebview | null;
153 }, []);
154
155 useEffect(() => {
156 const webview = webviewRef.current;
157 const bridge = previewBridge;
158 if (!clientSettingsHydrated || !webview || !config || !bridge) return;
159 let disposed = false;
160 let recoveryTimeout: ReturnType<typeof setTimeout> | null = null;
161 const register = () => {
162 const lease = tabLeaseRef.current;
163 if (!lease) return;
164 void (async () => {
165 try {
166 // The main-process tab and the DOM webview are created by separate
167 // effects. Wait for the former so registration cannot race and fail
168 // with PreviewTabNotFoundError on a fast about:blank attachment.
169 await lease.ready;
170 if (disposed || webviewRef.current !== webview) return;
171 const webContentsId = webview.getWebContentsId();
172 if (Number.isInteger(webContentsId) && webContentsId > 0) {
173 await bridge.registerWebview(runtimeTabId, webContentsId);
174 }
175 } catch {
176 // did-attach/dom-ready will retry if the guest was not ready yet.
177 }
178 })();
179 };
180 const recoverGuest = () => {
181 if (disposed || recoveryTimeout !== null) return;
182 const recovery = planWebviewCrashRecovery(crashRecoveryRef.current, Date.now());
183 if (!recovery) return;
184 crashRecoveryRef.current = recovery.state;
185 recoveryTimeout = setTimeout(() => {
186 recoveryTimeout = null;
187 if (!disposed) {
188 setRecoverySrc(latestUrlRef.current ?? initialSrc);
189 setWebviewGeneration((generation) => generation + 1);
190 }
191 }, recovery.delayMs);
192 };
193 // A click inside the guest only reaches this document as a webview focus
194 // event, so open menus and popovers never see the outside press that
195 // would dismiss them. Replay it as a pointerdown on the webview itself.
196 const dismissHostPopups = () => {
197 webview.dispatchEvent(
198 new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }),
199 );
200 };
201 webview.addEventListener("did-attach", register);
202 webview.addEventListener("dom-ready", register);
203 webview.addEventListener("render-process-gone", recoverGuest);
204 webview.addEventListener("focus", dismissHostPopups);
205 register();
206 return () => {
207 disposed = true;
208 if (recoveryTimeout !== null) clearTimeout(recoveryTimeout);
209 webview.removeEventListener("did-attach", register);
210 webview.removeEventListener("dom-ready", register);
211 webview.removeEventListener("render-process-gone", recoverGuest);
212 webview.removeEventListener("focus", dismissHostPopups);
213 };
214 }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]);
215
216 const active = presentation.visible && presentation.rect !== null;
217 const lastRect = presentation.rect;
218 const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
219 const viewportWidth = viewport._tag === "fill" ? null : viewport.width;
220 const viewportHeight = viewport._tag === "fill" ? null : viewport.height;
221 const viewportAspectRatio =
222 viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight;
223 const lockedAspectRatio =
224 aspectRatioLocked && viewportAspectRatio !== null ? viewportAspectRatio : null;
225 const handleAspectRatioChange = useCallback((aspectRatio: number | null) => {
226 setAspectRatioLocked(aspectRatio !== null);
227 }, []);
228 const hiddenContentSize = presentation.content
229 ? {
230 width: presentation.content.width / presentation.content.scale,
231 height: presentation.content.height / presentation.content.scale,
232 }
233 : null;
234 const hiddenSize =
235 viewport._tag !== "fill"
236 ? {
237 width: viewport.width * normalizedZoomFactor,
238 height: viewport.height * normalizedZoomFactor,
239 }
240 : {
241 width: hiddenContentSize?.width ?? lastRect?.width ?? 1280,
242 height: hiddenContentSize?.height ?? lastRect?.height ?? 800,
243 };
244 const containerSize = active && lastRect ? lastRect : hiddenSize;
245 const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent;
246 const {
247 activeDrag,
248 commitViewportChange,
249 effectiveViewport,
250 handleResizeKeyDown,
251 handleResizePointerDown,
252 layout: viewportLayout,
253 } = useBrowserViewportResize({
254 tabId: runtimeTabId,
255 viewport,
256 zoomFactor,
257 containerSize,
258 deviceToolbarVisible,
259 aspectRatio: lockedAspectRatio,
260 });
261 const fittedSourceViewport =
262 presentation.fitSourceContent && lastRect
263 ? resolveFittedBrowserViewport(
264 viewport,
265 presentation.fittedSourceContent,
266 normalizedZoomFactor,
267 )
268 : null;
269 const layout =
270 fittedSourceViewport && lastRect
271 ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, normalizedZoomFactor)
272 : viewportLayout;
273
274 const syncContentPresentation = useCallback(() => {
275 const wrapper = wrapperRef.current;
276 if (!wrapper) return;
277 useBrowserSurfaceStore.getState().presentContent(runtimeTabId, {
278 x: layout.viewportX,
279 y: layout.viewportY,
280 width: layout.viewportWidth,
281 height: layout.viewportHeight,
282 scale: layout.viewportScale,
283 scrollLeft: wrapper.scrollLeft,
284 scrollTop: wrapper.scrollTop,
285 });
286 }, [layout, runtimeTabId]);
287
288 useEffect(() => {
289 const frameId = window.requestAnimationFrame(syncContentPresentation);
290 return () => window.cancelAnimationFrame(frameId);
291 }, [syncContentPresentation]);
292
293 useEffect(() => {
294 const wrapper = wrapperRef.current;
295 if (!wrapper) return;
296 wrapper.scrollTo({ left: 0, top: 0 });
297 }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]);
298
299 if (!clientSettingsHydrated || !config) return null;
300
301 const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;
302 const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
303 active,
304 renderingActive,
305 // Electron 43 can permanently blank a macOS webview after `visibility: hidden`.
306 // Inactive macOS guests intentionally remain paintable offscreen; other platforms still
307 // suspend them, and automation continues to see the macOS guests as inactive.
308 keepPaintableWhenInactive: isMacPlatform(navigator.platform),
309 cornerRadius: presentation.cornerRadius,
310 zIndex: presentation.zIndex,
311 rect: lastRect,
312 hiddenSize,
313 });
314
315 return (
316 <div
317 ref={wrapperRef}
318 className="fixed overflow-hidden bg-muted/35"
319 style={{ ...wrapperStyle, overscrollBehavior: "contain" }}
320 onScroll={syncContentPresentation}
321 data-preview-rendering={renderingActive ? "active" : "suspended"}
322 data-preview-viewport={runtimeTabId}
323 >
324 <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>
325 {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? (
326 <BrowserDeviceToolbar
327 setting={effectiveViewport}
328 width={Math.max(1, Math.round(containerSize.width))}
329 aspectRatio={lockedAspectRatio}
330 onAspectRatioChange={handleAspectRatioChange}
331 onChange={commitViewportChange}
332 />
333 ) : null}
334 <webview
335 key={webviewGeneration}
336 ref={setWebviewRef}
337 // Must be an attribute on the element itself: Electron reads it when the
338 // guest attaches, so setting it from the ref callback lands too late and
339 // the guest attaches with popups disabled. React types `allowpopups` as a
340 // boolean, but react-dom drops boolean values for unrecognized attributes,
341 // so the literal string has to be spread past the type.
342 {...({ allowpopups: "true" } as unknown as { readonly allowpopups?: boolean })}
343 src={webviewGeneration === 0 ? initialSrc : recoverySrc}
344 partition={config.partition}
345 webpreferences={config.webPreferences}
346 {...(config.preloadUrl ? { preload: config.preloadUrl } : {})}
347 data-preview-tab={runtimeTabId}
348 data-preview-server-tab={tabId}
349 data-preview-viewport-mode={effectiveViewport._tag}
350 data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)}
351 data-preview-css-width={
352 fittedSourceViewport
353 ? fittedSourceViewport.width
354 : effectiveViewport._tag === "fill"
355 ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor))
356 : effectiveViewport.width
357 }
358 data-preview-css-height={
359 fittedSourceViewport
360 ? fittedSourceViewport.height
361 : effectiveViewport._tag === "fill"
362 ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor))
363 : effectiveViewport.height
364 }
365 aria-hidden={active ? undefined : true}
366 className={cn(
367 "absolute flex overflow-hidden bg-white",
368 active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm",
369 )}
370 style={{
371 left: layout.viewportX,
372 top: layout.viewportY,
373 width: layout.viewportWidth / layout.viewportScale,
374 height: layout.viewportHeight / layout.viewportScale,
375 transform: layout.viewportScale < 1 ? `scale(${layout.viewportScale})` : undefined,
376 transformOrigin: "top left",
377 }}
378 />
379 {active && effectiveViewport._tag !== "fill" && !fittedSourceViewport ? (
380 <>
381 <BrowserViewportResizeHandles
382 layout={layout}
383 activeDirection={activeDrag?.direction ?? null}
384 onPointerDown={handleResizePointerDown}
385 onKeyDown={handleResizeKeyDown}
386 />
387 {activeDrag ? (
388 <div
389 className="pointer-events-none absolute z-40 -translate-x-1/2 rounded-md border border-border/80 bg-background/95 px-2 py-1 text-2xs font-medium tabular-nums text-foreground shadow-md backdrop-blur-sm"
390 style={{
391 left: layout.viewportX + layout.viewportWidth / 2,
392 top: layout.viewportY + 10,
393 }}
394 aria-hidden="true"
395 >
396 {activeDrag.width} × {activeDrag.height}
397 </div>
398 ) : null}
399 </>
400 ) : null}
401 </div>
402 </div>
403 );
404}