Files
autofirmer-expanded/extension/background.js
T
Brandon LiandClaude Opus 5 035f93d220 Ship the extension as a signed CRX from our own update server
The dashboard-hosting stack from the previous commit is gone; it was answering
a question nobody asked. What was actually needed is Chrome's real update
channel, which an unpacked extension does not have and cannot be given.

So the extension is now signed into a CRX with an update manifest beside it,
served from the droplet, and installed on the trading machines by policy
(ExtensionInstallForcelist). From there Chrome installs it, updates it on its
own schedule, and puts it back if it goes missing. The manifest gained the
`key` that fixes the extension id, so an unpacked development load keeps the
same identity as the published build.

The self-update check now branches on how it was installed: policy installs ask
Chrome to check the update server now rather than waiting out its five-hour
cycle, and unpacked loads keep the reload-from-disk path. chrome.management
.getSelf() tells the two apart and needs no permission to do it.

The signing key lives at /opt/autofirmer-updates/key.pem on the droplet and
nowhere else. It is the extension's identity: a different key is a different
extension, and no browser would take it as an update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:50:01 -05:00

995 lines
42 KiB
JavaScript

/* AutoFirmer Capture — MV3 service worker.
*
* Loop: every tick, scrape the tab you are looking at and POST it to
* /api/autobuyer/capture. The dashboard's AutoBuyer page renders whatever
* landed there.
*
* Nothing here has a switch. Capture is scoped instead: only the ACTIVE tab of
* the last-focused window, and only when that tab is one of the firm sites in
* host_permissions. Sit on any other page and nothing is read or sent.
*
* The locate queue is served whenever clicks are waiting. The AutoBuyer is
* ready at all times; a run happens because someone asked for one.
*/
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() } });
}
/** `!` when the dashboard is unreachable, blank otherwise. A blank badge is the
* working state — there is nothing to switch on, so nothing to advertise. */
function setBadge(connected) {
chrome.action.setBadgeText({ text: connected ? '' : '!' });
chrome.action.setBadgeBackgroundColor({ color: '#ef4444' });
}
// ── 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));
}
/** The firm tab you are actually looking at, or nothing.
*
* Deliberately no fallback to "some other firm tab that happens to be open":
* capture should report the page in front of you, not a stale background tab
* you left on a broker site hours ago. Chrome does the pattern matching, so a
* tab outside host_permissions cannot be selected here at all.
*/
async function pickTab() {
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
url: firmPatterns(),
});
return tab && /^https?:/.test(tab.url || '') ? tab : undefined;
}
async function captureAndSend(cfg) {
const tab = await pickTab();
if (!tab) {
await setState({ connected: true, error: null, waiting: 'Not on a firm page — nothing captured' });
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,
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);
// Enough to identify it. A bare tag name — the common case for a modal
// backdrop or a captcha overlay, neither of which carries an id — says
// nothing about which overlay is in the way. SVG's className is an object,
// not a string, hence the guard.
const describeNode = (node) => {
if (!node) return null;
const classes = typeof node.className === 'string'
? node.className.trim().split(/\s+/).filter(Boolean).slice(0, 4).map((c) => `.${c}`).join('')
: '';
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}${classes}`;
};
// 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 ? describeNode(atPoint) : 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,
};
}
/** Runs in the page. Answers "is this element there, and can it be seen?" —
* no scrolling, no measuring, no error when it is missing. pageLocate cannot
* answer this: absent and hidden both come back as errors there, and a wait
* gate has to tell those two apart from a broken pipeline. */
function pageVisible(selector, index) {
const matches = document.querySelectorAll(selector);
const el = matches[index];
if (!el) {
return {
op: 'visible', present: false, visible: false, matchCount: matches.length,
reason: matches.length ? `matched ${matches.length} element(s), no index ${index}` : 'no element matches',
};
}
// checkVisibility covers what CSS can do to make an element invisible while
// leaving it in the DOM — display, visibility, content-visibility, opacity —
// including when the property sits on an ancestor. Both spellings of the
// options are passed: Chrome renamed them, and unknown keys are ignored.
const rendered = typeof el.checkVisibility === 'function'
? el.checkVisibility({
checkVisibilityCSS: true, checkOpacity: true,
visibilityProperty: true, opacityProperty: true,
contentVisibilityAuto: true,
})
: true;
// Size is not part of that check, and a container collapsed to nothing is
// just as invisible as one that was hidden outright.
const r = el.getBoundingClientRect();
const sized = r.width > 0 && r.height > 0;
return {
op: 'visible',
present: true,
visible: rendered && sized,
matchCount: matches.length,
tag: el.tagName.toLowerCase(),
text: (el.textContent || '').trim().slice(0, 80),
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
reason: rendered ? (sized ? null : 'zero size') : 'hidden by CSS',
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,
};
}
// ── Captcha: read the widget, then hand a solved token back to the page ─────
/** Runs in the page's MAIN world. Describes the captcha widget well enough for
* 2captcha to solve it, or reports that there isn't one yet.
*
* Three sources, best first: the render hook (the only one that sees
* programmatically-passed parameters), the widget's own data- attributes, and
* finally the challenge iframe, whose URL carries the sitekey when nothing else
* does. */
function pageCaptchaInfo(scopeSelector, wanted) {
const root = scopeSelector ? document.querySelector(scopeSelector) : document;
if (!root) return { error: `No element matches ${scopeSelector}` };
const KINDS = {
turnstile: { cls: '.cf-turnstile', frame: 'challenges.cloudflare.com' },
recaptcha: { cls: '.g-recaptcha', frame: '/recaptcha/' },
hcaptcha: { cls: '.h-captcha', frame: 'hcaptcha.com' },
};
const want = (kind) => !wanted || wanted === 'auto' || wanted === kind;
const enterprise = !!document.querySelector('script[src*="recaptcha/enterprise"]');
// Cloudflare's iframe has no sitekey parameter — it is a path segment, and
// every Turnstile key starts with 0x.
const fromFrameUrl = (kind, src) => {
try {
const url = new URL(src, location.href);
if (kind === 'recaptcha') return url.searchParams.get('k');
if (kind === 'hcaptcha') return url.searchParams.get('sitekey');
return url.pathname.split('/').find((part) => /^0x[A-Za-z0-9_-]{8,}$/.test(part)) || null;
} catch {
return null;
}
};
// Newest first. A page that renders a fresh widget per modal leaves the
// earlier ones in the registry — they are never unregistered — and the one
// that matters is the one just rendered, not the first ever seen.
for (const widget of [...(window.__autofirmerCaptcha?.widgets || [])].reverse()) {
const params = widget.params || {};
if (!want(widget.kind) || !params.sitekey) continue;
return {
provider: widget.kind,
sitekey: params.sitekey,
pageurl: location.href,
action: params.action || '',
data: params.cData || '',
pagedata: params.chlPageData || '',
invisible: params.size === 'invisible',
enterprise,
userAgent: navigator.userAgent,
source: 'render hook',
};
}
for (const kind of Object.keys(KINDS)) {
if (!want(kind)) continue;
const el = root.querySelector(`${KINDS[kind].cls}[data-sitekey]`);
if (!el) continue;
return {
provider: kind,
sitekey: el.getAttribute('data-sitekey'),
pageurl: location.href,
action: el.getAttribute('data-action') || '',
data: el.getAttribute('data-cdata') || '',
pagedata: '',
invisible: el.getAttribute('data-size') === 'invisible',
enterprise,
userAgent: navigator.userAgent,
source: 'data-sitekey',
};
}
const frames = Array.from(root.querySelectorAll('iframe'));
for (const kind of Object.keys(KINDS)) {
if (!want(kind)) continue;
for (const frame of frames) {
const src = frame.getAttribute('src') || '';
if (!src.includes(KINDS[kind].frame)) continue;
const sitekey = fromFrameUrl(kind, src);
if (!sitekey) continue;
return {
provider: kind,
sitekey,
pageurl: location.href,
action: '',
data: '',
pagedata: '',
invisible: false,
enterprise,
userAgent: navigator.userAgent,
source: 'challenge iframe',
};
}
}
return { error: 'No captcha widget on the page yet' };
}
/** Runs in the page's MAIN world. Puts a solved token where the page looks for
* it, then tells the page it arrived.
*
* Both halves matter. The hidden response field is what the form posts, but
* most pages enable their submit button from the widget's callback and never
* read the field until then — so filling it alone leaves the page looking
* unsolved. Only the FIRST callback found is fired: they are the page's own
* submit handlers, and calling several would risk sending the form twice. */
function pageCaptchaSubmit(token, provider) {
const FIELDS = {
turnstile: ['cf-turnstile-response', 'g-recaptcha-response'],
recaptcha: ['g-recaptcha-response'],
hcaptcha: ['h-captcha-response', 'g-recaptcha-response'],
};
const targets = new Set();
for (const name of (FIELDS[provider] || FIELDS.recaptcha)) {
document.querySelectorAll(`[name="${name}"], #${name}`).forEach((el) => targets.add(el));
}
// Turnstile names its hidden input per widget: cf-chl-widget-<id>_response.
document.querySelectorAll('input[name^="cf-chl-widget-"]').forEach((el) => targets.add(el));
let filled = 0;
const fieldIds = [];
for (const el of targets) {
el.value = token;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
fieldIds.push(el.id || `(${el.getAttribute('name')})`);
filled++;
}
// reCAPTCHA numbers its response fields by widget id: the first widget gets
// #g-recaptcha-response, the second #g-recaptcha-response-1, and so on. That
// suffix is the only reliable link from the field the open modal is actually
// showing back to the client object holding ITS callback.
//
// Guessing wrong is invisible from the outside and looks exactly like the
// site ignoring a good token: the callback runs, but on a widget belonging
// to a modal that has already closed, so nothing stores the token and the
// confirm button never enables.
let liveWidgetId = null;
for (const el of targets) {
const match = /^g-recaptcha-response(?:-(\d+))?$/.exec(el.id || '');
if (match) { liveWidgetId = Number(match[1] || 0); break; }
}
let called = null;
const invoke = (fn, where) => {
if (called || typeof fn !== 'function') return false;
try {
fn(token);
called = where;
return true;
} catch (err) {
// The page's own handler threw. That is its business, but the token
// did reach it, so don't go looking for another one to call.
called = `${where} (threw: ${err.message})`;
return true;
}
};
// Newest first, for the same reason detection is: firing the callback of a
// widget belonging to a modal that has already closed hands the token to
// nobody, and the live widget never hears that it was solved — which looks
// from the outside like the site simply ignoring a correct token.
const hookWidgets = [...(window.__autofirmerCaptcha?.widgets || [])].reverse();
let candidates = 0;
let clientKeys = [];
for (const widget of hookWidgets) {
if (provider && provider !== 'auto' && widget.kind !== provider) continue;
candidates++;
if (called) continue;
const cb = (widget.params || {}).callback;
if (typeof cb === 'function') invoke(cb, `${widget.kind} render callback`);
else if (typeof cb === 'string') invoke(window[cb], cb);
}
if (!called) {
for (const el of document.querySelectorAll('[data-callback]')) {
const name = el.getAttribute('data-callback');
if (invoke(window[name], `data-callback ${name}`)) break;
}
}
// reCAPTCHA rendered declaratively keeps its callback in a private config
// object and nowhere else. The keys around it are minified; `callback` is not.
if (window.___grecaptcha_cfg?.clients) {
// Clients accumulate: reCAPTCHA adds one per render and removes none, so
// after the second modal there are two and only one is live. Try the one
// the response field named, then the rest newest-first as a fallback.
const all = Object.entries(window.___grecaptcha_cfg.clients);
clientKeys = all.map(([key]) => key);
candidates += all.length;
const ordered = all
.sort(([a], [b]) => Number(b) - Number(a))
.sort(([a], [b]) => (Number(a) === liveWidgetId ? -1 : Number(b) === liveWidgetId ? 1 : 0));
for (const [key, client] of ordered) {
if (called) break;
// Depth-limited walk: the keys around it are minified, `callback`
// is not.
const seen = new Set();
const queue = [client];
let budget = 2000;
while (queue.length && !called && budget-- > 0) {
const node = queue.shift();
if (!node || typeof node !== 'object' || seen.has(node)) continue;
seen.add(node);
for (const [prop, value] of Object.entries(node)) {
if (typeof value === 'function') {
if (/callback/i.test(prop) && invoke(value, `grecaptcha ${prop} (widget ${key})`)) break;
} else if (value && typeof value === 'object') {
queue.push(value);
}
}
}
}
}
if (!filled && !called) {
return { error: 'nowhere to put the token — no response field and no callback on the page' };
}
// `candidates` is the diagnostic that matters when a token is accepted and
// the page does nothing: more than one means the page has stale widgets, and
// the token went to whichever this picked.
return {
filled,
called,
candidates,
// The diagnostic that matters when a token is accepted and the page does
// nothing: which field carried it, and which widgets were on offer.
fieldIds,
liveWidgetId,
clientKeys,
provider,
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 };
}
/** Hand an answer — or a failure — back to whoever queued the request. */
async function postLocateResult(cfg, id, result, error) {
await fetch(`${cfg.apiBase}/api/autobuyer/locate/result`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, result, error }),
});
}
/** 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 postLocateResult(cfg, request.id, result, null);
return true;
}
// Captcha ops are the same shape: no element is measured, the page is
// read for its widget parameters or handed a token 2captcha solved.
// Both run in the page's MAIN world — the isolated world cannot see the
// page's own `turnstile` / `grecaptcha` objects, nor the render hook's
// registry, and those hold the callbacks a token has to be given to.
// allFrames because the widget is often in a same-origin sub-frame;
// Chrome skips frames outside host_permissions, so the challenge iframe
// itself is never touched.
if (request.options && (request.options.op === 'captchaInfo' || request.options.op === 'captchaToken')) {
const isInfo = request.options.op === 'captchaInfo';
const injections = await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: true },
world: 'MAIN',
func: isInfo ? pageCaptchaInfo : pageCaptchaSubmit,
args: isInfo
? [request.options.scopeSelector || '', request.options.provider || 'auto']
: [String(request.options.token || ''), request.options.provider || 'auto'],
});
const answers = injections.map((i) => i?.result).filter(Boolean);
const found = answers.find((a) => !a.error);
if (!found) {
throw new Error(answers[0]?.error || 'Captcha injection returned nothing');
}
result = { ...found, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
await postLocateResult(cfg, request.id, result, null);
return true;
}
// A visibility probe is a read, not a lookup: it reports on the element
// instead of failing when there isn't one, so it never retries and never
// errors on a missing selector — being missing is the answer.
if (request.options && request.options.op === 'visible') {
const [probe] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: pageVisible,
args: [request.selector, request.index || 0],
});
const out = probe?.result;
if (!out) throw new Error('Visibility injection returned nothing');
result = { ...out, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
await postLocateResult(cfg, request.id, result, 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 postLocateResult(cfg, request.id, result, error);
return true;
}
async function tick() {
const cfg = await getConfig();
let status;
try {
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
status = await res.json();
pendingLocate = Number(status.pendingLocate) || 0;
} catch (err) {
setBadge(false);
await setState({ connected: false, error: `Cannot reach ${cfg.apiBase} — ${err.message}` });
return;
}
setBadge(true);
await setState({ connected: true });
// Only when nothing is queued: a reload mid-run drops the locate the runner
// is blocked on. There is no hurry — the next tick is seconds away.
if (!pendingLocate) {
try {
await maybeSelfUpdate(status.extensionVersion);
} catch (err) {
await setState({ connected: true, error: `Self-update check: ${err.message}` });
}
}
// 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, error: `Locate queue: ${err.message}` });
}
try {
await captureAndSend(cfg);
} catch (err) {
await setState({ connected: true, error: err.message });
}
}
// ── Keeping ourselves current ───────────────────────────────────────────────
/** -1 / 0 / 1 over dotted numeric versions; missing parts count as zero. */
function compareVersions(a, b) {
const pa = String(a).split('.').map(Number);
const pb = String(b).split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff) return diff < 0 ? -1 : 1;
}
return 0;
}
/** How we were installed: 'admin' when Chrome policy pulled us from the update
* server, 'development' when somebody loaded the folder unpacked. getSelf is
* the one chrome.management call that needs no permission. */
async function installType() {
try {
const self = await chrome.management.getSelf();
return self.installType || 'development';
} catch {
return 'development';
}
}
/** chrome.runtime.requestUpdateCheck as a promise; null if the call is not
* available at all. */
function requestUpdateCheck() {
return new Promise((resolve) => {
try {
chrome.runtime.requestUpdateCheck((result) => {
if (chrome.runtime.lastError) return resolve(null);
resolve(result?.status || 'no_update');
});
} catch {
resolve(null);
}
});
}
/** Get onto the newer build the dashboard is advertising.
*
* Two different mechanisms, because there are two ways this extension gets
* installed. A policy install has a real update channel: Chrome polls the CRX
* on the droplet on its own schedule, and all we add is "check now" so a
* release lands in seconds rather than within five hours.
*
* An unpacked load has no channel at all — Chrome reads the folder once and
* never looks again — but chrome.runtime.reload() re-reads it from disk, which
* the update check has already pulled into. That can only ever run code that is
* already there, so a dashboard ahead of this machine's pull would have us
* reload into the same version forever. Hence one attempt per version pair:
* come back unchanged and it says so and waits. A dashboard that is *behind* is
* left alone either way — downgrading is not something we can do. */
async function maybeSelfUpdate(expected) {
const running = chrome.runtime.getManifest().version;
const { updateAttempt } = await chrome.storage.local.get('updateAttempt');
if (!expected || compareVersions(expected, running) <= 0) {
// Nothing to do — and if we got here off the back of a reload, that
// reload worked, so clear the note it left in the popup.
if (updateAttempt) {
await chrome.storage.local.remove('updateAttempt');
await setState({ error: '' });
}
return;
}
if ((await installType()) !== 'development') {
const status = await requestUpdateCheck();
// Chrome swaps the extension out from under us when the CRX arrives, so
// there is nothing to do afterwards but say what was asked for.
await setState({
error: status === 'throttled'
? `Running v${running}, v${expected} is published — Chrome is rate-limiting `
+ `update checks and will pick it up shortly.`
: `Running v${running}, asked Chrome to fetch v${expected} (${status || 'no answer'}).`,
});
return;
}
if (updateAttempt && updateAttempt.expected === expected && updateAttempt.running === running) {
await setState({
error: `Running v${running}, the dashboard ships v${expected}. Reloaded once and `
+ `the files on disk were still the old ones — waiting for the next pull. `
+ `If this sticks, pull and reload the card in chrome://extensions.`,
});
return;
}
// Recorded *before* the reload: the worker does not come back from this call,
// so anything written after it would never be written at all.
await chrome.storage.local.set({ updateAttempt: { expected, running, at: Date.now() } });
await setState({ error: `Reloading to pick up v${expected} (running v${running})` });
chrome.runtime.reload();
}
// ── 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();