/**
* 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";
}
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;
}
// ---------- 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;
}
function replayStep(data: Record<string, unknown>): { ok: boolean } {
const target = resolveRecordedElement(data.element);
if (!target) return { ok: false };
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) {
attachCaptureListeners();
void browser.runtime
.sendMessage({ type: "recorder_environment", environment: collectEnvironment() })
.catch(() => {});
} else {
detachCaptureListeners();
}
sendResponse({ ok: true });
return true;
}
case "replay_step": {
const result = replayStep((msg.data ?? {}) as Record<string, unknown>);
sendResponse({ ok: result.ok });
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;
}
});