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

let overlay: ReturnType<typeof mountOverlay> | null = null;

function getOverlay() {
  if (!overlay) overlay = mountOverlay();
  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 }>();

function sendRecorderEvent(type: "click" | "input", element: ElementContext, data: Record<string, unknown>) {
  void browser.runtime
    .sendMessage({
      type: "recorder_event",
      event: { type, data: { element, ...data }, 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) });
  };

  document.addEventListener("click", onClick, true);
  document.addEventListener("input", onInput, true);
  document.addEventListener("mousemove", onMouseMove, true);
  // the content script dies on full page navigations — flush regularly so
  // each page contributes its own slice of the track
  moveFlushTimer = window.setInterval(() => {
    if (!moveSamples.length) return;
    void browser.runtime.sendMessage({ type: "recorder_move_batch", points: takeMoveSamples() }).catch(() => {});
  }, 1000);
  captureListeners = () => {
    document.removeEventListener("click", onClick, true);
    document.removeEventListener("input", onInput, true);
    document.removeEventListener("mousemove", onMouseMove, true);
    if (moveFlushTimer != null) window.clearInterval(moveFlushTimer);
    moveFlushTimer = 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;
}

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 mousemove keeps JS hover/move handlers live during replay;
  // CSS :hover can't be triggered synthetically — that's a browser limitation
  const now = performance.now();
  if (now - state.lastDispatch < 30) return;
  state.lastDispatch = now;
  const target = document.elementFromPoint(x, y);
  if (target && target.id !== "ltt-cursor-host") {
    target.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: y, bubbles: true }));
  }
}

function startReplayCursor(track: CursorState["track"]) {
  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(), lastDispatch: 0 };
  cursor = state;
  const points = track.points;
  let index = 0;
  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[0].x * (window.innerWidth / track.viewport.w), points[0].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":
      getOverlay().startPicker();
      sendResponse({ ok: true });
      return true;

    case "recorder_state": {
      const recording = Boolean((message as { recording?: unknown }).recording);
      getOverlay().setRecorderState(recording);
      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_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"]);
      sendResponse({ ok: true });
      return true;

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

    case "show_flash":
      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;
  }
});