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:
co-authored by
Claude Opus 5
parent
1f4459985a
commit
d1e8668440
+128
-1
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user