Three changes that ride together because they touch the same surface. A control link to the master. lib/master-link.ts dials out to <master>/ws/instance and holds it open, so the dashboard can trigger a trade or queue an AutoBuyer run here. Commands call the same functions the local pages call — runTrade, startScheduler, queueRun — so nothing reaches the broker by a path the UI does not also use. Reports prefer the socket and fall back to the existing POST, which keeps older masters working unchanged. startMasterLink is idempotent, and that matters more than it looks: getClients() is lazy and starts the link, so the first command re-enters it from inside a command the live socket is waiting to ack. Tearing the socket down there loses the ack and reports a trade that actually executed as a failure — which invites pressing the button twice. queueRun is lifted out of the automations route so the socket and HTTP share one copy of the input clamping that decides how many times a purchase fires. Page capture loses its switch and gains a scope. It now runs every tick, but only against the active tab of the last-focused window that matches host_permissions — so it follows the page in front of you and stops when you switch away, where before it would fall back to any firm tab left open in the background. autobuyer_enabled is gone entirely. The AutoBuyer is ready whenever the runner is; a run happens because someone asked for one. That does mean a queued run executes as soon as clicker/runner.py claims it, with no second gate — stopping the runner is now the way to make a machine idle. Extension to 0.10.0; it needs a manual reload at chrome://extensions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
545 lines
23 KiB
JavaScript
545 lines
23 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);
|
|
|
|
// 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();
|
|
});
|
|
}
|
|
|
|
/** 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,
|
|
};
|
|
}
|
|
|
|
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
|
|
|
|
// 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 fetch(`${cfg.apiBase}/api/autobuyer/locate/result`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: request.id, result, error: 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 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();
|
|
|
|
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();
|
|
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 });
|
|
|
|
// 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 });
|
|
}
|
|
}
|
|
|
|
// ── 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();
|