/**
* 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, 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);
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("pagehide", onPageHide);
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"], 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 };
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":
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"], 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 (gap-capped) step clock
// after every executed step, so the cursor doesn't drift behind
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":
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;
}
});