1"use client";
2
3import type {
4 DesktopPreviewTabState,
5 PreviewReportStatusInput,
6 ScopedThreadRef,
7 ThreadId,
8} from "@t3tools/contracts";
9import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";
10import * as Option from "effect/Option";
11import { useEffect, useEffectEvent, useMemo, useRef } from "react";
12
13import {
14 flushPendingFaviconsForThread,
15 recordFaviconForThread,
16 useFaviconProjectRefForThread,
17} from "~/browserFaviconStore";
18import { useBrowserPointerStore } from "~/browser/browserPointerStore";
19import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore";
20import { previewEnvironment } from "~/state/preview";
21import { usePreparedConnection } from "~/state/session";
22import { useAtomCommand } from "~/state/use-atom-command";
23
24import { previewBridge } from "./previewBridge";
25
26function originOf(url: string): string | null {
27 try {
28 return new URL(url).origin;
29 } catch {
30 return null;
31 }
32}
33
34/**
35 * Mirrors low-latency desktop state into the store and reflects navigation
36 * events back to the server. Webview lifetime is owned by ElectronBrowserHost.
37 */
38export function usePreviewBridge(input: {
39 threadRef: ScopedThreadRef;
40 tabId: string;
41 runtimeTabId: string;
42 /** The server drives this tab and reports its navigation itself. */
43 serverDriven?: boolean;
44}): void {
45 const { threadRef, tabId, runtimeTabId, serverDriven = false } = input;
46 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);
47 const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report");
48 const bridge = previewBridge;
49 const threadKey = scopedThreadKey(threadRef);
50 const stableThreadRef = useMemo(() => {
51 const parsed = parseScopedThreadKey(threadKey);
52 if (!parsed) throw new Error(`Invalid scoped thread key: ${threadKey}`);
53 return parsed;
54 }, [threadKey]);
55 const projectRef = useFaviconProjectRefForThread(stableThreadRef);
56 const preparedConnection = usePreparedConnection(stableThreadRef.environmentId);
57 const environmentHostname = Option.isSome(preparedConnection)
58 ? new URL(preparedConnection.value.httpBaseUrl).hostname
59 : undefined;
60
61 // One bridge subscription does both jobs (mirror state + forward to
62 // server) so the desktop bridge keeps a single listener entry per tab.
63 const lastReportedUrl = useRef<string | null>(null);
64 const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null);
65 const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null);
66 const handleStateChange = useEffectEvent(
67 (changedTabId: string, state: DesktopPreviewTabState): void => {
68 if (changedTabId !== runtimeTabId) return;
69 if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) {
70 clearBrowserPointer(runtimeTabId);
71 }
72 lastDesktopNavStatus.current = state.navStatus;
73 applyPreviewDesktopState(stableThreadRef, tabId, projectDesktopState(state));
74 if (state.favicon) {
75 recordFaviconForThread(stableThreadRef, state.favicon, projectRef, environmentHostname);
76 }
77 const reported = buildReportInput({
78 threadId: stableThreadRef.threadId,
79 tabId,
80 state,
81 lastReportedUrl: lastReportedUrl.current,
82 lastReportedKind: lastReportedKind.current,
83 });
84 if (!reported || serverDriven) return;
85 lastReportedUrl.current = reported.lastReportedUrl;
86 lastReportedKind.current = reported.lastReportedKind;
87 void reportStatus({
88 environmentId: stableThreadRef.environmentId,
89 input: reported.input,
90 });
91 },
92 );
93 useEffect(() => {
94 if (!bridge || typeof window === "undefined") return;
95 lastReportedUrl.current = null;
96 lastReportedKind.current = null;
97 lastDesktopNavStatus.current = null;
98 return bridge.onStateChange(handleStateChange);
99 }, [bridge, runtimeTabId, stableThreadRef, tabId]);
100 useEffect(() => {
101 if (!projectRef) return;
102 flushPendingFaviconsForThread(stableThreadRef, projectRef, environmentHostname);
103 }, [environmentHostname, projectRef, stableThreadRef]);
104}
105
106function shouldClearBrowserPointer(
107 previous: DesktopPreviewTabState["navStatus"] | null,
108 current: DesktopPreviewTabState["navStatus"],
109): boolean {
110 if (!previous) return false;
111 if (current.kind === "Loading" && previous.kind !== "Loading") return true;
112 if (current.kind === "Idle" || previous.kind === "Idle") return false;
113 return current.url !== previous.url;
114}
115
116export function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay {
117 const navOrigin = state.navStatus.kind === "Idle" ? null : originOf(state.navStatus.url);
118 return {
119 hasWebContents: state.webContentsId !== null,
120 canGoBack: state.canGoBack,
121 canGoForward: state.canGoForward,
122 loading: state.navStatus.kind === "Loading",
123 zoomFactor: state.zoomFactor,
124 pictureInPicture: state.pictureInPicture,
125 colorScheme: state.colorScheme,
126 audioMuted: state.audioMuted,
127 audible: state.audible,
128 controller: state.controller,
129 favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null,
130 };
131}
132
133/**
134 * Decide whether a state change warrants an RPC to the server, and shape
135 * the report payload.
136 *
137 * - Idle never reports — the tab is post-close or pre-load and the server
138 * already knows the canonical state from `open` / `closed`.
139 * - We dedupe on (kind, url): consecutive Loading→Loading→Loading for the
140 * same URL collapses to a single RPC, ditto Success.
141 * - LoadFailed always reports (the server uses it to emit `failed`).
142 */
143function buildReportInput(args: {
144 readonly threadId: ThreadId;
145 readonly tabId: string;
146 readonly state: DesktopPreviewTabState;
147 readonly lastReportedUrl: string | null;
148 readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"] | null;
149}): {
150 readonly input: PreviewReportStatusInput;
151 readonly lastReportedUrl: string;
152 readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"];
153} | null {
154 const { threadId, tabId, state, lastReportedUrl, lastReportedKind } = args;
155 const status = state.navStatus;
156 if (status.kind === "Idle") return null;
157
158 // Skip if we've already reported the same kind+url. LoadFailed always
159 // reports (rapid duplicate failures are unusual and worth surfacing).
160 const sameAsLast =
161 status.kind !== "LoadFailed" &&
162 status.kind === lastReportedKind &&
163 status.url === lastReportedUrl;
164 if (sameAsLast) return null;
165
166 const base = {
167 threadId,
168 tabId,
169 canGoBack: state.canGoBack,
170 canGoForward: state.canGoForward,
171 };
172 if (status.kind === "LoadFailed") {
173 return {
174 input: {
175 ...base,
176 navStatus: {
177 _tag: "LoadFailed",
178 url: status.url,
179 title: status.title,
180 code: status.code,
181 description: status.description,
182 },
183 },
184 lastReportedUrl: status.url,
185 lastReportedKind: "LoadFailed",
186 };
187 }
188 return {
189 input: {
190 ...base,
191 navStatus: { _tag: status.kind, url: status.url, title: status.title },
192 },
193 lastReportedUrl: status.url,
194 lastReportedKind: status.kind,
195 };
196}