Newer
Older
bugtrail / packages / extension / src / content / index.ts
/**
 * Content script bootstrap — deliberately minimal:
 * no UI of its own; mounts the shadow-DOM overlay (server version first,
 * bundled copy as fallback), relays DOM events and (while recording) captures
 * clicks/inputs for the background buffer.
 */
import browser from "webextension-polyfill";
import { compareVersions, type ElementContext } from "@ltt/shared";
import { mountOverlay, type Overlay, type MountOverlayOptions } from "./overlay/mount";
import { buildElementContext, collectEnvironment } from "../lib/selector";
import { pushUiHidden, popUiHidden } from "./uiVisibility";

// ---------- overlay loading (remote-first, bundled fallback) ----------

type OverlayFactory = (options?: MountOverlayOptions) => Overlay;

let overlay: Overlay | null = null;
let overlayFactory: Promise<OverlayFactory> | null = null;

/**
 * Resolves the overlay factory once: try the server-published overlay module
 * (hot-updated UI without reinstalling the extension), fall back to the copy
 * statically bundled in this file on any failure — offline, server absent,
 * restrictive CSP, module timeout, or a server version older than the
 * installed extension (never downgrade the UI below the core).
 *
 * The module is imported straight from the server URL: the manifest and asset
 * endpoints answer with Access-Control-Allow-Origin: * (they are public,
 * static assets), and a cross-origin import is the only module-loading path
 * that works in a content script's isolated world — blob and data: URL
 * imports are rejected by the MV3 script CSP, so there is no
 * fetch-verify-then-execute shortcut; the import is validated by contract
 * (typeof mountOverlay === "function") instead of by hash.
 */
async function resolveOverlayFactory(): Promise<OverlayFactory> {
  try {
    const reply = (await browser.runtime.sendMessage({ type: "settings_get" })) as {
      ok?: boolean;
      data?: { serverUrl?: string };
    };
    const serverUrl = reply?.data?.serverUrl?.replace(/\/+$/, "");
    if (serverUrl) {
      const manifest = await fetchExtManifest(serverUrl);
      const asset = manifest?.assets?.overlay_js?.url;
      const remoteVersion = typeof manifest?.version === "string" ? manifest.version : null;
      const selfVersion = browser.runtime.getManifest().version;
      if (asset && remoteVersion && compareVersions(remoteVersion, selfVersion) >= 0) {
        const mod = (await importWithTimeout(`${serverUrl}${asset}?v=${remoteVersion}`)) as {
          mountOverlay?: unknown;
        } | null;
        const mount = mod?.mountOverlay;
        if (typeof mount === "function") {
          const cssUrl = manifest?.assets?.overlay_css?.url
            ? `${serverUrl}${manifest.assets.overlay_css.url}?v=${remoteVersion}`
            : null;
          const factory: OverlayFactory = (options) => (mount as typeof mountOverlay)({ ...options, cssUrl });
          return factory;
        }
      }
    }
  } catch {
    // any failure lands on the bundled overlay — the UI must always come up
  }
  return mountOverlay;
}

/** GET /api/ext/manifest with a hard timeout; null when unreachable. */
async function fetchExtManifest(serverUrl: string): Promise<ExtManifest | null> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 3000);
  try {
    const response = await fetch(`${serverUrl}/api/ext/manifest`, { signal: controller.signal });
    if (!response.ok) return null;
    return (await response.json()) as ExtManifest;
  } catch {
    return null;
  } finally {
    clearTimeout(timer);
  }
}

interface ExtManifest {
  version?: string | null;
  assets?: {
    overlay_js?: { url?: string; sha256?: string };
    overlay_css?: { url?: string; sha256?: string };
  } | null;
}

/** Dynamic import that can't hang: resolves null after 5s. */
function importWithTimeout(url: string): Promise<unknown | null> {
  return Promise.race([
    import(/* @vite-ignore */ url).catch(() => null),
    new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000)),
  ]);
}

async function getOverlay(): Promise<Overlay> {
  if (!overlayFactory) overlayFactory = resolveOverlayFactory();
  const factory = await overlayFactory;
  if (!overlay) {
    overlay = factory();
    // observable marker for e2e/telemetry: which overlay source is in use
    document
      .getElementById("ltt-overlay-host")
      ?.setAttribute("data-overlay-source", factory === mountOverlay ? "bundled" : "remote");
  }
  return overlay;
}

// ---------- recorder capture (event listeners, no UI) ----------

let captureListeners: (() => void) | null = null;

/** Longest input debounced per element; flushes a single input event per pause. */
const pendingInputs = new Map<Element, { timer: number; element: ElementContext }>();

/** Window scroll debounced like inputs: one "scroll" step per pause. */
let pendingScroll: number | null = null;

function flushScroll() {
  if (pendingScroll == null) return;
  window.clearTimeout(pendingScroll);
  pendingScroll = null;
  sendRecorderEvent("scroll", null, { x: window.scrollX, y: window.scrollY });
}

function sendRecorderEvent(
  type: "click" | "input" | "scroll",
  element: ElementContext | null,
  data: Record<string, unknown>
) {
  const payload = element ? { element, ...data } : data;
  void browser.runtime
    .sendMessage({
      type: "recorder_event",
      event: { type, data: payload, at: Date.now() },
    })
    .catch(() => {});
}

function flushInput(element: Element) {
  const pending = pendingInputs.get(element);
  if (!pending) return;
  pendingInputs.delete(element);
  window.clearTimeout(pending.timer);
  const input = element as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
  const isPassword = (input as HTMLInputElement).type === "password";
  const value = isPassword ? null : input.value.slice(0, 500);
  sendRecorderEvent("input", pending.element, {
    value_length: input.value.length,
    value,
    input_type: (input as HTMLInputElement).type ?? null,
  });
}

function isOurOverlay(target: EventTarget | null): boolean {
  // events originating in the closed shadow root are retargeted to the host
  return target instanceof Element && target.id === "ltt-overlay-host";
}

// ---------- cursor sampling (while recording) ----------

interface MoveSample {
  /** client timestamp (Date.now()) — background converts to track offset */
  at: number;
  x: number;
  y: number;
}

let moveSamples: MoveSample[] = [];
let moveFlushTimer: number | null = null;
let lastMove: { x: number; y: number; at: number } | null = null;

const MOVE_MIN_INTERVAL_MS = 40; // ~25 samples/sec is plenty to redraw the path
const MOVE_MIN_DISTANCE_PX = 2;

function onMouseMove(event: MouseEvent) {
  if (isOurOverlay(event.target)) return;
  const now = Date.now();
  if (lastMove) {
    const moved =
      Math.abs(event.clientX - lastMove.x) >= MOVE_MIN_DISTANCE_PX ||
      Math.abs(event.clientY - lastMove.y) >= MOVE_MIN_DISTANCE_PX;
    if (!moved || now - lastMove.at < MOVE_MIN_INTERVAL_MS) return;
  }
  lastMove = { x: event.clientX, y: event.clientY, at: now };
  moveSamples.push({ at: now, x: event.clientX, y: event.clientY });
}

/** Hands the accumulated samples to the background and clears the local buffer. */
function takeMoveSamples(): MoveSample[] {
  const out = moveSamples;
  moveSamples = [];
  return out;
}

function attachCaptureListeners() {
  if (captureListeners) return;

  const onClick = (event: MouseEvent) => {
    if (isOurOverlay(event.target)) return;
    const target = event.target;
    if (!(target instanceof Element)) return;
    sendRecorderEvent("click", buildElementContext(target), {
      button: event.button,
    });
  };

  const onInput = (event: Event) => {
    if (isOurOverlay(event.target)) return;
    const target = event.target;
    if (
      !(target instanceof HTMLInputElement) &&
      !(target instanceof HTMLTextAreaElement) &&
      !(target instanceof HTMLSelectElement)
    ) {
      return true;
    }
    const existing = pendingInputs.get(target);
    if (existing) window.clearTimeout(existing.timer);
    const timer = window.setTimeout(() => flushInput(target), 250);
    pendingInputs.set(target, { timer, element: buildElementContext(target) });
  };

  const onScroll = (event: Event) => {
    // only page scroll — a scroll inside an inner container bubbles up too
    if (event.target !== document && event.target !== document.documentElement) return;
    if (pendingScroll != null) window.clearTimeout(pendingScroll);
    pendingScroll = window.setTimeout(flushScroll, 250);
  };

  document.addEventListener("click", onClick, true);
  document.addEventListener("input", onInput, true);
  document.addEventListener("mousemove", onMouseMove, true);
  window.addEventListener("scroll", onScroll, { capture: true, passive: true });
  // the content script dies on full page navigations — flush regularly so
  // each page contributes its own slice of the track, and squeeze out the
  // tail when the page is being unloaded
  const flushTimer = () => {
    if (!moveSamples.length) return;
    void browser.runtime.sendMessage({ type: "recorder_move_batch", points: takeMoveSamples() }).catch(() => {});
  };
  const onPageHide = () => {
    // flush pending input debounces too — otherwise "type, then navigate"
    // loses the input step entirely
    for (const element of [...pendingInputs.keys()]) flushInput(element);
    flushScroll();
    const points = takeMoveSamples();
    if (points.length) void browser.runtime.sendMessage({ type: "recorder_move_batch", points }).catch(() => {});
  };
  moveFlushTimer = window.setInterval(flushTimer, 1000);
  window.addEventListener("pagehide", onPageHide);
  captureListeners = () => {
    document.removeEventListener("click", onClick, true);
    document.removeEventListener("input", onInput, true);
    document.removeEventListener("mousemove", onMouseMove, true);
    window.removeEventListener("scroll", onScroll, { capture: true });
    window.removeEventListener("pagehide", onPageHide);
    if (moveFlushTimer != null) window.clearInterval(moveFlushTimer);
    moveFlushTimer = null;
    if (pendingScroll != null) window.clearTimeout(pendingScroll);
    pendingScroll = null;
    for (const [element, pending] of pendingInputs) window.clearTimeout(pending.timer);
    pendingInputs.clear();
  };
}

function detachCaptureListeners() {
  if (!captureListeners) return;
  captureListeners();
  captureListeners = null;
}

// ---------- replay (executes recorded steps on the page) ----------

function resolveRecordedElement(element: unknown): Element | null {
  const context = element as { selector?: string | null; unique_selector?: string | null } | null;
  if (!context) return null;
  for (const selector of [context.unique_selector, context.selector]) {
    if (!selector) continue;
    try {
      const found = document.querySelector(selector);
      if (found) return found;
    } catch {
      // invalid selector — try the next one
    }
  }
  return null;
}

// ---------- replay cursor (a virtual mouse that walks the recorded path) ----------

interface CursorState {
  el: HTMLElement;
  raf: number;
  track: { viewport: { w: number; h: number }; points: { t: number; x: number; y: number }[] };
  startedAt: number;
  lastDispatch: number;
  lastCdp: number;
  /** element currently "hovered" by the virtual cursor */
  hoverTarget: Element | null;
}

let cursor: CursorState | null = null;

function ensureCursorEl(): HTMLElement {
  let host = document.getElementById("ltt-cursor-host");
  if (host) return host;
  host = document.createElement("div");
  host.id = "ltt-cursor-host";
  host.style.cssText = "position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;will-change:transform;";
  host.innerHTML =
    '<svg width="24" height="24" viewBox="0 0 24 24">' +
    '<path d="M4 2 L4 20 L9.5 14.5 L13 22 L16 20.5 L12.6 13.2 L20 13 Z" ' +
    'fill="#7aa2f7" stroke="#10121c" stroke-width="1.5"/></svg>';
  document.documentElement.appendChild(host);
  return host;
}

function setCursorPosition(state: CursorState, x: number, y: number, dispatch: boolean) {
  state.el.style.transform = `translate(${x}px, ${y}px)`;
  if (!dispatch) return;
  // synthetic JS hover events keep mouse handlers live during replay; CSS
  // :hover needs trusted input — the background mirrors these positions to
  // the Chrome debugger API (Input.dispatchMouseEvent) when hoverReplay is on
  const now = performance.now();
  const target = document.elementFromPoint(x, y);
  if (!target || target.id === "ltt-cursor-host") return;
  const opts: MouseEventInit = { clientX: x, clientY: y, bubbles: true, cancelable: true, view: window };
  if (target !== state.hoverTarget) {
    if (state.hoverTarget) {
      state.hoverTarget.dispatchEvent(new MouseEvent("mouseout", { ...opts, relatedTarget: target }));
      state.hoverTarget.dispatchEvent(new MouseEvent("mouseleave", { ...opts, relatedTarget: target, bubbles: false }));
    }
    target.dispatchEvent(new MouseEvent("mouseover", { ...opts, relatedTarget: state.hoverTarget }));
    target.dispatchEvent(new MouseEvent("mouseenter", { ...opts, relatedTarget: state.hoverTarget, bubbles: false }));
    state.hoverTarget = target;
  }
  if (now - state.lastDispatch >= 30) {
    state.lastDispatch = now;
    target.dispatchEvent(new MouseEvent("mousemove", opts));
  }
  // mirror the position to the background at ~20 Hz for CSS :hover replay
  if (now - state.lastCdp >= 50) {
    state.lastCdp = now;
    void browser.runtime.sendMessage({ type: "replay_hover_move", x, y }).catch(() => {});
  }
}

function startReplayCursor(track: CursorState["track"], fromT = 0) {
  stopReplayCursor();
  if (!track?.points?.length || !track.viewport?.w || !track.viewport?.h) return;
  const el = ensureCursorEl();
  const state: CursorState = { el, raf: 0, track, startedAt: performance.now() - fromT, lastDispatch: 0, lastCdp: 0, hoverTarget: null };
  cursor = state;
  const points = track.points;
  // resume mid-track (after a navigation the replay continues, not restarts)
  let index = points.findIndex((p) => p.t >= fromT);
  if (index < 0) index = points.length - 1;
  const loop = () => {
    if (!cursor || cursor !== state) return;
    const t = performance.now() - state.startedAt;
    // scale recorded viewport coordinates to the current window size
    const sx = window.innerWidth / track.viewport.w;
    const sy = window.innerHeight / track.viewport.h;
    while (index + 1 < points.length && points[index + 1].t <= t) index++;
    const a = points[index];
    const b = points[index + 1];
    let x: number;
    let y: number;
    if (!b) {
      x = a.x * sx;
      y = a.y * sy;
    } else {
      const k = Math.min(Math.max((t - a.t) / (b.t - a.t), 0), 1);
      x = (a.x + (b.x - a.x) * k) * sx;
      y = (a.y + (b.y - a.y) * k) * sy;
    }
    setCursorPosition(state, x, y, true);
    state.raf = requestAnimationFrame(loop);
  };
  setCursorPosition(state, points[index].x * (window.innerWidth / track.viewport.w), points[index].y * (window.innerHeight / track.viewport.h), false);
  state.raf = requestAnimationFrame(loop);
}

function moveCursorToElement(target: Element) {
  if (!cursor) return;
  const rect = target.getBoundingClientRect();
  setCursorPosition(cursor, rect.left + rect.width / 2, rect.top + rect.height / 2, true);
}

function stopReplayCursor() {
  if (!cursor) return;
  cancelAnimationFrame(cursor.raf);
  cursor.el.remove();
  cursor = null;
}

function replayStep(data: Record<string, unknown>): { ok: boolean } {
  const target = resolveRecordedElement(data.element);
  if (!target) return { ok: false };
  moveCursorToElement(target);
  const value = typeof data.value === "string" ? data.value : undefined;
  const isField =
    target instanceof HTMLInputElement ||
    target instanceof HTMLTextAreaElement ||
    target instanceof HTMLSelectElement;
  if (value !== undefined && isField) {
    const field = target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
    field.value = value;
    field.dispatchEvent(new Event("input", { bubbles: true }));
    field.dispatchEvent(new Event("change", { bubbles: true }));
  } else {
    (target as HTMLElement).click();
  }
  return { ok: true };
}

// ---------- message wiring ----------

browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  const msg = message as { type: string; [key: string]: unknown };
  switch (msg.type) {
    case "ping":
      sendResponse({ ok: true, data: "pong" });
      return true;

    case "start_picker":
      void (async () => {
        (await getOverlay()).startPicker();
        sendResponse({ ok: true });
      })();
      return true;

    case "recorder_state": {
      const recording = Boolean((message as { recording?: unknown }).recording);
      const pending = Boolean((message as { pending?: unknown }).pending);
      const reportToken = (message as { report_token?: string | null }).report_token ?? null;
      void (async () => {
        (await getOverlay()).setRecorderState(recording, pending, reportToken);
        if (recording) {
          moveSamples = [];
          lastMove = null;
          attachCaptureListeners();
          void browser.runtime
            .sendMessage({ type: "recorder_environment", environment: collectEnvironment() })
            .catch(() => {});
        } else {
          detachCaptureListeners();
          // hand the tail of the track to the background before it submits
          const points = takeMoveSamples();
          if (points.length) {
            void browser.runtime.sendMessage({ type: "recorder_move_batch", points }).catch(() => {});
          }
        }
        sendResponse({ ok: true });
      })();
      return true;
    }

    case "recorder_flush_moves":
      sendResponse({ ok: true, points: takeMoveSamples() });
      return true;

    case "replay_scroll": {
      // scale the recorded offsets to the current window size (the recorded
      // viewport comes from the report environment)
      const viewport = (msg.viewport ?? null) as { w?: number; h?: number } | null;
      const x = typeof msg.x === "number" ? msg.x : 0;
      const y = typeof msg.y === "number" ? msg.y : 0;
      window.scrollTo(
        viewport?.w ? x * (window.innerWidth / viewport.w) : x,
        viewport?.h ? y * (window.innerHeight / viewport.h) : y
      );
      sendResponse({ ok: true });
      return true;
    }

    case "replay_step": {
      const result = replayStep((msg.data ?? {}) as Record<string, unknown>);
      sendResponse({ ok: result.ok });
      return true;
    }

    case "replay_start":
      startReplayCursor(msg.track as CursorState["track"], typeof msg.from_t === "number" ? msg.from_t : 0);
      sendResponse({ ok: true });
      return true;

    case "replay_stop":
      stopReplayCursor();
      sendResponse({ ok: true });
      return true;

    case "replay_sync": {
      // background nudges the cursor clock to the step clock after every
      // executed step; with 1:1 pacing the drift stays small, the clamp is
      // just a safety net against slow message round-trips
      if (cursor && typeof msg.recorded_ms === "number") {
        const drift = msg.recorded_ms - (performance.now() - cursor.startedAt);
        cursor.startedAt -= Math.max(Math.min(drift, 400), -400);
      }
      sendResponse({ ok: true });
      return true;
    }

    case "show_flash":
      void (async () => {
        (await getOverlay()).setFlash(typeof msg.text === "string" ? msg.text : null);
        sendResponse({ ok: true });
      })();
      return true;

    case "set_ui_hidden":
      // the background hides all plugin UI while it grabs a screenshot
      if (Boolean(msg.hidden)) pushUiHidden();
      else popUiHidden();
      sendResponse({ ok: true });
      return true;

    default:
      return true;
  }
});