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:
co-authored by
Claude Opus 5
parent
3cc632ac40
commit
c5c946d3cc
@@ -59,3 +59,6 @@ scripts/.deps-hash
|
|||||||
scripts/.python-cmd
|
scripts/.python-cmd
|
||||||
scripts/.update.lock
|
scripts/.update.lock
|
||||||
scripts/update.log
|
scripts/update.log
|
||||||
|
|
||||||
|
# clicker virtualenv (Linux/Pi)
|
||||||
|
clicker/.venv/
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ Automated futures trading dashboard for prop firm accounts via Tradovate.
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
On a fresh Windows machine, in **PowerShell**, from wherever you want the
|
### Windows
|
||||||
instance to live:
|
|
||||||
|
In **PowerShell**, from wherever you want the instance to live:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
irm https://git.juicerroom.com/senofy/autofirmer-expanded/raw/branch/master/setup-windows.bat -OutFile setup-windows.bat
|
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
|
needed — or installed without its "add to PATH" option — is invisible to a
|
||||||
running shell, so the script checks the standard install directories too.
|
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`.
|
The dashboard runs on port **3000**: `http://localhost:3000`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
`pyautogui` moves nothing and fails silently, which looks identical to a bad
|
||||||
selector.
|
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
|
### Windows
|
||||||
|
|
||||||
No extra permissions, but two things differ.
|
No extra permissions, but two things differ.
|
||||||
|
|||||||
@@ -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.
|
Nothing is clicked. The cursor is moved and put back where it started.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -27,6 +29,19 @@ def check_platform() -> None:
|
|||||||
line("os", sys.platform)
|
line("os", sys.platform)
|
||||||
line("python", sys.version.split()[0])
|
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":
|
if sys.platform != "win32":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+84
-3
@@ -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.
|
still in the background. This module raises the application itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -26,35 +28,48 @@ BROWSERS = {
|
|||||||
"com.google.Chrome.dev", "com.google.Chrome.canary",
|
"com.google.Chrome.dev", "com.google.Chrome.canary",
|
||||||
"org.chromium.Chromium"),
|
"org.chromium.Chromium"),
|
||||||
"win32": ("chrome.exe",),
|
"win32": ("chrome.exe",),
|
||||||
|
"linux": ("google-chrome", "chromium", "chromium-browser", "google-chrome-stable"),
|
||||||
"titles": ("Chrome", "Chromium"),
|
"titles": ("Chrome", "Chromium"),
|
||||||
},
|
},
|
||||||
"edge": {
|
"edge": {
|
||||||
"darwin": ("com.microsoft.edgemac",),
|
"darwin": ("com.microsoft.edgemac",),
|
||||||
"win32": ("msedge.exe",),
|
"win32": ("msedge.exe",),
|
||||||
|
"linux": ("microsoft-edge", "msedge"),
|
||||||
"titles": ("Edge",),
|
"titles": ("Edge",),
|
||||||
},
|
},
|
||||||
"brave": {
|
"brave": {
|
||||||
"darwin": ("com.brave.Browser",),
|
"darwin": ("com.brave.Browser",),
|
||||||
"win32": ("brave.exe",),
|
"win32": ("brave.exe",),
|
||||||
|
"linux": ("brave-browser", "brave"),
|
||||||
"titles": ("Brave",),
|
"titles": ("Brave",),
|
||||||
},
|
},
|
||||||
"opera": {
|
"opera": {
|
||||||
"darwin": ("com.operasoftware.Opera",),
|
"darwin": ("com.operasoftware.Opera",),
|
||||||
"win32": ("opera.exe", "launcher.exe"),
|
"win32": ("opera.exe", "launcher.exe"),
|
||||||
|
"linux": ("opera",),
|
||||||
"titles": ("Opera",),
|
"titles": ("Opera",),
|
||||||
},
|
},
|
||||||
"vivaldi": {
|
"vivaldi": {
|
||||||
"darwin": ("com.vivaldi.Vivaldi",),
|
"darwin": ("com.vivaldi.Vivaldi",),
|
||||||
"win32": ("vivaldi.exe",),
|
"win32": ("vivaldi.exe",),
|
||||||
|
"linux": ("vivaldi-stable", "vivaldi"),
|
||||||
"titles": ("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, ...]:
|
def _ids_for(browser: str | None) -> tuple[str, ...]:
|
||||||
"""Identifiers to accept on this platform. Without a named browser, every
|
"""Identifiers to accept on this platform. Without a named browser, every
|
||||||
known one — the old behaviour, and still right on a single-browser box."""
|
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:
|
if browser and browser in BROWSERS:
|
||||||
return BROWSERS[browser][key]
|
return BROWSERS[browser][key]
|
||||||
return tuple(i for b in BROWSERS.values() for i in b[key])
|
return tuple(i for b in BROWSERS.values() for i in b[key])
|
||||||
@@ -142,9 +157,67 @@ def _mac_workspace():
|
|||||||
return NSWorkspace.sharedWorkspace()
|
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, ...]:
|
def browser_ids(browser: str | None = None) -> tuple[str, ...]:
|
||||||
"""What counts as "the browser" on this platform, optionally narrowed to one."""
|
"""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 _ids_for(browser)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
@@ -172,6 +245,9 @@ def frontmost() -> str | None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
return _linux_frontmost()
|
||||||
|
|
||||||
return None
|
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
|
it, any known browser will do — fine on a machine with one installed, wrong
|
||||||
on a machine with two.
|
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:
|
if result.ok:
|
||||||
time.sleep(SETTLE)
|
time.sleep(SETTLE)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ pyautogui>=0.9.54
|
|||||||
pyobjc-core>=10.0; sys_platform == "darwin"
|
pyobjc-core>=10.0; sys_platform == "darwin"
|
||||||
pyobjc-framework-Quartz>=10.0; sys_platform == "darwin"
|
pyobjc-framework-Quartz>=10.0; sys_platform == "darwin"
|
||||||
pyobjc-framework-Cocoa>=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"
|
||||||
|
|||||||
Executable
+113
@@ -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"
|
||||||
Executable
+6
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user