1import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
2import {
3 AuthPreviewOperateScope,
4 type DesktopPreviewBridge,
5 type DesktopPreviewOpenLinkEvent,
6 type EnvironmentId,
7 FILL_PREVIEW_VIEWPORT,
8} from "@t3tools/contracts";
9import { useAtomValue } from "@effect/atom-react";
10import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
11import { type ComponentProps, useState } from "react";
12
13import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
14
15import { isElectron } from "~/env";
16import { useClientSettingsHydrated } from "~/hooks/useSettings";
17import { useDesktopHostedPreviewSessions } from "~/components/preview/usePreviewSession";
18import {
19 readActivePreviewSessions,
20 type ThreadPreviewState,
21 useActivePreviewSessions,
22} from "~/previewStateStore";
23import { appAtomRegistry } from "~/rpc/atomRegistry";
24import { previewEnvironment } from "~/state/preview";
25import { useEnvironmentScope } from "~/state/session";
26
27import { readPreviewAnnotationTheme } from "./annotationTheme";
28import { useBrowserDefaults } from "./browserDefaults";
29import { useBrowserPointerStore } from "./browserPointerStore";
30import { HostedBrowserWebview } from "./HostedBrowserWebview";
31import { openUrlInPreview } from "./openFileInPreview";
32import { rendersServerTabNatively } from "./previewRuntime";
33import { previewRuntimeTabId } from "./previewRuntimeTabId";
34
35/** Every session this window renders as a hosted webview, in a fixed (non-display) order. */
36function hostedBrowserSessions(
37 previewByThreadKey: Record<string, ThreadPreviewState>,
38 primaryEnvironmentId: EnvironmentId | null,
39) {
40 const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => {
41 const threadRef = parseScopedThreadKey(threadKey);
42 return threadRef
43 ? Object.values(previewState.sessions)
44 .filter(
45 (snapshot) =>
46 snapshot.runtime !== "server" ||
47 rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot),
48 )
49 .map((snapshot) => ({
50 threadRef,
51 snapshot,
52 runtimeTabId: previewRuntimeTabId(threadRef, previewState.serverEpoch, snapshot.tabId),
53 pictureInPicture:
54 previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false,
55 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
56 }))
57 : [];
58 });
59 // Electron destroys a moved webview's guest, so the render order must not follow the server's
60 // updatedAt order. Any fixed order works; this one is not a display order.
61 return activeSessions.toSorted((left, right) =>
62 left.runtimeTabId < right.runtimeTabId ? -1 : left.runtimeTabId > right.runtimeTabId ? 1 : 0,
63 );
64}
65
66function startAnnotationThemeSync(preview: DesktopPreviewBridge): () => void {
67 let lastSerializedTheme = "";
68 const syncTheme = () => {
69 const theme = readPreviewAnnotationTheme();
70 const serializedTheme = JSON.stringify(theme);
71 if (serializedTheme === lastSerializedTheme) return;
72 lastSerializedTheme = serializedTheme;
73 void preview.setAnnotationTheme(theme).catch(() => {
74 lastSerializedTheme = "";
75 });
76 };
77 // App services start before the first paint, when the theme styles may not be applied yet.
78 const frameId = window.requestAnimationFrame(syncTheme);
79 const observer = new MutationObserver(syncTheme);
80 observer.observe(document.documentElement, {
81 attributes: true,
82 attributeFilter: ["class", "style"],
83 });
84 const headObserver = new MutationObserver(syncTheme);
85 headObserver.observe(document.head, {
86 childList: true,
87 subtree: true,
88 characterData: true,
89 });
90 return () => {
91 window.cancelAnimationFrame(frameId);
92 observer.disconnect();
93 headObserver.disconnect();
94 };
95}
96
97function openLinkInPreview({ tabId, url, background }: DesktopPreviewOpenLinkEvent): void {
98 const source = hostedBrowserSessions(
99 readActivePreviewSessions(),
100 appAtomRegistry.get(primaryEnvironmentIdAtom),
101 ).find((session) => session.runtimeTabId === tabId);
102 if (!source) return;
103 void openUrlInPreview({
104 threadRef: source.threadRef,
105 url,
106 openPreview: (input) => runAtomCommand(appAtomRegistry, previewEnvironment.open, input),
107 profileId: source.snapshot.profileId,
108 background,
109 });
110}
111
112/** App-lifetime listeners for the desktop preview bridge: annotation theme, agent pointer, links. */
113export function startElectronBrowserHost(): () => void {
114 const preview = window.desktopBridge?.preview;
115 if (!preview) return () => {};
116 const disposers = [
117 startAnnotationThemeSync(preview),
118 preview.onPointerEvent((event) => {
119 useBrowserPointerStore.getState().apply(event);
120 }),
121 preview.onOpenLink(openLinkInPreview),
122 ];
123 return () => {
124 for (const dispose of disposers) dispose();
125 };
126}
127
128export function ElectronBrowserHost() {
129 const previewByThreadKey = useActivePreviewSessions();
130 const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom);
131 useDesktopHostedPreviewSessions(primaryEnvironmentId);
132 const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId);
133
134 if (!isElectron) return null;
135 return (
136 <div className="contents" data-electron-browser-host>
137 {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => {
138 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
139 return (
140 <AuthorizedBrowserWebview
141 key={runtimeTabId}
142 threadRef={threadRef}
143 tabId={snapshot.tabId}
144 runtimeTabId={runtimeTabId}
145 initialUrl={url}
146 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
147 pictureInPicture={pictureInPicture}
148 profileId={snapshot.profileId}
149 zoomFactor={zoomFactor}
150 serverDriven={snapshot.runtime === "server"}
151 {...(snapshot.runtime === "server"
152 ? {
153 serverRendering: {
154 colorScheme: snapshot.colorScheme ?? "system",
155 zoomFactor: snapshot.zoomFactor ?? 1,
156 },
157 }
158 : {})}
159 />
160 );
161 })}
162 </div>
163 );
164}
165
166function AuthorizedBrowserWebview(props: ComponentProps<typeof HostedBrowserWebview>) {
167 const canOperatePreview = useEnvironmentScope(
168 props.threadRef.environmentId,
169 AuthPreviewOperateScope,
170 );
171 const profileId = useTabProfileId(props.profileId);
172 return canOperatePreview ? <HostedBrowserWebview {...props} profileId={profileId} /> : null;
173}
174
175// Latch after hydration: Electron fixes the partition when the guest attaches.
176function useTabProfileId(profileId: string | undefined): string | undefined {
177 const hydrated = useClientSettingsHydrated();
178 const defaultProfileId = useBrowserDefaults().profileId;
179 const [fallback, setFallback] = useState<string | undefined>(undefined);
180 if (profileId === undefined && hydrated && fallback === undefined) {
181 setFallback(defaultProfileId);
182 }
183 return profileId ?? fallback;
184}