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
+12
View File
@@ -0,0 +1,12 @@
.git
node_modules
.next
*.sqlite
*.sqlite-*
clicker/.venv
clicker/__pycache__
scripts/update.log
scripts/.last-check
scripts/*.png
.env*
*.tsbuildinfo
+3
View File
@@ -63,3 +63,6 @@ scripts/update.log
# clicker virtualenv (Linux/Pi) # clicker virtualenv (Linux/Pi)
clicker/.venv/ clicker/.venv/
scripts/.last-check scripts/.last-check
# The deploy template is meant to be read, unlike a real .env
!deploy/.env.example
+67
View File
@@ -0,0 +1,67 @@
# syntax=docker/dockerfile:1
#
# The hosted dashboard only. The clicker cannot run in here — it drives a real
# mouse and keyboard and needs a desktop session — and neither can the
# extension. Both stay on the Windows machine and talk to this over HTTPS.
#
# Debian rather than Alpine: better-sqlite3 publishes glibc prebuilds, and a
# musl image would compile it from source on every build.
# ── deps ─────────────────────────────────────────────────────────────────────
FROM node:24-slim AS deps
WORKDIR /app
# Only used if npm cannot find a prebuilt better-sqlite3 for this Node ABI.
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
# ── prod deps ────────────────────────────────────────────────────────────────
# A separate, dev-free install for the runtime image.
FROM node:24-slim AS proddeps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ── build ────────────────────────────────────────────────────────────────────
FROM node:24-slim AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ── run ──────────────────────────────────────────────────────────────────────
FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
AUTOFIRMER_DB=/data/autotrader.sqlite
RUN groupadd -g 1001 nodejs && useradd -u 1001 -g nodejs -m nextjs
# The SQLite database lives here, on a named volume. Anything written inside the
# image is thrown away on the next deploy, and WAL mode also needs to create
# -wal and -shm files alongside the database, so the directory must be writable.
RUN mkdir -p /data && chown nextjs:nodejs /data
COPY --from=proddeps --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --chown=nextjs:nodejs package.json next.config.ts ./
COPY --chown=nextjs:nodejs public ./public
# /api/autobuyer/status reads the version out of this manifest and the extension
# reloads itself to match, so the folder has to travel with the server.
COPY --chown=nextjs:nodejs extension ./extension
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/autobuyer/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["npm", "start"]
+34 -5
View File
@@ -131,9 +131,11 @@ pick it up from the server.
`chrome://extensions` → enable **Developer mode** → **Load unpacked** → `chrome://extensions` → enable **Developer mode** → **Load unpacked** →
select the `extension` folder. select the `extension` folder.
Chrome does **not** reload an unpacked extension when its files change. After Chrome does **not** reload an unpacked extension when its files change, and it
pulling updates, click the reload icon on its card — the dashboard shows the has no update channel for one either. The extension therefore checks the version
version it sees, and a mismatch means the reload did not take. the dashboard ships on every poll and reloads *itself* when it is behind, which
re-reads the folder from disk. Clicking the reload icon on its card still works
and is the thing to try when something looks stuck.
Adding a new firm also means adding its host to `host_permissions` in Adding a new firm also means adding its host to `host_permissions` in
`extension/manifest.json` and reloading. Without it the extension cannot read `extension/manifest.json` and reloading. Without it the extension cannot read
@@ -280,6 +282,25 @@ New-NetFirewallRule -DisplayName "AutoFirmer" -Direction Inbound -Protocol TCP -
--- ---
## Hosting the dashboard on a server
The dashboard can run on the droplet instead of on the trading machine, with the
clicker and the extension pointing at it over HTTPS. The clicker and the
extension cannot move — one needs a desktop to send real input to, the other is
your browser.
```bash
cp deploy/.env.example deploy/.env # set FIRMER_HOST
./deploy/bootstrap-tls.sh # first run: certificate, then deploy
./deploy/deploy.sh # every run after that
```
It shares the droplet's existing nginx and certbot rather than standing up its
own, and the database lives on a Docker volume. There is no login on it. See
[deploy/README.md](deploy/README.md) before opening it to the internet.
---
## Updating ## Updating
Once auto-start is installed, nothing here is manual. Every 5 minutes the update Once auto-start is installed, nothing here is manual. Every 5 minutes the update
@@ -318,11 +339,19 @@ To apply an update immediately rather than waiting for the next check:
node scripts\update-check.mjs node scripts\update-check.mjs
``` ```
### The two things that still do not reload themselves ### What reloads itself, and what does not
- **The extension** — click reload on its card in `chrome://extensions`. - **The extension** reloads itself now. `/api/autobuyer/status` reports the
version the dashboard's own checkout ships; when the running build is older,
the extension calls `chrome.runtime.reload()` and comes back on the files the
update check pulled. It only ever runs code already on disk, so if it reloads
before the pull lands it says so in the popup and waits rather than restarting
in a loop. Clicking reload on its card is still the manual fallback.
- **The scheduler is fine now.** It persists to the settings table and resumes - **The scheduler is fine now.** It persists to the settings table and resumes
after a restart, so an update no longer silently stops automated trading. after a restart, so an update no longer silently stops automated trading.
- **The runner** does not. It is a long-lived Python process; the update check
restarts it when anything under `clicker/` changes, and the dashboard warns
when the version it reports is behind what the server expects.
The dashboard reports the version it sees from the extension and the runner, and The dashboard reports the version it sees from the extension and the runner, and
warns when either is behind. Most AutoBuyer bugs that look mysterious are the warns when either is behind. Most AutoBuyer bugs that look mysterious are the
+5 -2
View File
@@ -2,8 +2,11 @@ import { NextResponse } from 'next/server';
/** The Chromium extension calls these routes from a background service worker, /** The Chromium extension calls these routes from a background service worker,
* whose Origin is `chrome-extension://<id>`. The id changes every time the * whose Origin is `chrome-extension://<id>`. The id changes every time the
* unpacked extension is reloaded, so we allow any origin — these routes are * unpacked extension is reloaded, so we allow any origin.
* only ever reachable on the LAN behind the port-3000 firewall rule. */ *
* That was harmless while this only ever answered on the LAN behind the
* port-3000 firewall rule. The hosted dashboard has no such fence — see
* deploy/README.md, "No login". */
export const CORS_HEADERS = { export const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
+32 -2
View File
@@ -1,11 +1,41 @@
import { readFileSync, statSync } from 'fs';
import path from 'path';
import { countPendingLocate } from '@/lib/db'; import { countPendingLocate } from '@/lib/db';
import { corsJson, corsPreflight } from '../cors'; import { corsJson, corsPreflight } from '../cors';
/** The extension version this checkout ships.
*
* Read out of the manifest rather than kept as a constant here: the manifest is
* the file Chrome actually loads, so the two cannot drift apart. Cached on
* mtime because every browser polls this every few seconds and the file only
* changes when an update lands. Null when there is no extension folder beside
* the server — the extension then leaves itself alone, which is the right
* answer for a dashboard that cannot see what it is supposed to be running. */
let manifestCache: { mtimeMs: number; version: string | null } | null = null;
function shippedExtensionVersion(): string | null {
const file = path.join(process.cwd(), 'extension', 'manifest.json');
try {
const { mtimeMs } = statSync(file);
if (!manifestCache || manifestCache.mtimeMs !== mtimeMs) {
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { version?: string };
manifestCache = { mtimeMs, version: parsed.version ?? null };
}
return manifestCache.version;
} catch {
return null;
}
}
/** Polled by the Chromium extension every few seconds, and by the AutoBuyer page. /** Polled by the Chromium extension every few seconds, and by the AutoBuyer page.
* The AutoBuyer has no on/off switch — it is ready whenever the runner is — so * The AutoBuyer has no on/off switch — it is ready whenever the runner is — so
* the only thing worth reporting here is whether clicks are waiting to be served. */ * the only things worth reporting here are whether clicks are waiting to be
* served, and which extension build this checkout expects to be talking to. */
export async function GET() { export async function GET() {
return corsJson({ pendingLocate: countPendingLocate() }); return corsJson({
pendingLocate: countPendingLocate(),
extensionVersion: shippedExtensionVersion(),
});
} }
export async function OPTIONS() { export async function OPTIONS() {
+24 -5
View File
@@ -34,7 +34,7 @@ HEARTBEAT_SECONDS = 2.0
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in # Bumped whenever the step vocabulary or the locate protocol changes. Reported in
# the heartbeat so the dashboard can say "restart your runner" instead of letting # the heartbeat so the dashboard can say "restart your runner" instead of letting
# a stale process fail on a step type it has never heard of. # a stale process fail on a step type it has never heard of.
VERSION = "0.17.0" VERSION = "0.18.0"
# Shared with the heartbeat thread: whether a run is currently executing. # Shared with the heartbeat thread: whether a run is currently executing.
_busy = threading.Event() _busy = threading.Event()
@@ -80,9 +80,14 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
# `absent`, until it is gone. Nothing is clicked or typed. Whatever has to # `absent`, until it is gone. Nothing is clicked or typed. Whatever has to
# change the page — a person solving a challenge, a modal closing itself, # change the page — a person solving a challenge, a modal closing itself,
# a slow server — happens outside this run. # a slow server — happens outside this run.
#
# `hidden` widens an absent gate from "removed from the DOM" to "no
# longer visible": a modal that closes by going display:none never stops
# matching its selector, so the plain gate can only ever time out.
selector = step["selector"] selector = step["selector"]
absent = bool(step.get("absent")) absent = bool(step.get("absent"))
goal = "disappear" if absent else "appear" by_sight = absent and bool(step.get("hidden"))
goal = "disappear from view" if by_sight else "disappear" if absent else "appear"
timeout_s = float(step.get("timeoutSeconds", 120)) timeout_s = float(step.get("timeoutSeconds", 120))
deadline = time.time() + timeout_s deadline = time.time() + timeout_s
announced = False announced = False
@@ -93,10 +98,24 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
raise actions.StepError("cancelled while waiting") raise actions.StepError("cancelled while waiting")
try: try:
dash.locate(selector, int(step.get("index", 0)), seen = dash.locate(selector, int(step.get("index", 0)),
step.get("urlPattern", "") or opts.url, step.get("urlPattern", "") or opts.url,
opts.timeout, step.get("openUrl", "") or "") opts.timeout, step.get("openUrl", "") or "",
if not absent: options={"op": "visible"} if by_sight else None)
if by_sight:
# An extension too old for the probe quietly does an ordinary
# locate instead, whose result has no `visible` key — reading
# that as "gone" would wave the gate through on a visible
# element, so insist the answer is the one we asked for.
if seen.get("op") != "visible":
raise actions.StepError(
"waitFor `hidden` needs AutoBuyer extension 0.12.0+ — "
"reload it in chrome://extensions"
)
if not seen.get("visible"):
report(f"{selector} is out of sight ({seen.get('reason') or 'hidden'})")
break
elif not absent:
report(f"{selector} appeared") report(f"{selector} appeared")
break break
# Still there. Keep waiting for it to go. # Still there. Keep waiting for it to go.
+11
View File
@@ -0,0 +1,11 @@
# The hostname the dashboard answers on. Needs an A record pointing at the
# droplet before bootstrap-tls.sh can get a certificate for it.
FIRMER_HOST=firmer.example.com
# Where the autotrader stack lives. Its nginx is the shared ingress, its certbot
# issues the certificates, and its compose project owns the network this joins.
SHARED_STACK=/opt/autotrader/deploy
# The docker network that stack created. Only set this if you renamed the
# project — the default matches `docker network ls`.
SHARED_NETWORK=autotrader-vps_web
+81
View File
@@ -0,0 +1,81 @@
# Hosting the dashboard on the droplet
The dashboard is the only piece that can live on a server. The clicker drives a
real mouse and keyboard and the extension runs inside your Chrome, so both stay
on the Windows machine — they just point at the droplet instead of at
`localhost`.
```
Windows box 159.223.129.19
clicker (runner.py) ──────┐
Chrome + extension ───────┴──HTTPS──> nginx ──> autofirmer:3000 ──> /data/autotrader.sqlite
```
This stack deliberately owns none of the ingress. The droplet already runs one
nginx on 80/443 for `master.juicerroom.com` and `git.juicerroom.com`, from
`/opt/autotrader/deploy`; this joins that network, and `deploy.sh` renders a
server block into that stack's `nginx/conf.d`. Two consequences worth knowing:
- The rendered `firmer.conf` is untracked in the autotrader repo. It survives
that stack's pulls and deploys. It does **not** survive its
`bootstrap-tls.sh`, which clears `conf.d` — re-run `./deploy.sh` here if that
ever happens.
- `deploy.sh` reloads the shared nginx, so a mistake in the template would be a
mistake in front of the autotrader dashboard too. It runs `nginx -t` first and
refuses to reload on a bad config, and it will not install the TLS server
block until the certificate actually exists on disk.
## First run
```bash
cp .env.example .env # set FIRMER_HOST
./bootstrap-tls.sh # DNS check, certificate, then deploy.sh
```
`bootstrap-tls.sh` uses the autotrader stack's certbot container and Let's
Encrypt account, and reads `ACME_EMAIL` from its `.env` rather than keeping a
second copy. The A record has to point at the droplet first, or the challenge
cannot be answered.
## Shipping a change
```bash
git pull && ./deploy/deploy.sh
```
The build runs before anything is swapped, so a broken commit leaves the running
container serving. The database is on the `autofirmer-data` volume and is never
touched by a deploy.
## Pointing the desktop at it
- **Extension** — open its popup and set the dashboard URL to
`https://<FIRMER_HOST>`.
- **Clicker** — `python runner.py --api https://<FIRMER_HOST>`, or edit the
argument in the PM2 config that starts it.
Both keep working against `http://localhost:3000` instead; nothing here forces
the hosted one.
## No login
There is none, at the owner's request, and basic auth could not be bolted on
without breaking the two clients: the extension and the clicker poll
`/api/autobuyer/*` unattended with nowhere to put a password. Anyone who reaches
the host can queue runs, read the settings table — the 2captcha key included —
and spend money. If that changes, the options are an IP allowlist in the nginx
template, or a shared secret that both clients learn to send.
## Updating the extension
The extension can no longer be updated by this stack alone, and it is worth
being clear about why. Chrome has no update channel for an unpacked extension:
it reads the folder off disk once and never looks again. What the dashboard
does is *publish* the version its own checkout ships, at
`/api/autobuyer/status`. The extension compares that against its own manifest
and calls `chrome.runtime.reload()`, which re-reads the folder from disk.
So the files still arrive the way they always did — `scripts/update-check.mjs`
pulling on the Windows box every five minutes. If the droplet is ahead of that
pull, the extension reloads once, notices nothing changed, and waits instead of
looping; the popup says as much.
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# First run only: get a certificate for FIRMER_HOST, then hand off to deploy.sh.
#
# This uses the autotrader stack's certbot container and its Let's Encrypt
# account, so it agrees to the Subscriber Agreement the same way that stack's
# own bootstrap did (certbot --agree-tos):
# https://letsencrypt.org/repository/
set -euo pipefail
cd "$(dirname "$0")"
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$1" >&2; exit 1; }
[ -f .env ] || fail "no .env — copy .env.example to .env and fill it in"
set -a; . ./.env; set +a
: "${FIRMER_HOST:?set FIRMER_HOST in .env}"
SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}"
# ACME_EMAIL is not duplicated here: it belongs to the account that already
# holds the other certificates on this box.
[ -f "$SHARED_STACK/.env" ] || fail "no .env under $SHARED_STACK"
ACME_EMAIL=$(grep -E '^ACME_EMAIL=' "$SHARED_STACK/.env" | cut -d= -f2-)
[ -n "$ACME_EMAIL" ] || fail "ACME_EMAIL is not set in $SHARED_STACK/.env"
# Let's Encrypt rate-limits failures, so check DNS before spending an attempt.
log "Checking DNS"
ip=$(curl -fsS --max-time 5 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null \
|| curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null) \
|| fail "could not determine this droplet's public IP"
got=$(getent ahostsv4 "$FIRMER_HOST" 2>/dev/null | awk 'NR==1{print $1}') || true
echo " droplet: $ip"
echo " $FIRMER_HOST -> ${got:-<unresolved>}"
[ "$got" = "$ip" ] || fail "$FIRMER_HOST does not resolve to $ip — fix DNS and wait for it to propagate"
# The challenge is served over plain HTTP by the shared nginx, which needs a
# server block for this name before it will answer for it.
log "Rendering the HTTP-only config so the challenge can be served"
envsubst '${FIRMER_HOST}' < nginx/firmer-acme.conf.template > "$SHARED_STACK/nginx/conf.d/firmer.conf"
docker exec nginx nginx -t
docker exec nginx nginx -s reload
log "Requesting a certificate for $FIRMER_HOST"
docker compose --project-directory "$SHARED_STACK" -f "$SHARED_STACK/docker-compose.yml" \
run --rm --entrypoint certbot certbot \
certonly --webroot -w /var/www/certbot \
--email "$ACME_EMAIL" --agree-tos --no-eff-email \
--non-interactive -d "$FIRMER_HOST" \
|| fail "certbot failed — check DNS and that port 80 reaches this droplet"
log "Switching to the TLS config"
exec ./deploy.sh
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Rebuild the dashboard image, restart the container, point the shared nginx at
# it. Safe to re-run any time — this is the normal "ship a change" command.
set -euo pipefail
cd "$(dirname "$0")"
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$1" >&2; exit 1; }
[ -f .env ] || fail "no .env — copy .env.example to .env and fill it in"
set -a; . ./.env; set +a
: "${FIRMER_HOST:?set FIRMER_HOST in .env}"
SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}"
command -v envsubst >/dev/null || fail "envsubst missing — apt-get install -y gettext-base"
[ -d "$SHARED_STACK/nginx/conf.d" ] || fail "no nginx/conf.d under $SHARED_STACK"
log "Building and starting"
docker compose up -d --build
log "Waiting for the container to report healthy"
for _ in $(seq 1 30); do
state=$(docker inspect autofirmer --format '{{.State.Health.Status}}' 2>/dev/null || echo missing)
[ "$state" = healthy ] && break
sleep 2
done
[ "${state:-}" = healthy ] || fail "autofirmer is $state — check: docker logs autofirmer"
# Rendered into the *other* stack's conf.d, because that is the directory its
# nginx has mounted. The file is untracked there, and survives that stack's
# deploys and pulls; only its bootstrap-tls.sh wipes conf.d, and re-running this
# script puts it back.
#
# Held back until the certificate exists: nginx refuses to load a server block
# whose ssl_certificate is missing, and a broken file in that directory would
# also abort the autotrader deploys that reload the same nginx.
if docker run --rm -v autotrader-vps_letsencrypt:/le alpine \
test -f "/le/live/$FIRMER_HOST/fullchain.pem" 2>/dev/null; then
template=nginx/firmer.conf.template
else
log "No certificate for $FIRMER_HOST yet — rendering the HTTP-only config"
log "Run ./bootstrap-tls.sh once DNS points here, then this script again"
template=nginx/firmer-acme.conf.template
fi
log "Rendering $template"
# Named explicitly so envsubst leaves nginx's own $host, $remote_addr and the
# rest alone.
envsubst '${FIRMER_HOST}' < "$template" > "$SHARED_STACK/nginx/conf.d/firmer.conf"
log "Reloading the shared nginx"
docker exec nginx nginx -t
docker exec nginx nginx -s reload
log "Status"
docker compose ps
+30
View File
@@ -0,0 +1,30 @@
# The dashboard, alongside the autotrader stack rather than instead of it: the
# droplet already runs one nginx on 80/443, and this joins that network so the
# same ingress can proxy to it. Nothing here publishes a host port.
name: autofirmer
services:
autofirmer:
build:
context: ..
dockerfile: Dockerfile
image: autofirmer:local
container_name: autofirmer
restart: unless-stopped
environment:
AUTOFIRMER_DB: /data/autotrader.sqlite
volumes:
- autofirmer-data:/data
expose:
- "3000"
networks: [web]
networks:
# Created by the autotrader stack. `external` so this compose file never tries
# to own it — bringing this stack down must not take the ingress with it.
web:
name: ${SHARED_NETWORK:-autotrader-vps_web}
external: true
volumes:
autofirmer-data:
+16
View File
@@ -0,0 +1,16 @@
# HTTP only, for the window before a certificate exists: nginx will not start
# with an ssl_certificate that is not on disk yet, so the real config cannot go
# in until certbot has run. Rendered by bootstrap-tls.sh.
server {
listen 80;
listen [::]:80;
server_name ${FIRMER_HOST};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 503;
}
}
+52
View File
@@ -0,0 +1,52 @@
# AutoFirmer dashboard — rendered into the autotrader stack's nginx/conf.d by
# deploy.sh. Edit the template, not the rendered file.
server {
listen 80;
listen [::]:80;
server_name ${FIRMER_HOST};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${FIRMER_HOST};
ssl_certificate /etc/letsencrypt/live/${FIRMER_HOST}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${FIRMER_HOST}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
# A capture posts a whole page's HTML, which runs to megabytes on the firm
# dashboards. nginx's 1m default would reject those with a 413.
client_max_body_size 32m;
# No basic auth, at the owner's request and because it could not work here
# anyway: the extension and the clicker poll /api/autobuyer/* unattended and
# have nowhere to put a password. Anyone who reaches this host can queue
# runs, read the settings table including the 2captcha key, and spend money.
location / {
proxy_pass http://autofirmer:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+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 — /** Is the tab already showing this page? Compares origin and path only —
* query strings and hashes shouldn't force a reload. */ * query strings and hashes shouldn't force a reload. */
function alreadyAt(current, target) { function alreadyAt(current, target) {
@@ -714,6 +758,23 @@ async function serveLocateRequest(cfg) {
return true; 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 // `complete` only means the document loaded — a React app still has to
// mount and paint. Retry briefly rather than declaring the element missing, // mount and paint. Retry briefly rather than declaring the element missing,
// with a longer budget when we just opened the page from cold. // with a longer budget when we just opened the page from cold.
@@ -749,10 +810,11 @@ async function serveLocateRequest(cfg) {
async function tick() { async function tick() {
const cfg = await getConfig(); const cfg = await getConfig();
let status;
try { try {
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' }); const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const status = await res.json(); status = await res.json();
pendingLocate = Number(status.pendingLocate) || 0; pendingLocate = Number(status.pendingLocate) || 0;
} catch (err) { } catch (err) {
setBadge(false); setBadge(false);
@@ -763,6 +825,16 @@ async function tick() {
setBadge(true); setBadge(true);
await setState({ connected: 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, // 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. // 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 // 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 ────────────────────────────────────────────────────────────── // ── Scheduling ──────────────────────────────────────────────────────────────
async function restart() { async function restart() {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "AutoFirmer Capture", "name": "AutoFirmer Capture",
"version": "0.11.2", "version": "0.12.0",
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.", "description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
"permissions": ["scripting", "tabs", "storage", "alarms"], "permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [ "host_permissions": [
+27 -9
View File
@@ -23,10 +23,16 @@ export type AutomationStep =
| { action: 'navigate'; url?: string; label?: string } | { action: 'navigate'; url?: string; label?: string }
// Block until `selector` exists — or, with `absent`, until it is gone from the // Block until `selector` exists — or, with `absent`, until it is gone from the
// DOM. Nothing is clicked or typed; this is a gate, for conditions something // DOM. Nothing is clicked or typed; this is a gate, for conditions something
// outside the run has to satisfy. Note `absent` means removed, not merely // outside the run has to satisfy. Note `absent` alone means removed, not
// hidden: an element still in the DOM with display:none keeps matching, so // merely hidden: an element still in the DOM with display:none keeps
// for those use a selector that only matches while it is visible. // matching, and the gate would sit there until it timed out.
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; timeoutSeconds?: number; label?: string } //
// `hidden` (only meaningful with `absent`) widens the gate to "we cannot see
// it any more" — removed, display:none, visibility:hidden, opacity:0,
// content-visibility, or collapsed to zero size all end the wait. It says
// nothing about being scrolled off-screen or covered by an overlay: those
// are still on the page. Needs extension 0.12.0+ and runner 0.18.0+.
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; hidden?: boolean; timeoutSeconds?: number; label?: string }
// Scroll until the page stops adding elements matching `selector`, for lists // Scroll until the page stops adding elements matching `selector`, for lists
// that load progressively. `containerSelector` names the scrolling element // that load progressively. `containerSelector` names the scrolling element
// when the automatic guess is wrong — these lists usually scroll inside a div // when the automatic guess is wrong — these lists usually scroll inside a div
@@ -171,7 +177,7 @@ export const FIRMS: Firm[] = [
{ action: 'type', selector: 'div.summary_section div.MuiTextField-root input', text: 'MX8'}, { action: 'type', selector: 'div.summary_section div.MuiTextField-root input', text: 'MX8'},
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root + button'}, { action: 'click', selector: 'div.summary_section div.MuiTextField-root + button'},
{ action: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' }, { action: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
{ action: 'click', selector: 'div.summary_section > button:last-child', label: 'Open Add Account' }, { action: 'click', selector: 'div.summary_section > button:last-child', label: 'Buy Account' },
// TODO: the rest of the purchase flow. Confirm each selector with // TODO: the rest of the purchase flow. Confirm each selector with
// `clicker.py locate` before adding it here. // `clicker.py locate` before adding it here.
@@ -249,9 +255,20 @@ export const FIRMS: Firm[] = [
// dashboard, so go there first rather than assuming. // dashboard, so go there first rather than assuming.
{ action: 'navigate', label: 'Open the Lucid dashboard' }, { action: 'navigate', label: 'Open the Lucid dashboard' },
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' }, { action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
{ action: 'click', selector: 'button.tab-flex', label: 'Open Flex Accounts' },
{ action: 'repeat', timesFrom: 'count', steps: [ { action: 'repeat', timesFrom: 'count', steps: [
// one purchase — the steps you already have // one purchase — the steps you already have
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' }, { action: 'click', selector: 'input.coupon_code_field', label: 'Click on coupon code'},
{ action: 'type', selector: 'input.coupon_code_field', text: 'THEPROPFARM'},
{ action: 'click', selector: '.apply_coupon_button'},
{ action: 'click', selector: '.validate-required:not(.woocommerce-validated)', label: 'Agree to terms' },
{ action: 'click', selector: '.validate-required:not(.woocommerce-validated)', label: 'Agree to terms 2' },
{ action: 'waitFor', selector: '#woocommerceModal', absent: true, hidden: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
]}, ]},
], ],
}, },
@@ -405,9 +422,10 @@ export function describeStep(step: AutomationStep): string {
case 'wait': return `wait ${step.seconds}s`; case 'wait': return `wait ${step.seconds}s`;
case 'navigate': return `open ${step.url ?? 'the firm page'}`; case 'navigate': return `open ${step.url ?? 'the firm page'}`;
case 'waitFor': case 'waitFor':
return step.absent if (!step.absent) return `wait for ${step.selector}`;
? `wait for ${step.selector} to disappear` return step.hidden
: `wait for ${step.selector}`; ? `wait for ${step.selector} to disappear from view`
: `wait for ${step.selector} to disappear`;
case 'scrollToLoad': return `scroll to load all ${step.selector}`; case 'scrollToLoad': return `scroll to load all ${step.selector}`;
case 'solveCaptcha': return `solve the ${step.provider ?? 'page'} captcha with 2captcha`; case 'solveCaptcha': return `solve the ${step.provider ?? 'page'} captcha with 2captcha`;
case 'repeat': { case 'repeat': {
+6 -2
View File
@@ -1,7 +1,11 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import path from 'path'; import path from 'path';
const db = new Database(path.join(process.cwd(), 'autotrader.sqlite')); // Next to the checkout by default, which is what every desktop instance wants.
// `AUTOFIRMER_DB` moves it onto a mounted volume for the hosted dashboard, where
// the working directory is inside a container image and is replaced on every
// deploy — a database written there would be a fresh empty one each time.
const db = new Database(process.env.AUTOFIRMER_DB || path.join(process.cwd(), 'autotrader.sqlite'));
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
@@ -627,7 +631,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
/** The runner version this server's step vocabulary requires. A running process /** The runner version this server's step vocabulary requires. A running process
* doesn't reload when the source changes, so an older one silently fails on * doesn't reload when the source changes, so an older one silently fails on
* steps it predates — the dashboard warns instead. */ * steps it predates — the dashboard warns instead. */
export const RUNNER_EXPECTED_VERSION = '0.17.0'; export const RUNNER_EXPECTED_VERSION = '0.18.0';
export interface RunnerHeartbeat { export interface RunnerHeartbeat {
at: number; at: number;