194 lines · 7.0 KB · TypeScript
RawDownload
"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<string | null>(null);  const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null);  const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(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,  };}