Files
autofirmer-expanded/extension/background.js
T
Brandon LiandClaude Opus 5 3ac9fe060f Raise the browser the extension reported, and add a diagnostics script
focus.py raised the first browser in its list that happened to be running. On a
machine with both Chrome and Edge installed that is a coin flip, and losing it is
silent: coordinates measured from a tab in one browser, the click delivered into
a window of the other. It presents as selectors failing for no reason. A VPS with
both installed hit exactly this.

The extension now reports which browser is hosting it, and that travels with the
measurement, so the clicker raises the browser the coordinates actually came
from. Asking for a browser that is not running now fails honestly instead of
quietly raising a different one, and the verification step rejects the wrong
browser coming forward. Chromium, Opera and Vivaldi are recognised alongside
Chrome, Edge and Brave, on both platforms.

diagnose.py answers the question a remote desktop makes hard: whether the mouse
is really moving or the viewer simply is not drawing it. It moves the cursor and
reads the position back from the OS, so the answer does not depend on anything
being rendered, and it reports DPI mode, screen size, whether this is an RDP
session, and whether the browser can be raised at all. Nothing is clicked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:13:18 -05:00

444 lines
19 KiB
JavaScript

/* 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();
});
}
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
// `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();