| 1 | "use client"; |
| 2 | |
| 3 | import type { |
| 4 | DesktopPreviewColorScheme, |
| 5 | PreviewViewportSetting, |
| 6 | ScopedThreadRef, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { useShallow } from "zustand/react/shallow"; |
| 9 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 10 | |
| 11 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 12 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 13 | import { useClientSettingsHydrated } from "~/hooks/useSettings"; |
| 14 | import { cn, isMacPlatform } from "~/lib/utils"; |
| 15 | |
| 16 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 17 | import { useActiveBrowserRecordingTabIds } from "./browserRecording"; |
| 18 | import { |
| 19 | browserViewportSettingKey, |
| 20 | resolveBrowserViewportLayout, |
| 21 | resolveFittedBrowserViewport, |
| 22 | } from "./browserViewportLayout"; |
| 23 | import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; |
| 24 | import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; |
| 25 | import { acquireDesktopTab, type AcquiredDesktopTab, withDesktopTab } from "./desktopTabLifetime"; |
| 26 | import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; |
| 27 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| 28 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; |
| 29 | import { |
| 30 | INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, |
| 31 | planWebviewCrashRecovery, |
| 32 | type WebviewCrashRecoveryState, |
| 33 | } from "./webviewCrashRecovery"; |
| 34 | |
| 35 | interface 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 | |
| 44 | declare global { |
| 45 | interface HTMLElementTagNameMap { |
| 46 | webview: ElectronWebview; |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | export 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 | } |