1"use client";
2
3import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
4import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
5import { useAtomValue } from "@effect/atom-react";
6import { type ComponentProps, useEffect, useMemo, useRef, useState } from "react";
7
8import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
9
10import { isElectron } from "~/env";
11import { useClientSettingsHydrated } from "~/hooks/useSettings";
12import { useTheme } from "~/hooks/useTheme";
13import { useActivePreviewSessions } from "~/previewStateStore";
14import { previewEnvironment } from "~/state/preview";
15import { useEnvironmentScope } from "~/state/session";
16import { useAtomCommand } from "~/state/use-atom-command";
17
18import { readPreviewAnnotationTheme } from "./annotationTheme";
19import { useBrowserDefaults } from "./browserDefaults";
20import { useBrowserPointerStore } from "./browserPointerStore";
21import { HostedBrowserWebview } from "./HostedBrowserWebview";
22import { openUrlInPreview } from "./openFileInPreview";
23import { rendersServerTabNatively } from "./previewRuntime";
24import { previewRuntimeTabId } from "./previewRuntimeTabId";
25
26export function ElectronBrowserHost() {
27 const { resolvedTheme } = useTheme();
28 const previewByThreadKey = useActivePreviewSessions();
29 const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom);
30 const sessions = useMemo(
31 () =>
32 Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => {
33 const threadRef = parseScopedThreadKey(threadKey);
34 // Server tabs of other environments stream; this desktop's own server tabs render here.
35 return threadRef
36 ? Object.values(previewState.sessions)
37 .filter(
38 (snapshot) =>
39 snapshot.runtime !== "server" ||
40 rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot),
41 )
42 .map((snapshot) => ({
43 threadRef,
44 snapshot,
45 runtimeTabId: previewRuntimeTabId(
46 threadRef,
47 previewState.serverEpoch,
48 snapshot.tabId,
49 ),
50 pictureInPicture:
51 previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false,
52 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
53 }))
54 : [];
55 }),
56 [previewByThreadKey, primaryEnvironmentId],
57 );
58
59 useEffect(() => {
60 const preview = window.desktopBridge?.preview;
61 if (!preview) return;
62
63 let lastSerializedTheme = "";
64 const syncTheme = () => {
65 const theme = readPreviewAnnotationTheme();
66 const serializedTheme = JSON.stringify(theme);
67 if (serializedTheme === lastSerializedTheme) return;
68 lastSerializedTheme = serializedTheme;
69 void preview.setAnnotationTheme(theme).catch(() => {
70 lastSerializedTheme = "";
71 });
72 };
73 const frameId = window.requestAnimationFrame(syncTheme);
74 const observer = new MutationObserver(syncTheme);
75 observer.observe(document.documentElement, {
76 attributes: true,
77 attributeFilter: ["class", "style"],
78 });
79 const headObserver = new MutationObserver(syncTheme);
80 headObserver.observe(document.head, {
81 childList: true,
82 subtree: true,
83 characterData: true,
84 });
85 return () => {
86 window.cancelAnimationFrame(frameId);
87 observer.disconnect();
88 headObserver.disconnect();
89 };
90 }, [resolvedTheme]);
91
92 useEffect(() => {
93 const preview = window.desktopBridge?.preview;
94 if (!preview) return;
95 return preview.onPointerEvent((event) => {
96 useBrowserPointerStore.getState().apply(event);
97 });
98 }, []);
99
100 // A `target="_blank"` link inside a hosted page opens as another tab of the
101 // same thread, so the page that held the link stays where it is.
102 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: true });
103 const sessionByRuntimeTabId = useRef(new Map<string, (typeof sessions)[number]>());
104 useEffect(() => {
105 sessionByRuntimeTabId.current = new Map(
106 sessions.map((session) => [session.runtimeTabId, session]),
107 );
108 }, [sessions]);
109 useEffect(() => {
110 const preview = window.desktopBridge?.preview;
111 if (!preview) return;
112 return preview.onOpenLink(({ tabId, url, background }) => {
113 const source = sessionByRuntimeTabId.current.get(tabId);
114 if (!source) return;
115 // The new tab keeps the source tab's profile so its cookies carry over.
116 void openUrlInPreview({
117 threadRef: source.threadRef,
118 url,
119 openPreview,
120 profileId: source.snapshot.profileId,
121 background,
122 });
123 });
124 }, [openPreview]);
125
126 if (!isElectron) return null;
127 return (
128 <div className="contents" data-electron-browser-host>
129 {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => {
130 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
131 return (
132 <AuthorizedBrowserWebview
133 key={runtimeTabId}
134 threadRef={threadRef}
135 tabId={snapshot.tabId}
136 runtimeTabId={runtimeTabId}
137 initialUrl={url}
138 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
139 pictureInPicture={pictureInPicture}
140 profileId={snapshot.profileId}
141 zoomFactor={zoomFactor}
142 serverDriven={snapshot.runtime === "server"}
143 {...(snapshot.runtime === "server"
144 ? {
145 serverRendering: {
146 colorScheme: snapshot.colorScheme ?? "system",
147 zoomFactor: snapshot.zoomFactor ?? 1,
148 },
149 }
150 : {})}
151 />
152 );
153 })}
154 </div>
155 );
156}
157
158function AuthorizedBrowserWebview(props: ComponentProps<typeof HostedBrowserWebview>) {
159 const canOperatePreview = useEnvironmentScope(
160 props.threadRef.environmentId,
161 AuthPreviewOperateScope,
162 );
163 const profileId = useTabProfileId(props.profileId);
164 return canOperatePreview ? <HostedBrowserWebview {...props} profileId={profileId} /> : null;
165}
166
167/**
168 * Agent `preview_open` normally carries the profile clients reported (see
169 * BrowserProfileReporter). An agent tab opened before any client reported has
170 * none, so it falls back to the configured default here. It is latched once
171 * settings load: Electron fixes the partition when the guest attaches, so a
172 * later settings change must not move a live tab.
173 */
174function useTabProfileId(profileId: string | undefined): string | undefined {
175 const hydrated = useClientSettingsHydrated();
176 const defaultProfileId = useBrowserDefaults().profileId;
177 const [fallback, setFallback] = useState<string | undefined>(undefined);
178 if (profileId === undefined && hydrated && fallback === undefined) {
179 setFallback(defaultProfileId);
180 }
181 return profileId ?? fallback;
182}