/**
* 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";
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";
}
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);
captureListeners = () => {
document.removeEventListener("click", onClick, true);
document.removeEventListener("input", onInput, true);
for (const [element, pending] of pendingInputs) window.clearTimeout(pending.timer);
pendingInputs.clear();
};
}
function detachCaptureListeners() {
if (!captureListeners) return;
captureListeners();
captureListeners = null;
}
// ---------- 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) {
attachCaptureListeners();
void browser.runtime
.sendMessage({ type: "recorder_environment", environment: collectEnvironment() })
.catch(() => {});
} else {
detachCaptureListeners();
}
sendResponse({ ok: true });
return true;
}
default:
return true;
}
});