diff --git a/.gitignore b/.gitignore index 2c1f209..54effff 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ scripts/.deps-hash scripts/.python-cmd scripts/.update.lock scripts/update.log + +# clicker virtualenv (Linux/Pi) +clicker/.venv/ diff --git a/README.md b/README.md index 9e74a00..cab405e 100644 --- a/README.md +++ b/README.md @@ -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`. --- diff --git a/clicker/README.md b/clicker/README.md index 6d2c084..18aaa25 100644 --- a/clicker/README.md +++ b/clicker/README.md @@ -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. diff --git a/clicker/diagnose.py b/clicker/diagnose.py index b2aaf73..39a9695 100644 --- a/clicker/diagnose.py +++ b/clicker/diagnose.py @@ -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 diff --git a/clicker/focus.py b/clicker/focus.py index 6d32283..252a0d7 100644 --- a/clicker/focus.py +++ b/clicker/focus.py @@ -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 diff --git a/clicker/requirements.txt b/clicker/requirements.txt index 2fdd032..c808c16 100644 --- a/clicker/requirements.txt +++ b/clicker/requirements.txt @@ -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" diff --git a/scripts/install-autostart.sh b/scripts/install-autostart.sh new file mode 100755 index 0000000..d459382 --- /dev/null +++ b/scripts/install-autostart.sh @@ -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" < 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" diff --git a/scripts/start-all.sh b/scripts/start-all.sh new file mode 100755 index 0000000..1578b9b --- /dev/null +++ b/scripts/start-all.sh @@ -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 diff --git a/setup-linux.sh b/setup-linux.sh new file mode 100755 index 0000000..ebb6df3 --- /dev/null +++ b/setup-linux.sh @@ -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