Let the extension update itself, and host the dashboard

Chrome has no update channel for an unpacked extension, so every pull left one
manual step: click reload on its card. The dashboard now publishes the version
its own checkout ships at /api/autobuyer/status, and the extension reloads
itself when it is behind — chrome.runtime.reload() re-reads the folder from
disk, which the update check has already pulled into. It only ever runs code
that is on disk, so a reload that changes nothing is reported and waited out
rather than repeated; a dashboard that is behind is ignored.

Also adds the deploy stack for running the dashboard on the droplet, next to
the autotrader one: it joins that nginx's network and renders a server block
into its conf.d rather than standing up a second ingress. AUTOFIRMER_DB moves
the SQLite file onto a volume, since the working directory in a container is
replaced on every deploy.

And waitFor gained `hidden`: an absent gate matched on DOM presence only, so a
modal that closes by going display:none could never satisfy it. The extension
grew a visibility probe for this — locate cannot answer it, because absent and
hidden are both errors there and a wait gate has to tell them apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 12:30:59 -05:00
co-authored by Claude Opus 5
parent 1f4459985a
commit d1e8668440
18 changed files with 637 additions and 28 deletions
+128 -1
View File
@@ -255,6 +255,50 @@ function pageLocate(selector, index) {
};
}
/** 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) {
@@ -714,6 +758,23 @@ async function serveLocateRequest(cfg) {
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.
@@ -749,10 +810,11 @@ async function serveLocateRequest(cfg) {
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}`);
const status = await res.json();
status = await res.json();
pendingLocate = Number(status.pendingLocate) || 0;
} catch (err) {
setBadge(false);
@@ -763,6 +825,16 @@ async function tick() {
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
@@ -782,6 +854,61 @@ async function tick() {
}
}
// ── 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;
}
/** Reload ourselves when the dashboard ships a newer build than the one running.
*
* Chrome has no update channel for an unpacked extension — it reads the folder
* once at startup and never looks again — but chrome.runtime.reload() does
* re-read it from disk, and the updater is already pulling new files into that
* folder every few minutes. This closes the last manual step between the two.
*
* It can only ever run code that is already on disk, so a dashboard that is
* ahead of this machine's pull would have us reload into the same version over
* and over. Hence one attempt per version pair: if we come back unchanged, say
* so and wait for the pull instead of restarting forever. A dashboard that is
* *behind* is left alone entirely — 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 (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 `
+ `update. 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() {