1import type {
2 DesktopPreviewColorScheme,
3 PreviewViewportSetting,
4 ScopedThreadRef,
5} from "@t3tools/contracts";
6import { useShallow } from "zustand/react/shallow";
7import { useEffect, useEffectEvent, useRef, useState } from "react";
8
9import { previewBridge } from "~/components/preview/previewBridge";
10import {
11 preventThumbButtonFocus,
12 takeThumbButtonNavigation,
13} from "~/components/preview/thumbButtons";
14import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
15import { useClientSettingsHydrated } from "~/hooks/useSettings";
16import { cn, isMacPlatform } from "~/lib/utils";
17
18import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
19import { useActiveBrowserRecordingTabIds } from "./browserRecording";
20import {
21 browserViewportSettingKey,
22 resolveBrowserViewportLayout,
23 resolveFittedBrowserViewport,
24} from "./browserViewportLayout";
25import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
26import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
27import { acquireDesktopTab, type AcquiredDesktopTab, withDesktopTab } from "./desktopTabLifetime";
28import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle";
29import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
30import { useBrowserLayoutZoomFactor } from "./useBrowserLayoutZoomFactor";
31import { useBrowserViewportResize } from "./useBrowserViewportResize";
32import {
33 INITIAL_WEBVIEW_CRASH_RECOVERY_STATE,
34 planWebviewCrashRecovery,
35 type WebviewCrashRecoveryState,
36} from "./webviewCrashRecovery";
37
38interface ElectronWebview extends HTMLElement {
39 src: string;
40 partition: string;
41 preload?: string;
42 webpreferences?: string;
43 getWebContentsId: () => number;
44 executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>;
45}
46
47declare global {
48 interface HTMLElementTagNameMap {
49 webview: ElectronWebview;
50 }
51}
52
53export function HostedBrowserWebview(props: {
54 readonly threadRef: ScopedThreadRef;
55 readonly tabId: string;
56 readonly runtimeTabId: string;
57 readonly initialUrl: string | null;
58 readonly viewport: PreviewViewportSetting;
59 readonly pictureInPicture: boolean;
60 /** Fixed for the tab lifetime; Electron reads partition before guest attachment. */
61 readonly profileId: string | undefined;
62 readonly zoomFactor: number;
63 readonly serverDriven?: boolean;
64 readonly serverRendering?: {
65 readonly colorScheme: DesktopPreviewColorScheme;
66 readonly zoomFactor: number;
67 };
68}) {
69 const {
70 threadRef,
71 tabId,
72 runtimeTabId,
73 initialUrl,
74 viewport,
75 pictureInPicture,
76 zoomFactor,
77 profileId,
78 serverDriven = false,
79 serverRendering,
80 } = props;
81 const clientSettingsHydrated = useClientSettingsHydrated();
82 const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);
83 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
84 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
85 const wrapperRef = useRef<HTMLDivElement | null>(null);
86 const webviewRef = useRef<ElectronWebview | null>(null);
87 const crashRecoveryRef = useRef<WebviewCrashRecoveryState>(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE);
88 const [aspectRatioLocked, setAspectRatioLocked] = useState(false);
89 const presentation = useBrowserSurfaceStore(
90 useShallow((state) => {
91 const current = state.byTabId[runtimeTabId];
92 return {
93 content: current?.content ?? null,
94 cornerRadius: current?.cornerRadius ?? 0,
95 fitSourceContent: current?.fitSourceContent ?? false,
96 fittedSourceContent: current?.fittedSourceContent ?? null,
97 rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId),
98 visible: current?.visible ?? false,
99 zIndex: current?.zIndex ?? 30,
100 };
101 }),
102 );
103 const backgroundActivity = useBrowserSurfaceStore(
104 (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0,
105 );
106 const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId);
107 usePreviewBridge({ threadRef, tabId, runtimeTabId, serverDriven });
108
109 const serverColorScheme = serverRendering?.colorScheme;
110 const serverZoomFactor = serverRendering?.zoomFactor;
111
112 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- leases the desktop preview tab for this webview and releases it on cleanup
113 useEffect(() => {
114 if (!clientSettingsHydrated) return;
115 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;
116 const lease = acquireDesktopTab(
117 runtimeTabId,
118 serverDriven ? { threadId: threadRef.threadId, tabId } : undefined,
119 );
120 tabLeaseRef.current = lease;
121 return () => {
122 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;
123 lease.release();
124 };
125 }, [clientSettingsHydrated, runtimeTabId, serverDriven, tabId, threadRef.threadId]);
126
127 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies the server color scheme to the desktop preview tab through the desktop bridge
128 useEffect(() => {
129 const bridge = window.desktopBridge?.preview;
130 if (!bridge || serverColorScheme === undefined) return;
131 withDesktopTab(runtimeTabId, () => bridge.setColorScheme(runtimeTabId, serverColorScheme));
132 }, [runtimeTabId, serverColorScheme]);
133 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies the server zoom factor to the desktop preview tab through the desktop bridge
134 useEffect(() => {
135 const bridge = window.desktopBridge?.preview;
136 if (!bridge || serverZoomFactor === undefined) return;
137 withDesktopTab(runtimeTabId, () => bridge.setZoomFactor(runtimeTabId, serverZoomFactor));
138 }, [runtimeTabId, serverZoomFactor]);
139
140 const [webviewGeneration, setWebviewGeneration] = useState(0);
141 const [recoverySrc, setRecoverySrc] = useState(initialSrc);
142 const active = presentation.visible && presentation.rect !== null;
143 const shown = active || pictureInPicture;
144 const reportedShown = useEffectEvent(() => serverDriven && shown);
145 const remountCrashedWebview = useEffectEvent(() => {
146 setRecoverySrc(initialUrl ?? initialSrc);
147 setWebviewGeneration((generation) => generation + 1);
148 });
149
150 const setWebviewRef = (node: HTMLElement | null) => {
151 webviewRef.current = node as ElectronWebview | null;
152 };
153
154 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers the webview's webContents with the desktop bridge and listens for crashes and focus
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 // Tab creation and webview attachment use separate effects; wait to avoid PreviewTabNotFoundError.
167 await lease.ready;
168 if (disposed || webviewRef.current !== webview) return;
169 const webContentsId = webview.getWebContentsId();
170 if (Number.isInteger(webContentsId) && webContentsId > 0) {
171 await bridge.registerWebview(runtimeTabId, webContentsId);
172 withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, reportedShown()));
173 }
174 } catch {}
175 })();
176 };
177 const recoverGuest = () => {
178 if (disposed || recoveryTimeout !== null) return;
179 const recovery = planWebviewCrashRecovery(crashRecoveryRef.current, Date.now());
180 if (!recovery) return;
181 crashRecoveryRef.current = recovery.state;
182 recoveryTimeout = setTimeout(() => {
183 recoveryTimeout = null;
184 if (!disposed) remountCrashedWebview();
185 }, recovery.delayMs);
186 };
187 // Guest clicks never reach this document, so replay a pointerdown to dismiss host popups.
188 // The guest preload reports each press, since the guest does not always take focus.
189 const dismissHostPopups = () => {
190 webview.dispatchEvent(
191 new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }),
192 );
193 };
194 const guestPointerDown = (event: Event) => {
195 // Mirrors POINTER_DOWN_HOST_CHANNEL in apps/desktop/src/preview/GuestProtocol.ts.
196 if ((event as Event & { channel?: string }).channel === "preview:pointer-down") {
197 dismissHostPopups();
198 }
199 };
200 webview.addEventListener("did-attach", register);
201 webview.addEventListener("dom-ready", register);
202 webview.addEventListener("render-process-gone", recoverGuest);
203 webview.addEventListener("focus", dismissHostPopups);
204 webview.addEventListener("ipc-message", guestPointerDown);
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 webview.removeEventListener("ipc-message", guestPointerDown);
214 };
215 }, [
216 clientSettingsHydrated,
217 config,
218 runtimeTabId,
219 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A crash recovery remounts the webview under a new key, so the listeners attach to the new element.
220 webviewGeneration,
221 ]);
222
223 const lastRect = presentation.rect;
224 const layoutZoomFactor = useBrowserLayoutZoomFactor(zoomFactor);
225 const viewportWidth = viewport._tag === "fill" ? null : viewport.width;
226 const viewportHeight = viewport._tag === "fill" ? null : viewport.height;
227 const viewportAspectRatio =
228 viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight;
229 const lockedAspectRatio =
230 aspectRatioLocked && viewportAspectRatio !== null ? viewportAspectRatio : null;
231 const handleAspectRatioChange = (aspectRatio: number | null) => {
232 setAspectRatioLocked(aspectRatio !== null);
233 };
234 const content = presentation.content;
235 const hiddenWidth =
236 viewport._tag !== "fill"
237 ? viewport.width * layoutZoomFactor
238 : content
239 ? content.width / content.scale
240 : (lastRect?.width ?? 1280);
241 const hiddenHeight =
242 viewport._tag !== "fill"
243 ? viewport.height * layoutZoomFactor
244 : content
245 ? content.height / content.scale
246 : (lastRect?.height ?? 800);
247 // Build from primitives on each side of the resize hook to keep both objects cached.
248 const containerSize =
249 active && lastRect ? lastRect : { width: hiddenWidth, height: hiddenHeight };
250 const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent;
251 const {
252 activeDrag,
253 commitViewportChange,
254 effectiveViewport,
255 handleResizeKeyDown,
256 handleResizePointerDown,
257 layout: viewportLayout,
258 } = useBrowserViewportResize({
259 target: { threadRef, tabId, runtimeTabId },
260 viewport,
261 zoomFactor: layoutZoomFactor,
262 containerSize,
263 deviceToolbarVisible,
264 aspectRatio: lockedAspectRatio,
265 });
266 const fittedSourceViewport =
267 presentation.fitSourceContent && lastRect
268 ? resolveFittedBrowserViewport(viewport, presentation.fittedSourceContent, layoutZoomFactor)
269 : null;
270 const layout =
271 fittedSourceViewport && lastRect
272 ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, layoutZoomFactor)
273 : viewportLayout;
274
275 const syncContentPresentation = () => {
276 const wrapper = wrapperRef.current;
277 if (!wrapper) return;
278 useBrowserSurfaceStore.getState().presentContent(runtimeTabId, {
279 x: layout.viewportX,
280 y: layout.viewportY,
281 width: layout.viewportWidth,
282 height: layout.viewportHeight,
283 scale: layout.viewportScale,
284 scrollLeft: wrapper.scrollLeft,
285 scrollTop: wrapper.scrollTop,
286 });
287 };
288
289 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a frame that publishes the webview layout and scroll to the browser surface store
290 useEffect(() => {
291 const frameId = window.requestAnimationFrame(syncContentPresentation);
292 return () => window.cancelAnimationFrame(frameId);
293 }, [syncContentPresentation]);
294
295 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the webview wrapper element to its origin for a new tab or viewport size
296 useEffect(() => {
297 const wrapper = wrapperRef.current;
298 if (!wrapper) return;
299 wrapper.scrollTo({ left: 0, top: 0 });
300 }, [
301 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A new tab or viewport size starts the canvas scrolled to its origin.
302 runtimeTabId,
303 viewport._tag,
304 viewportHeight,
305 viewportWidth,
306 ]);
307
308 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Report the mounted webview's visibility to the desktop host.
309 useEffect(() => {
310 const bridge = window.desktopBridge?.preview;
311 if (!clientSettingsHydrated || !bridge || !serverDriven) return;
312 withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, shown));
313 return () => withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, false));
314 }, [clientSettingsHydrated, runtimeTabId, serverDriven, shown]);
315
316 if (!clientSettingsHydrated || !config) return null;
317
318 // This fixed wrapper sits outside the right panel shell, so it takes thumb presses itself.
319 const navigateThumb = (direction: "back" | "forward") => {
320 if (direction === "back") void previewBridge?.goBack(runtimeTabId);
321 else void previewBridge?.goForward(runtimeTabId);
322 };
323
324 const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;
325 const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
326 active,
327 renderingActive,
328 // Electron 43 can permanently blank a macOS webview after `visibility: hidden`.
329 keepPaintableWhenInactive: isMacPlatform(navigator.platform),
330 cornerRadius: presentation.cornerRadius,
331 zIndex: presentation.zIndex,
332 rect: lastRect,
333 hiddenSize: { width: hiddenWidth, height: hiddenHeight },
334 });
335
336 return (
337 <div
338 ref={wrapperRef}
339 className="fixed overflow-hidden bg-muted/35"
340 style={{ ...wrapperStyle, overscrollBehavior: "contain" }}
341 onScroll={syncContentPresentation}
342 onMouseDown={preventThumbButtonFocus}
343 onMouseUp={(event) => takeThumbButtonNavigation(event, navigateThumb)}
344 data-preview-rendering={renderingActive ? "active" : "suspended"}
345 data-preview-viewport={runtimeTabId}
346 >
347 <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>
348 {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? (
349 <BrowserDeviceToolbar
350 setting={effectiveViewport}
351 width={Math.max(1, Math.round(active && lastRect ? lastRect.width : hiddenWidth))}
352 aspectRatio={lockedAspectRatio}
353 onAspectRatioChange={handleAspectRatioChange}
354 onChange={commitViewportChange}
355 />
356 ) : null}
357 <webview
358 key={webviewGeneration}
359 ref={setWebviewRef}
360 // Electron needs allowpopups at attachment. React drops boolean values for unknown attributes.
361 {...({ allowpopups: "true" } as unknown as { readonly allowpopups?: boolean })}
362 src={webviewGeneration === 0 ? initialSrc : recoverySrc}
363 partition={config.partition}
364 webpreferences={config.webPreferences}
365 {...(config.preloadUrl ? { preload: config.preloadUrl } : {})}
366 data-preview-tab={runtimeTabId}
367 data-preview-server-tab={tabId}
368 data-preview-viewport-mode={effectiveViewport._tag}
369 data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)}
370 data-preview-css-width={
371 fittedSourceViewport
372 ? fittedSourceViewport.width
373 : effectiveViewport._tag === "fill"
374 ? Math.max(1, Math.round(layout.viewportWidth / layoutZoomFactor))
375 : effectiveViewport.width
376 }
377 data-preview-css-height={
378 fittedSourceViewport
379 ? fittedSourceViewport.height
380 : effectiveViewport._tag === "fill"
381 ? Math.max(1, Math.round(layout.viewportHeight / layoutZoomFactor))
382 : effectiveViewport.height
383 }
384 aria-hidden={active ? undefined : true}
385 className={cn(
386 "absolute flex overflow-hidden bg-white",
387 active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm",
388 )}
389 style={{
390 left: layout.viewportX,
391 top: layout.viewportY,
392 width: layout.viewportWidth / layout.viewportScale,
393 height: layout.viewportHeight / layout.viewportScale,
394 transform: layout.viewportScale < 1 ? `scale(${layout.viewportScale})` : undefined,
395 transformOrigin: "top left",
396 }}
397 />
398 {active && effectiveViewport._tag !== "fill" && !fittedSourceViewport ? (
399 <>
400 <BrowserViewportResizeHandles
401 layout={layout}
402 activeDirection={activeDrag?.direction ?? null}
403 onPointerDown={handleResizePointerDown}
404 onKeyDown={handleResizeKeyDown}
405 />
406 {activeDrag ? (
407 <div
408 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"
409 style={{
410 left: layout.viewportX + layout.viewportWidth / 2,
411 top: layout.viewportY + 10,
412 }}
413 aria-hidden="true"
414 >
415 {activeDrag.width} × {activeDrag.height}
416 </div>
417 ) : null}
418 </>
419 ) : null}
420 </div>
421 </div>
422 );
423}