/* AutoFirmer Capture — MV3 service worker. * * Loop: poll GET /api/autobuyer/status. While `enabled` is true, scrape the * target tab's HTML and POST it to /api/autobuyer/capture. The dashboard's * AutoBuyer page renders whatever landed there. */ const DEFAULTS = { apiBase: 'http://localhost:3000', pollSeconds: 3, // Optional CSS selector — matching elements get their on-screen position // measured alongside the HTML. elementSelector: '', }; let timer = null; let pendingLocate = 0; // ── Config / state ────────────────────────────────────────────────────────── async function getConfig() { const stored = await chrome.storage.local.get(Object.keys(DEFAULTS)); const cfg = { ...DEFAULTS, ...stored }; cfg.apiBase = String(cfg.apiBase).replace(/\/+$/, ''); return cfg; } /** Mirror the worker's status into storage so the popup can render it. */ async function setState(patch) { const { state: prev } = await chrome.storage.local.get('state'); await chrome.storage.local.set({ state: { ...(prev || {}), ...patch, updatedAt: Date.now() } }); } function setBadge(connected, enabled) { chrome.action.setBadgeText({ text: !connected ? '!' : enabled ? 'ON' : '' }); chrome.action.setBadgeBackgroundColor({ color: !connected ? '#ef4444' : '#22c55e' }); } // ── The injected scraper ──────────────────────────────────────────────────── /** Runs in the page's own world. Must be self-contained — nothing from this * file's scope is available inside it. */ function pageCapture(selector) { // getBoundingClientRect() measures from the top-left of the viewport, so // rect.left / rect.top ARE the element's position relative to the window. // + scrollX/scrollY -> position within the document // + screenX/screenY and the chrome height -> absolute desktop coordinates, // which is what an OS-level clicker needs. const chromeHeight = window.outerHeight - window.innerHeight; const elements = selector ? Array.from(document.querySelectorAll(selector)).slice(0, 200).map((el, index) => { const r = el.getBoundingClientRect(); return { index, tag: el.tagName.toLowerCase(), id: el.id || null, text: (el.textContent || '').trim().slice(0, 80), // Off-screen or display:none elements still return a rect — all zeroes. visible: r.width > 0 && r.height > 0, inViewport: r.top < window.innerHeight && r.bottom > 0 && r.left < window.innerWidth && r.right > 0, viewport: { x: r.left, y: r.top, width: r.width, height: r.height }, page: { x: r.left + window.scrollX, y: r.top + window.scrollY }, // Centre of the element in desktop coordinates (CSS pixels — // multiply by devicePixelRatio for physical pixels on HiDPI). screen: { x: window.screenX + r.left + r.width / 2, y: window.screenY + chromeHeight + r.top + r.height / 2, }, }; }) : []; return { url: location.href, title: document.title, html: document.documentElement.outerHTML, viewport: { scrollX: window.scrollX, scrollY: window.scrollY, innerWidth: window.innerWidth, innerHeight: window.innerHeight, outerWidth: window.outerWidth, outerHeight: window.outerHeight, screenX: window.screenX, screenY: window.screenY, chromeHeight, devicePixelRatio: window.devicePixelRatio, }, elements, }; } // ── Capture pipeline ──────────────────────────────────────────────────────── /** The sites this extension is allowed to touch, taken from the manifest rather * than from a setting. * * These are the same hosts host_permissions grants, so they cannot drift out of * sync with what the extension can actually read, and adding a firm — which * means adding its host to the manifest anyway — scopes capture automatically. * localhost is filtered out: that is the dashboard, and capturing it would just * mirror our own output back. */ function firmPatterns() { return chrome.runtime.getManifest().host_permissions .filter((p) => !/\/\/(localhost|127\.0\.0\.1)/.test(p)); } async function pickTab(cfg) { const tabs = (await chrome.tabs.query({ url: firmPatterns() })) .filter((t) => /^https?:/.test(t.url || '')); if (tabs.length === 0) return undefined; // Prefer the one being looked at, so with two firms open the capture follows // attention rather than tab order. return tabs.find((t) => t.active) ?? tabs[0]; } async function captureAndSend(cfg) { const tab = await pickTab(cfg); if (!tab) { await setState({ connected: true, enabled: true, error: null, waiting: 'No eligible tab to capture' }); return; } const [injection] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: pageCapture, args: [cfg.elementSelector || ''], }); const result = injection?.result; if (!result) throw new Error('Injection returned nothing'); const res = await fetch(`${cfg.apiBase}/api/autobuyer/capture`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...result, // Stamp the build so the dashboard side can tell which version of this // worker is actually live — Chrome keeps running the old one until the // extension is reloaded, which is invisible from the server otherwise. viewport: { ...result.viewport, extVersion: chrome.runtime.getManifest().version }, capturedAt: Date.now(), }), }); if (!res.ok) throw new Error(`Capture POST failed: HTTP ${res.status}`); await setState({ connected: true, enabled: true, error: null, waiting: null, lastCapture: { url: result.url, title: result.title, bytes: result.html.length, at: Date.now() }, }); } /** Which browser is hosting this extension. * * The clicker has to raise *this* browser before clicking, and picking by list * order gets it wrong the moment two are installed — it would raise Edge while * the coordinates came from a tab in Chrome, landing every click in the wrong * window. So the answer travels with the measurement. */ function detectBrowser() { const ua = navigator.userAgent || ''; if (/\bEdg\//.test(ua)) return 'edge'; if (/\bOPR\//.test(ua)) return 'opera'; if (/\bVivaldi\//.test(ua)) return 'vivaldi'; try { if (navigator.brave) return 'brave'; // Brave otherwise reports as Chrome } catch { /* not Brave */ } return 'chrome'; } // ── Locate: turn a CSS selector into desktop coordinates ──────────────────── /** Runs in the page. Scrolls the element into view, then reports where it ended up. */ function pageLocate(selector, index) { const matches = document.querySelectorAll(selector); if (matches.length === 0) return { error: `No element matches ${selector}` }; const el = matches[index]; if (!el) return { error: `Selector ${selector} matched ${matches.length} element(s), no index ${index}` }; // Centre it in the viewport first — an element scrolled off-screen has a rect, // but clicking those coordinates would hit whatever is actually displayed there. el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) { return { error: `Element ${selector} has zero size (hidden or display:none)` }; } if (r.bottom <= 0 || r.top >= window.innerHeight || r.right <= 0 || r.left >= window.innerWidth) { return { error: `Element ${selector} is outside the viewport even after scrolling` }; } const chromeHeight = window.outerHeight - window.innerHeight; const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; // What sits at that point? If it's not our element (or a descendant), something // is covering it — a cookie banner, a modal — and the click would hit that instead. const atPoint = document.elementFromPoint(cx, cy); const covered = atPoint && atPoint !== el && !el.contains(atPoint); // Current contents, so the caller can confirm afterwards that what it typed // actually landed — a field that silently ignored the keystrokes (masked, // read-only, or never focused) is otherwise indistinguishable from success. const isField = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'; const value = isField ? el.value : (el.isContentEditable ? el.innerText : null); return { tag: el.tagName.toLowerCase(), text: (el.textContent || '').trim().slice(0, 80), matchCount: matches.length, value, editable: (isField || el.isContentEditable) && !el.disabled && !el.readOnly, inputType: isField ? (el.type || null) : null, covered: !!covered, coveredBy: covered ? `${atPoint.tagName.toLowerCase()}${atPoint.id ? '#' + atPoint.id : ''}` : null, viewport: { x: r.left, y: r.top, width: r.width, height: r.height }, // Desktop coordinates of the element's centre, in CSS pixels. screen: { x: window.screenX + cx, y: window.screenY + chromeHeight + cy }, // Lets the Python side work out whether the OS uses a different pixel // scale than CSS does (Windows display scaling, some Linux setups). screenSize: { width: window.screen.width, height: window.screen.height }, devicePixelRatio: window.devicePixelRatio, chromeHeight, url: location.href, }; } /** Is the tab already showing this page? Compares origin and path only — * query strings and hashes shouldn't force a reload. */ function alreadyAt(current, target) { try { const a = new URL(current); const b = new URL(target); return a.origin === b.origin && a.pathname.replace(/\/$/, '') === b.pathname.replace(/\/$/, ''); } catch { return false; } } /** Resolve after the tab reports `complete`. A tab that has only just been * created has no DOM to inject into yet. */ function waitForTabLoad(tabId, timeoutMs = 15000) { return new Promise((resolve, reject) => { const started = Date.now(); const check = async () => { let tab; try { tab = await chrome.tabs.get(tabId); } catch { return reject(new Error('the tab was closed while loading')); } if (tab.status === 'complete') return resolve(tab); if (Date.now() - started > timeoutMs) { return reject(new Error(`the page did not finish loading within ${timeoutMs / 1000}s`)); } setTimeout(check, 200); }; check(); }); } /** Runs in the page. Scrolls until the list stops growing, then reports what it * ended up with. * * The scrolling element is often NOT the window — lists like this usually live * in a div with its own overflow, and scrolling the document does nothing at * all. So walk up from a matched item looking for the ancestor that actually * scrolls, and let the caller name one outright when the guess is wrong. */ async function pageScrollToLoad(selector, containerSelector, maxScrolls, settleMs, stableRounds) { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const count = () => document.querySelectorAll(selector).length; function scroller() { if (containerSelector) { const named = document.querySelector(containerSelector); if (!named) return { error: `No element matches container ${containerSelector}` }; return { el: named }; } // An ancestor that can actually scroll: overflow allows it, and there is // more content than fits. let node = document.querySelector(selector)?.parentElement ?? null; while (node && node !== document.body) { const overflow = getComputedStyle(node).overflowY; if (/(auto|scroll)/.test(overflow) && node.scrollHeight > node.clientHeight + 4) { return { el: node }; } node = node.parentElement; } return { el: document.scrollingElement || document.documentElement, isDocument: true }; } const found = scroller(); if (found.error) return { error: found.error }; const el = found.el; const before = count(); let last = before; let stable = 0; let scrolls = 0; while (scrolls < maxScrolls) { const wasAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 4; el.scrollTop = el.scrollHeight; scrolls++; await sleep(settleMs); const now = count(); // Nothing new AND we were already pinned to the bottom: the list is done // growing, not merely slow. stable = (now > last) ? 0 : stable + (wasAtBottom ? 1 : 0); last = now; if (stable >= stableRounds) break; } return { before, after: last, scrolls, exhausted: stable >= stableRounds, container: found.isDocument ? 'document' : (el.className || el.tagName || 'element').toString().slice(0, 60), url: location.href, }; } async function resolveLocateTab(cfg, request) { // Every step carries its firm's pattern; the manifest hosts are the fallback // for a bare request (the CLI's locate without --url). const pattern = request.urlPattern || firmPatterns(); if (pattern) { const tabs = await chrome.tabs.query({ url: pattern }); const tab = tabs.find((t) => /^https?:/.test(t.url || '')); if (tab) return { tab, opened: false }; // Nothing matching is open. Open it rather than failing the run — but only // to the URL the firm declared, never to something a request supplied that // we have no host permission for. if (request.openUrl) { const created = await chrome.tabs.create({ url: request.openUrl, active: true }); await waitForTabLoad(created.id); return { tab: await chrome.tabs.get(created.id), opened: true }; } throw new Error(`No open tab matches ${pattern}, and no URL is configured to open`); } const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); if (!tab || !/^https?:/.test(tab.url || '')) throw new Error('No eligible active tab'); return { tab, opened: false }; } /** Returns true if a request was handled, false if the queue was empty. */ async function serveLocateRequest(cfg) { const claim = await fetch(`${cfg.apiBase}/api/autobuyer/locate/claim`, { method: 'POST' }); if (!claim.ok) throw new Error(`Claim failed: HTTP ${claim.status}`); const { request } = await claim.json(); if (!request) return false; let result = null; let error = null; try { let { tab, opened } = await resolveLocateTab(cfg, request); // A navigate step points the tab at a specific page first. Skip it when we // are already there — reloading would throw away page state for nothing, // and the common case is that the tab is on the right page already. if (request.navigateUrl && !alreadyAt(tab.url, request.navigateUrl)) { await chrome.tabs.update(tab.id, { url: request.navigateUrl }); await waitForTabLoad(tab.id); tab = await chrome.tabs.get(tab.id); opened = true; // treat as a cold load: the app still has to mount } // The reported coordinates are only worth anything if that tab is the one // actually visible: raise its window and bring the tab to the front. // Only un-minimise — passing state:'normal' unconditionally would drop a // maximised or fullscreen window out of that state on every single click. const win = await chrome.windows.get(tab.windowId); const focus = win.state === 'minimized' ? { focused: true, state: 'normal' } : { focused: true }; await chrome.windows.update(tab.windowId, focus); await chrome.tabs.update(tab.id, { active: true }); await new Promise((r) => setTimeout(r, 250)); // let the OS finish raising it // A scroll request is a different operation on the same channel: no // element is measured, the page is just walked to the bottom. if (request.options && request.options.op === 'scrollToLoad') { const [scrolled] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: pageScrollToLoad, args: [ request.selector, request.options.containerSelector || '', Number(request.options.maxScrolls) || 25, Number(request.options.settleMs) || 800, Number(request.options.stableRounds) || 2, ], }); const out = scrolled?.result; if (!out) throw new Error('Scroll injection returned nothing'); if (out.error) throw new Error(out.error); result = { ...out, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() }; await fetch(`${cfg.apiBase}/api/autobuyer/locate/result`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: request.id, result, error: null }), }); return true; } // `complete` only means the document loaded — a React app still has to // mount and paint. Retry briefly rather than declaring the element missing, // with a longer budget when we just opened the page from cold. const deadline = Date.now() + (opened ? 8000 : 2500); let out; for (;;) { const [injection] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: pageLocate, args: [request.selector, request.index || 0], }); out = injection?.result; if (!out) throw new Error('Injection returned nothing'); if (!out.error) break; if (Date.now() >= deadline) throw new Error(out.error); await new Promise((r) => setTimeout(r, 350)); } result = { ...out, tabId: tab.id, windowId: tab.windowId, openedTab: opened, browser: detectBrowser(), }; } catch (err) { error = err.message; } await fetch(`${cfg.apiBase}/api/autobuyer/locate/result`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: request.id, result, error }), }); return true; } async function tick() { const cfg = await getConfig(); let enabled; try { const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const status = await res.json(); enabled = !!status.enabled; pendingLocate = Number(status.pendingLocate) || 0; } catch (err) { setBadge(false, false); await setState({ connected: false, enabled: false, error: `Cannot reach ${cfg.apiBase} — ${err.message}` }); return; } setBadge(true, enabled); if (!enabled) { await setState({ connected: true, enabled: false, error: null, waiting: null }); return; } // Serve queued clicks before capturing — the Python side is waiting on these, // and a capture of a megabyte page shouldn't sit in front of them. // Failures inside a request are reported back to the caller; a failure of the // queue plumbing itself lands here, and must not take the capture loop down. try { for (let i = 0; i < pendingLocate; i++) { if (!(await serveLocateRequest(cfg))) break; } } catch (err) { await setState({ connected: true, enabled: true, error: `Locate queue: ${err.message}` }); } try { await captureAndSend(cfg); } catch (err) { await setState({ connected: true, enabled: true, error: err.message }); } } // ── Scheduling ────────────────────────────────────────────────────────────── async function restart() { if (timer) clearInterval(timer); const cfg = await getConfig(); // Re-check after the await: restart() is called from several places at startup, // and without this a concurrent call leaks an untracked interval — two polling // loops in one worker, capturing everything twice. if (timer) clearInterval(timer); const ms = Math.max(1, Number(cfg.pollSeconds) || DEFAULTS.pollSeconds) * 1000; timer = setInterval(tick, ms); tick(); } chrome.runtime.onInstalled.addListener(restart); chrome.runtime.onStartup.addListener(restart); // The setInterval above only survives while the worker is alive. Each tick makes // an extension API call, which resets the idle timer, so in practice the loop // keeps itself running — this alarm is the safety net that revives it if Chrome // tears the worker down anyway. 30s is the shortest period Chrome honours. chrome.alarms.create('keepalive', { periodInMinutes: 0.5 }); chrome.alarms.onAlarm.addListener(() => { if (timer) tick(); else restart(); }); chrome.storage.onChanged.addListener((changes, area) => { if (area !== 'local') return; if (Object.keys(DEFAULTS).some((k) => k in changes)) restart(); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type !== 'capture-now') return; getConfig() .then(captureAndSend) .then(() => sendResponse({ ok: true })) .catch((err) => sendResponse({ ok: false, error: err.message })); return true; // keep the message channel open for the async response }); restart();