227 lines · 8.3 KB · TypeScript
RawDownload
import { sanitizePublicBrowserUrl } from "@/lib/sanitize-embedded-browser-url"; const BROWSER_COMMAND_TIMEOUT_MS = 12_000; export type BrowserCommandResult = { ok: boolean; data?: unknown; error?: string }; type BrowserWebviewImage = { toDataURL: () => string }; type BrowserWebview = {  executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>;  loadURL: (url: string) => Promise<unknown>;  getURL: () => string;  getTitle: () => string;  capturePage: () => Promise<BrowserWebviewImage>;}; export type BrowserCommandHost = {  webview: BrowserWebview | null;  iframe: HTMLIFrameElement | null;}; export type BrowserCommandDeps = {  browser: BrowserCommandHost | null;  currentUrl: string;  setBrowserUrl: (url: string, input?: string) => void;  isElectron: boolean;}; function withBrowserTimeout<T>(operation: Promise<T>, label: string): Promise<T> {  let timer: ReturnType<typeof setTimeout> | null = null;  const timeout = new Promise<never>((_, reject) => {    timer = setTimeout(() => {      reject(new Error(`${label} timed out after ${BROWSER_COMMAND_TIMEOUT_MS / 1000}s`));    }, BROWSER_COMMAND_TIMEOUT_MS);  });  return Promise.race([operation, timeout]).finally(() => {    if (timer) clearTimeout(timer);  });} function detectBotProtection(text: string): string | null {  const normalized = text.toLowerCase();  if (    normalized.includes("our systems have detected unusual traffic") ||    normalized.includes("/sorry/") ||    normalized.includes("captcha") ||    normalized.includes("not a robot")  ) {    return "Bot-protection page detected. Stop automated browser use for this page and ask the user to intervene or use a non-browser search source.";  }  return null;} function isSafeBrowserSelector(selector: string): boolean {  return selector.length > 0 && selector.length <= 240 && !/[`;{}]/.test(selector);} function isRecord(value: unknown): value is Record<string, unknown> {  return Boolean(value && typeof value === "object" && !Array.isArray(value));} export async function runBrowserPanelCommand(  verb: string,  payload: Record<string, unknown>,  deps: BrowserCommandDeps,): Promise<BrowserCommandResult> {  const webview = deps.browser?.webview ?? null;  if (deps.isElectron && webview && typeof webview.executeJavaScript === "function") {    return runWebviewCommand(verb, payload, webview, deps.setBrowserUrl);  }   const iframe = deps.browser?.iframe ?? null;  if (!iframe && verb === "get-url") return { ok: true, data: { url: deps.currentUrl, title: "" } };  if (!iframe) return { ok: false, error: "Browser panel not mounted" };  return runIframeCommand(verb, payload, iframe, deps.setBrowserUrl);} async function runWebviewCommand(  verb: string,  payload: Record<string, unknown>,  webview: BrowserWebview,  setBrowserUrl: BrowserCommandDeps["setBrowserUrl"],): Promise<BrowserCommandResult> {  try {    switch (verb) {      case "navigate":        return navigateWebview(webview, payload, setBrowserUrl);      case "get-url":        return { ok: true, data: { url: webview.getURL(), title: webview.getTitle() } };      case "get-text":        return readProtectedWebviewText(          webview.executeJavaScript("document.body && document.body.innerText"),          "Browser text read",          "text",        );      case "get-html":        return readProtectedWebviewText(          webview.executeJavaScript(            "document.documentElement && document.documentElement.outerHTML",          ),          "Browser HTML read",          "html",        );      case "screenshot":        return captureWebviewScreenshot(webview);      case "click":        return clickWebviewSelector(webview, payload);      case "scroll":        return scrollWebview(webview, payload);      case "fill":        return fillWebviewSelector(webview, payload);      default:        return { ok: false, error: `Unsupported browser verb: ${verb}` };    }  } catch (error) {    return { ok: false, error: error instanceof Error ? error.message : String(error) };  }} async function navigateWebview(  webview: BrowserWebview,  payload: Record<string, unknown>,  setBrowserUrl: BrowserCommandDeps["setBrowserUrl"],): Promise<BrowserCommandResult> {  const url = sanitizePublicBrowserUrl(String(payload.url || ""));  if (!url) return { ok: false, error: "valid public http(s) url required" };  await withBrowserTimeout(webview.loadURL(url), "Browser navigation");  setBrowserUrl(url, url);  return { ok: true, data: { url } };} async function readProtectedWebviewText(  operation: Promise<unknown>,  label: string,  key: "text" | "html",): Promise<BrowserCommandResult> {  const value = await withBrowserTimeout(operation, label);  const text = typeof value === "string" ? value : "";  const protectionError = detectBotProtection(text);  return protectionError    ? { ok: false, error: protectionError }    : { ok: true, data: { [key]: text } };} async function captureWebviewScreenshot(webview: BrowserWebview): Promise<BrowserCommandResult> {  const image = await withBrowserTimeout(webview.capturePage(), "Browser screenshot");  return { ok: true, data: { dataUri: image.toDataURL() } };} async function clickWebviewSelector(  webview: BrowserWebview,  payload: Record<string, unknown>,): Promise<BrowserCommandResult> {  const selector = String(payload.selector || "");  const selectorError = validateBrowserSelector(selector);  if (selectorError) return selectorError;  const script = `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return { found: false }; (el).click(); return { found: true }; })()`;  const value = await withBrowserTimeout(webview.executeJavaScript(script, true), "Browser click");  return selectorResult(value);} async function scrollWebview(  webview: BrowserWebview,  payload: Record<string, unknown>,): Promise<BrowserCommandResult> {  const rawDeltaY = Number(payload.deltaY ?? 0);  const deltaY = Number.isFinite(rawDeltaY)    ? Math.max(-10_000, Math.min(10_000, Math.trunc(rawDeltaY)))    : 0;  await withBrowserTimeout(    webview.executeJavaScript(`window.scrollBy(0, ${deltaY})`),    "Browser scroll",  );  const scrollY = await withBrowserTimeout(    webview.executeJavaScript("window.scrollY"),    "Browser scroll position read",  );  return { ok: true, data: { deltaY, scrollY } };} async function fillWebviewSelector(  webview: BrowserWebview,  payload: Record<string, unknown>,): Promise<BrowserCommandResult> {  const selector = String(payload.selector || "");  const selectorError = validateBrowserSelector(selector);  if (selectorError) return selectorError;  const value = String(payload.value ?? "");  const script = `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return { found: false }; el.focus(); el.value = ${JSON.stringify(value)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return { found: true }; })()`;  const result = await withBrowserTimeout(webview.executeJavaScript(script, true), "Browser fill");  return selectorResult(result);} function validateBrowserSelector(selector: string): BrowserCommandResult | null {  if (!selector) return { ok: false, error: "selector required" };  if (!isSafeBrowserSelector(selector)) return { ok: false, error: "unsupported selector" };  return null;} function selectorResult(value: unknown): BrowserCommandResult {  const found = isRecord(value) && value.found === true;  return { ok: found, data: { found }, error: found ? undefined : "selector not found" };} function runIframeCommand(  verb: string,  payload: Record<string, unknown>,  iframe: HTMLIFrameElement,  setBrowserUrl: BrowserCommandDeps["setBrowserUrl"],): BrowserCommandResult {  switch (verb) {    case "navigate": {      const url = sanitizePublicBrowserUrl(String(payload.url || ""));      if (!url) return { ok: false, error: "valid public http(s) url required" };      iframe.src = url;      setBrowserUrl(url, url);      return { ok: true, data: { url } };    }    case "get-url":      return { ok: true, data: { url: iframe.src, title: "" } };    default:      return {        ok: false,        error: `Browser tool '${verb}' is only available in the desktop app (cross-origin iframe restriction in dev).`,      };  }}