Add Linux/Raspberry Pi setup and port the clicker to X11

The clicker had no Linux support at all: pygetwindow has no X11 backend, so
_other_activate returned "window management is unsupported" and every step
failed before it could click. focus.py now has a third backend that raises the
browser with xdotool and reads the focused window's WM_CLASS to verify it came
forward - the same activate-then-confirm shape as the macOS and Windows paths.

setup-linux.sh mirrors setup-windows.bat with apt instead of winget. Three
things are specific to this platform rather than incidental:

- Node comes from NodeSource. Pi OS ships one too old for Next 16, and the LTS
  line is also what better-sqlite3 publishes prebuilt arm64 binaries for.
- Python dependencies go in a virtualenv. Pi OS Bookworm enforces PEP 668, so
  pip into the system interpreter fails with externally-managed-environment.
- Autostart uses an XDG ~/.config/autostart entry, the direct analogue of the
  Windows Startup folder: no sudo, and it runs inside the graphical session,
  which the clicker needs for DISPLAY.

Wayland is called out in four places - the session guard, diagnose.py, the
installer and both READMEs - because Pi OS on a Pi 5 defaults to it and the
clicker simply cannot work there. Wayland does not let one client synthesise
input into another, so this is a switch-to-X11 situation, not a bug to fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-30 18:53:25 -05:00
co-authored by Claude Opus 5
parent 3cc632ac40
commit c5c946d3cc
9 changed files with 436 additions and 5 deletions
+3
View File
@@ -59,3 +59,6 @@ scripts/.deps-hash
scripts/.python-cmd
scripts/.update.lock
scripts/update.log
# clicker virtualenv (Linux/Pi)
clicker/.venv/
+30 -2
View File
@@ -6,8 +6,9 @@ Automated futures trading dashboard for prop firm accounts via Tradovate.
## Setup
On a fresh Windows machine, in **PowerShell**, from wherever you want the
instance to live:
### Windows
In **PowerShell**, from wherever you want the instance to live:
```powershell
irm https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-windows.bat -OutFile setup-windows.bat
@@ -34,6 +35,33 @@ Two things it deliberately does, worth knowing:
needed — or installed without its "add to PATH" option — is invisible to a
running shell, so the script checks the standard install directories too.
### Raspberry Pi 5 / Debian / Ubuntu
```bash
curl -fsSLO https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-linux.sh
```
```bash
bash setup-linux.sh
```
Same idea, apt instead of winget. It installs Node 22 from NodeSource (Pi OS
ships one too old for Next 16), `xdotool` and `scrot` for the clicker, and puts
the Python dependencies in a virtualenv — Pi OS Bookworm enforces PEP 668, so a
system-wide `pip install` fails outright.
**The clicker needs X11.** Pi OS on a Pi 5 defaults to Wayland, which does not
let one client synthesise input into another or read the focused window, so
neither `xdotool` nor pyautogui can work there. The dashboard is unaffected, but
for the AutoBuyer:
```bash
sudo raspi-config # Advanced Options -> Wayland -> X11, then reboot
```
`python clicker/diagnose.py` reports the session type, `DISPLAY` and whether
`xdotool` is present — run it before trusting a new machine.
The dashboard runs on port **3000**: `http://localhost:3000`.
---
+11
View File
@@ -53,6 +53,17 @@ VS Code — under System Settings → Privacy & Security → Accessibility. With
`pyautogui` moves nothing and fails silently, which looks identical to a bad
selector.
### Linux (including Raspberry Pi)
`setup-linux.sh` installs `xdotool` and `scrot` and builds a virtualenv for the
Python side.
**X11 only.** pygetwindow has no X11 backend, so raising the browser goes
through `xdotool` instead. Neither that nor pyautogui works under Wayland — it
refuses by design to let one client drive another — and Raspberry Pi OS on a Pi
5 defaults to Wayland. Switch with `sudo raspi-config` -> Advanced Options ->
Wayland -> X11 and reboot. `diagnose.py` prints which session you are in.
### Windows
No extra permissions, but two things differ.
+15
View File
@@ -12,6 +12,8 @@ OS after each move, so the answer doesn't depend on anything being rendered.
Nothing is clicked. The cursor is moved and put back where it started.
"""
import os
import shutil
import sys
import time
@@ -27,6 +29,19 @@ def check_platform() -> None:
line("os", sys.platform)
line("python", sys.version.split()[0])
if sys.platform.startswith("linux"):
# The two things that decide whether the clicker can work at all here.
session = os.environ.get("XDG_SESSION_TYPE", "unknown")
display = os.environ.get("DISPLAY") or "(unset)"
line("session type", session)
line("DISPLAY", display)
line("xdotool", shutil.which("xdotool") or "NOT INSTALLED — sudo apt install -y xdotool")
if session == "wayland" or (os.environ.get("WAYLAND_DISPLAY") and not os.environ.get("DISPLAY")):
print(" Wayland cannot be automated: xdotool and pyautogui both speak X11,")
print(" and Wayland does not let one client drive another. On Raspberry Pi OS:")
print(" sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot.")
return
if sys.platform != "win32":
return
+84 -3
View File
@@ -12,6 +12,8 @@ which it is, because that's where this script was launched — Chrome as a whole
still in the background. This module raises the application itself.
"""
import os
import shutil
import subprocess
import sys
import time
@@ -26,35 +28,48 @@ BROWSERS = {
"com.google.Chrome.dev", "com.google.Chrome.canary",
"org.chromium.Chromium"),
"win32": ("chrome.exe",),
"linux": ("google-chrome", "chromium", "chromium-browser", "google-chrome-stable"),
"titles": ("Chrome", "Chromium"),
},
"edge": {
"darwin": ("com.microsoft.edgemac",),
"win32": ("msedge.exe",),
"linux": ("microsoft-edge", "msedge"),
"titles": ("Edge",),
},
"brave": {
"darwin": ("com.brave.Browser",),
"win32": ("brave.exe",),
"linux": ("brave-browser", "brave"),
"titles": ("Brave",),
},
"opera": {
"darwin": ("com.operasoftware.Opera",),
"win32": ("opera.exe", "launcher.exe"),
"linux": ("opera",),
"titles": ("Opera",),
},
"vivaldi": {
"darwin": ("com.vivaldi.Vivaldi",),
"win32": ("vivaldi.exe",),
"linux": ("vivaldi-stable", "vivaldi"),
"titles": ("Vivaldi",),
},
}
def _platform_key() -> str:
if sys.platform == "darwin":
return "darwin"
if sys.platform.startswith("linux"):
return "linux"
return "win32"
def _ids_for(browser: str | None) -> tuple[str, ...]:
"""Identifiers to accept on this platform. Without a named browser, every
known one — the old behaviour, and still right on a single-browser box."""
key = "darwin" if sys.platform == "darwin" else "win32"
key = _platform_key()
if browser and browser in BROWSERS:
return BROWSERS[browser][key]
return tuple(i for b in BROWSERS.values() for i in b[key])
@@ -142,9 +157,67 @@ def _mac_workspace():
return NSWorkspace.sharedWorkspace()
def _linux_session_problem() -> str | None:
"""Why the clicker cannot drive this desktop, or None if it can.
Raspberry Pi OS on a Pi 5 defaults to Wayland (labwc). Neither xdotool nor
pyautogui works there: both speak X11 protocol, and Wayland deliberately
refuses to let one client synthesise input into another or read the focused
window. There is no workaround short of switching the session to X11, so say
so plainly rather than failing every step with something cryptic.
"""
if not os.environ.get("DISPLAY"):
if os.environ.get("WAYLAND_DISPLAY"):
return ("this is a Wayland session and the clicker needs X11 — on Raspberry Pi OS: "
"sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot")
return "no DISPLAY is set — the clicker needs a graphical session"
if shutil.which("xdotool") is None:
return "xdotool is not installed — run: sudo apt install -y xdotool"
return None
def _xdotool(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["xdotool", *args], capture_output=True, text=True, timeout=5)
def _linux_frontmost() -> str | None:
"""WM_CLASS of the focused window, lowercased to match the ids above."""
try:
result = _xdotool("getactivewindow", "getwindowclassname")
except Exception:
return None
name = (result.stdout or "").strip().lower()
return name or None
def _linux_activate(browser: str | None = None) -> FocusResult:
problem = _linux_session_problem()
if problem:
return FocusResult(False, problem)
for cls in _ids_for(browser):
try:
found = _xdotool("search", "--onlyvisible", "--class", cls)
except Exception as exc:
return FocusResult(False, f"xdotool failed ({exc})")
ids = (found.stdout or "").split()
if not ids:
continue
# Last match is the most recently mapped window — the one a person would
# mean by "the browser" when several are open.
activated = _xdotool("windowactivate", "--sync", ids[-1])
if activated.returncode == 0:
return FocusResult(True, f"activated {cls} (window {ids[-1]})")
return FocusResult(False, f"could not activate {cls}: {(activated.stderr or '').strip()}")
return FocusResult(False, f"no {browser or 'browser'} window found")
def browser_ids(browser: str | None = None) -> tuple[str, ...]:
"""What counts as "the browser" on this platform, optionally narrowed to one."""
if sys.platform in ("darwin", "win32"):
if sys.platform in ("darwin", "win32") or sys.platform.startswith("linux"):
return _ids_for(browser)
return ()
@@ -172,6 +245,9 @@ def frontmost() -> str | None:
except Exception:
return None
if sys.platform.startswith("linux"):
return _linux_frontmost()
return None
@@ -257,7 +333,12 @@ def activate_browser(browser: str | None = None) -> FocusResult:
it, any known browser will do — fine on a machine with one installed, wrong
on a machine with two.
"""
result = _mac_activate(browser) if sys.platform == "darwin" else _other_activate(browser)
if sys.platform == "darwin":
result = _mac_activate(browser)
elif sys.platform.startswith("linux"):
result = _linux_activate(browser)
else:
result = _other_activate(browser)
if result.ok:
time.sleep(SETTLE)
return result
+4
View File
@@ -4,3 +4,7 @@ pyautogui>=0.9.54
pyobjc-core>=10.0; sys_platform == "darwin"
pyobjc-framework-Quartz>=10.0; sys_platform == "darwin"
pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin"
# Linux: pyautogui drives X11 through Xlib. Raising the browser window is done
# with xdotool, which is an apt package rather than a wheel - setup-linux.sh
# installs it.
python-xlib>=0.33; sys_platform == "linux"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
#
# Register AutoFirmer to start at login and keep itself updated. Linux/Pi
# counterpart of install-autostart.ps1 - the same two mechanisms, expressed the
# way this desktop does them:
#
# * a ~/.config/autostart entry runs scripts/start-all.sh at login
# * PM2's own cron restart drives the update checks
#
# No sudo, nothing system-wide. Everything runs inside the graphical login
# session, which is not incidental: the clicker synthesises real X11 input and
# needs a desktop with DISPLAY set. A systemd system service has neither.
#
# ./scripts/install-autostart.sh [--interval-minutes 5] [--skip-clicker]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
INTERVAL=5
SKIP_CLICKER=0
while [ $# -gt 0 ]; do
case "$1" in
--interval-minutes) INTERVAL="$2"; shift 2 ;;
--skip-clicker) SKIP_CLICKER=1; shift ;;
*) echo "unknown option: $1" >&2; exit 1 ;;
esac
done
info() { printf ' %s\n' "$1"; }
warn() { printf ' ! %s\n' "$1" >&2; }
info "project root: $ROOT"
# ── PM2 ─────────────────────────────────────────────────────────────────────
if ! command -v pm2 >/dev/null 2>&1; then
info 'installing PM2 globally...'
npm install -g pm2
command -v pm2 >/dev/null 2>&1 || { echo "PM2 installed but not on PATH" >&2; exit 1; }
fi
info "pm2: $(command -v pm2)"
cd "$ROOT"
# Run Next directly rather than through `npm start`, so PM2 supervises the
# server itself instead of an npm wrapper that spawns it.
NEXT_BIN="$ROOT/node_modules/next/dist/bin/next"
[ -f "$NEXT_BIN" ] || { echo "next not found at $NEXT_BIN - run npm install first" >&2; exit 1; }
pm2 delete autofirmer >/dev/null 2>&1 || true
pm2 start "$NEXT_BIN" --name autofirmer --interpreter node -- start
info 'pm2: autofirmer registered'
if [ "$SKIP_CLICKER" -eq 0 ]; then
PY_CMD_FILE="$ROOT/scripts/.python-cmd"
PY_CMD="$( [ -f "$PY_CMD_FILE" ] && cat "$PY_CMD_FILE" || echo python3 )"
PY_EXE="$($PY_CMD -c 'import sys;print(sys.executable)' 2>/dev/null || true)"
if [ -n "$PY_EXE" ] && [ -x "$PY_EXE" ]; then
pm2 delete clicker >/dev/null 2>&1 || true
if pm2 start "$ROOT/clicker/runner.py" --name clicker --interpreter "$PY_EXE"; then
info "pm2: clicker registered ($PY_EXE)"
else
warn 'pm2 start clicker failed - the dashboard is unaffected'
fi
else
warn 'python not found - skipping the clicker'
fi
fi
# ── Update checks (PM2 cron) ────────────────────────────────────────────────
pm2 delete autofirmer-update >/dev/null 2>&1 || true
if pm2 start "$ROOT/scripts/update-check.mjs" --name autofirmer-update \
--no-autorestart --cron-restart "*/$INTERVAL * * * *"; then
info "update checks: every $INTERVAL minutes (*/$INTERVAL * * * *)"
else
warn 'could not register the update checker - run scripts/update-check.mjs by hand'
fi
pm2 save >/dev/null
info 'pm2: process list saved'
# ── Start at login (XDG autostart) ──────────────────────────────────────────
# The desktop equivalent of the Windows Startup folder: runs inside the
# graphical session, so DISPLAY is set and the clicker can reach the X server.
AUTOSTART_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/autostart"
DESKTOP_FILE="$AUTOSTART_DIR/autofirmer.desktop"
mkdir -p "$AUTOSTART_DIR"
cat > "$DESKTOP_FILE" <<DESKTOP
[Desktop Entry]
Type=Application
Name=AutoFirmer
Comment=Start the AutoFirmer dashboard, clicker and update checker
Exec=$ROOT/scripts/start-all.sh
Terminal=false
X-GNOME-Autostart-enabled=true
DESKTOP
info "login entry: $DESKTOP_FILE"
# ── Wayland warning ─────────────────────────────────────────────────────────
# Worth saying now rather than letting every automation fail later.
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${DISPLAY:-}" ]; then
echo
warn 'This is a Wayland session. The dashboard is fine, but the clicker cannot'
warn 'work: xdotool and pyautogui both speak X11, which Wayland does not expose.'
warn 'Switch with: sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot.'
fi
echo
info 'Done. No sudo was needed and nothing system-wide was changed.'
info 'Running now, and again at every login.'
info "Updates checked every $INTERVAL minutes; see scripts/update.log"
info 'Useful: pm2 list | pm2 logs autofirmer | pm2 logs clicker'
info "To disable autostart: rm $DESKTOP_FILE"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Thin wrapper so the desktop autostart entry has a single entry point.
# All the logic is in start-all.mjs, which is platform-neutral.
set -euo pipefail
cd "$(dirname "$0")/.."
exec node scripts/start-all.mjs
Executable
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
#
# AutoFirmer instance setup for Raspberry Pi OS / Debian / Ubuntu.
#
# Standalone: download this one file and run it. It clones the repo into a
# subfolder next to itself, installs, builds, and points the instance at the
# master dashboard. Safe to re-run - it pulls and rebuilds instead of cloning.
#
# curl -fsSLO https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-linux.sh
# bash setup-linux.sh
set -euo pipefail
REPO_URL="https://git.juicerroom.com/senofy/autofirmer-expanded.git"
DEFAULT_MASTER="https://master.juicerroom.com"
HERE="$(cd "$(dirname "$0")" && pwd)"
TARGET="$HERE/autofirmer"
NODE_MAJOR_MIN=20
ok() { printf ' [ok] %s\n' "$1"; }
info() { printf ' %s\n' "$1"; }
warn() { printf ' [!] %s\n' "$1" >&2; }
die() { printf '\n [X] %s\n\n' "$1" >&2; exit 1; }
echo
echo " ============================================"
echo " AutoFirmer - Linux setup"
echo " ============================================"
echo
# ── sudo ────────────────────────────────────────────────────────────────────
command -v apt-get >/dev/null 2>&1 || die "this script expects apt (Raspberry Pi OS, Debian, Ubuntu)"
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
command -v sudo >/dev/null 2>&1 || die "not root and sudo is not installed"
SUDO="sudo"
info "some steps need sudo; you may be prompted"
fi
# ── system packages ─────────────────────────────────────────────────────────
# xdotool raises the browser window (pygetwindow has no X11 backend, so the
# clicker drives xdotool instead). scrot backs pyautogui's screen reads.
# build-essential/python3-dev are only the fallback path for better-sqlite3 if
# no arm64 prebuilt binary matches this Node ABI.
info "installing system packages..."
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq \
git curl ca-certificates \
python3 python3-venv python3-dev \
xdotool scrot \
build-essential
ok "system packages"
# ── node ────────────────────────────────────────────────────────────────────
node_major() { command -v node >/dev/null 2>&1 && node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0; }
if [ "$(node_major)" -lt "$NODE_MAJOR_MIN" ]; then
# Raspberry Pi OS ships a Node too old for Next 16, so take the LTS line
# from NodeSource. LTS also matters for better-sqlite3, which publishes
# prebuilt arm64 binaries only for released ABIs.
info "installing Node 22 LTS (found major $(node_major))..."
curl -fsSL https://deb.nodesource.com/setup_22.x | $SUDO -E bash - >/dev/null
$SUDO apt-get install -y -qq nodejs
fi
[ "$(node_major)" -ge "$NODE_MAJOR_MIN" ] || die "Node $NODE_MAJOR_MIN+ required, found $(node -v 2>/dev/null || echo none)"
ok "node $(node -v) npm $(npm -v)"
ok "python $(python3 -V 2>&1 | awk '{print $2}')"
# ── memory ──────────────────────────────────────────────────────────────────
TOTAL_MB=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
SWAP_MB=$(awk '/SwapTotal/ {print int($2/1024)}' /proc/meminfo)
if [ "$TOTAL_MB" -lt 3500 ] && [ "$SWAP_MB" -lt 1024 ]; then
warn "${TOTAL_MB}MB RAM and only ${SWAP_MB}MB swap - 'next build' may be OOM-killed."
warn "Consider raising CONF_SWAPSIZE in /etc/dphys-swapfile to 2048 and rebooting."
fi
# ── prompts ─────────────────────────────────────────────────────────────────
echo
read -r -p " Master dashboard URL [$DEFAULT_MASTER]: " MASTER_URL
MASTER_URL="${MASTER_URL:-$DEFAULT_MASTER}"
read -r -p " Instance name [$(hostname)]: " INSTANCE
INSTANCE="${INSTANCE:-$(hostname)}"
echo
info "installing to: $TARGET"
echo
# ── 1. clone / pull ─────────────────────────────────────────────────────────
# The branch is named explicitly: a bare clone follows the remote's default
# branch, which is not necessarily master.
if [ -d "$TARGET/.git" ]; then
info "[1/6] existing checkout - switching to master and pulling..."
git -C "$TARGET" fetch origin
git -C "$TARGET" checkout master
git -C "$TARGET" pull --ff-only origin master
else
info "[1/6] cloning $REPO_URL ..."
git clone --branch master "$REPO_URL" "$TARGET"
fi
cd "$TARGET"
# ── 2. npm ──────────────────────────────────────────────────────────────────
info "[2/6] installing npm dependencies (a few minutes on a Pi)..."
npm install
# ── 3. build ────────────────────────────────────────────────────────────────
info "[3/6] building..."
npm run build
# ── 4. settings ─────────────────────────────────────────────────────────────
info "[4/6] writing instance settings..."
node scripts/seed-settings.js "$MASTER_URL" "$INSTANCE"
# ── 5. python ───────────────────────────────────────────────────────────────
# A virtualenv rather than a system pip install: Raspberry Pi OS Bookworm
# enforces PEP 668, so pip into the system interpreter fails outright with
# "externally-managed-environment".
info "[5/6] installing clicker dependencies into a virtualenv..."
VENV="$TARGET/clicker/.venv"
[ -d "$VENV" ] || python3 -m venv "$VENV"
"$VENV/bin/python" -m pip install --quiet --upgrade pip
if "$VENV/bin/python" -m pip install --quiet -r clicker/requirements.txt; then
echo "$VENV/bin/python" > scripts/.python-cmd
ok "clicker dependencies ready"
else
warn "pip install failed - the dashboard still works, the clicker will not"
fi
# ── 6. autostart ────────────────────────────────────────────────────────────
echo
info "[6/6] Auto-start and auto-update"
info " Starts AutoFirmer and the clicker at login, and checks master every"
info " 5 minutes - rebuilding and restarting when it moves. A failed build"
info " is never deployed."
read -r -p " Set this up now? [Y/n] " DOAUTO
AUTOSTART=0
if [ "${DOAUTO,,}" != "n" ]; then
if bash scripts/install-autostart.sh; then AUTOSTART=1; else
warn "auto-start setup failed - start by hand with: node scripts/start-all.mjs"
fi
else
info " Skipped. Run scripts/install-autostart.sh later to enable it."
fi
# ── summary ─────────────────────────────────────────────────────────────────
echo
echo " ============================================"
echo " Done."
echo " ============================================"
echo
info "Instance name : $INSTANCE"
info "Reporting to : $MASTER_URL"
info "Installed in : $TARGET"
info "Dashboard at : http://localhost:3000"
echo
if [ "$AUTOSTART" -eq 1 ]; then
info "Running now, and again at every login (PM2)."
info "Handy: pm2 list | pm2 logs autofirmer | pm2 logs clicker"
else
info "Start it with : node scripts/start-all.mjs"
fi
echo
info "Still manual:"
info " - Load the Chrome extension: chrome://extensions -> Developer mode"
info " -> Load unpacked -> $TARGET/extension"
info " - Add your firm credentials on the dashboard's Settings page"
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${DISPLAY:-}" ]; then
echo
warn "This is a Wayland session - the clicker needs X11."
warn "sudo raspi-config -> Advanced Options -> Wayland -> X11, then reboot."
fi
echo