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