"use client"; import type { DesktopPreviewTabState, PreviewReportStatusInput, ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import * as Option from "effect/Option"; import { useEffect, useEffectEvent, useMemo, useRef } from "react"; import { flushPendingFaviconsForThread, recordFaviconForThread, useFaviconProjectRefForThread, } from "~/browserFaviconStore"; import { useBrowserPointerStore } from "~/browser/browserPointerStore"; import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore"; import { previewEnvironment } from "~/state/preview"; import { usePreparedConnection } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { previewBridge } from "./previewBridge"; function originOf(url: string): string | null { try { return new URL(url).origin; } catch { return null; } } /** * Mirrors low-latency desktop state into the store and reflects navigation * events back to the server. Webview lifetime is owned by ElectronBrowserHost. */ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string; runtimeTabId: string; }): void { const { threadRef, tabId, runtimeTabId } = input; const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report"); const bridge = previewBridge; const threadKey = scopedThreadKey(threadRef); const stableThreadRef = useMemo(() => { const parsed = parseScopedThreadKey(threadKey); if (!parsed) throw new Error(`Invalid scoped thread key: ${threadKey}`); return parsed; }, [threadKey]); const projectRef = useFaviconProjectRefForThread(stableThreadRef); const preparedConnection = usePreparedConnection(stableThreadRef.environmentId); const environmentHostname = Option.isSome(preparedConnection) ? new URL(preparedConnection.value.httpBaseUrl).hostname : undefined; // One bridge subscription does both jobs (mirror state + forward to // server) so the desktop bridge keeps a single listener entry per tab. const lastReportedUrl = useRef(null); const lastReportedKind = useRef(null); const lastDesktopNavStatus = useRef(null); const handleStateChange = useEffectEvent( (changedTabId: string, state: DesktopPreviewTabState): void => { if (changedTabId !== runtimeTabId) return; if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) { clearBrowserPointer(runtimeTabId); } lastDesktopNavStatus.current = state.navStatus; applyPreviewDesktopState(stableThreadRef, tabId, projectDesktopState(state)); if (state.favicon) { recordFaviconForThread(stableThreadRef, state.favicon, projectRef, environmentHostname); } const reported = buildReportInput({ threadId: stableThreadRef.threadId, tabId, state, lastReportedUrl: lastReportedUrl.current, lastReportedKind: lastReportedKind.current, }); if (!reported) return; lastReportedUrl.current = reported.lastReportedUrl; lastReportedKind.current = reported.lastReportedKind; void reportStatus({ environmentId: stableThreadRef.environmentId, input: reported.input, }); }, ); useEffect(() => { if (!bridge || typeof window === "undefined") return; lastReportedUrl.current = null; lastReportedKind.current = null; lastDesktopNavStatus.current = null; return bridge.onStateChange(handleStateChange); }, [bridge, runtimeTabId, stableThreadRef, tabId]); useEffect(() => { if (!projectRef) return; flushPendingFaviconsForThread(stableThreadRef, projectRef, environmentHostname); }, [environmentHostname, projectRef, stableThreadRef]); } function shouldClearBrowserPointer( previous: DesktopPreviewTabState["navStatus"] | null, current: DesktopPreviewTabState["navStatus"], ): boolean { if (!previous) return false; if (current.kind === "Loading" && previous.kind !== "Loading") return true; if (current.kind === "Idle" || previous.kind === "Idle") return false; return current.url !== previous.url; } export function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { const navOrigin = state.navStatus.kind === "Idle" ? null : originOf(state.navStatus.url); return { hasWebContents: state.webContentsId !== null, canGoBack: state.canGoBack, canGoForward: state.canGoForward, loading: state.navStatus.kind === "Loading", zoomFactor: state.zoomFactor, pictureInPicture: state.pictureInPicture, colorScheme: state.colorScheme, audioMuted: state.audioMuted, audible: state.audible, controller: state.controller, favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null, }; } /** * Decide whether a state change warrants an RPC to the server, and shape * the report payload. * * - Idle never reports — the tab is post-close or pre-load and the server * already knows the canonical state from `open` / `closed`. * - We dedupe on (kind, url): consecutive Loading→Loading→Loading for the * same URL collapses to a single RPC, ditto Success. * - LoadFailed always reports (the server uses it to emit `failed`). */ function buildReportInput(args: { readonly threadId: ThreadId; readonly tabId: string; readonly state: DesktopPreviewTabState; readonly lastReportedUrl: string | null; readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"] | null; }): { readonly input: PreviewReportStatusInput; readonly lastReportedUrl: string; readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"]; } | null { const { threadId, tabId, state, lastReportedUrl, lastReportedKind } = args; const status = state.navStatus; if (status.kind === "Idle") return null; // Skip if we've already reported the same kind+url. LoadFailed always // reports (rapid duplicate failures are unusual and worth surfacing). const sameAsLast = status.kind !== "LoadFailed" && status.kind === lastReportedKind && status.url === lastReportedUrl; if (sameAsLast) return null; const base = { threadId, tabId, canGoBack: state.canGoBack, canGoForward: state.canGoForward, }; if (status.kind === "LoadFailed") { return { input: { ...base, navStatus: { _tag: "LoadFailed", url: status.url, title: status.title, code: status.code, description: status.description, }, }, lastReportedUrl: status.url, lastReportedKind: "LoadFailed", }; } return { input: { ...base, navStatus: { _tag: status.kind, url: status.url, title: status.title }, }, lastReportedUrl: status.url, lastReportedKind: status.kind, }; }