Files
autofirmer-expanded/clicker/diagnose.py
T
Brandon LiandClaude Opus 5 c5c946d3cc 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>
2026-08-30 18:53:25 -05:00

157 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Check whether this machine can actually be driven.
Run it on the box that will do the clicking, before trusting a run:
python diagnose.py
It answers the question a remote desktop makes hard — is the mouse really moving,
or is the viewer just not drawing it? The cursor position is read back from the
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
from clicker import force_utf8_output
def line(label: str, value: str) -> None:
print(f" {label:<22} {value}")
def check_platform() -> None:
print("\nPlatform")
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
import ctypes
# SM_REMOTESESSION: non-zero when this process is running inside an RDP
# session rather than at the physical console.
remote = ctypes.windll.user32.GetSystemMetrics(0x1000)
line("remote session", "yes — RDP/terminal services" if remote else "no — physical console")
if remote:
print(" Input injection still works over RDP, but the session's desktop")
print(" is locked when you disconnect, and clicks go nowhere until you")
print(" reconnect. Keep the window open for the duration of a run.")
def check_dpi() -> None:
print("\nDisplay")
try:
import focus
except ImportError:
line("dpi awareness", "focus.py not importable — run this from clicker/")
return
result = focus.enable_dpi_awareness()
line("dpi awareness", result or "n/a (not Windows)")
try:
import pyautogui
except ImportError:
line("screen size", "pyautogui not installed")
return
size = pyautogui.size()
line("screen size", f"{size.width}x{size.height} (as the OS reports it)")
print(" Compare against what `clicker.py locate` reports for screenSize.")
print(" A mismatch that isn't a clean scaling factor means clicks land off.")
def check_mouse() -> bool:
"""Move the cursor and read it back. Returns True if the OS agreed."""
print("\nMouse")
try:
import pyautogui
except ImportError:
line("result", "pyautogui not installed — run: pip install -r requirements.txt")
return False
pyautogui.FAILSAFE = False # a deliberate corner move would abort us
start = pyautogui.position()
line("start position", f"{start[0]},{start[1]}")
width, height = pyautogui.size()
targets = [(width // 4, height // 4), (width // 2, height // 2)]
agreed = True
for x, y in targets:
pyautogui.moveTo(x, y, duration=0.3)
time.sleep(0.1)
got = pyautogui.position()
ok = abs(got[0] - x) <= 2 and abs(got[1] - y) <= 2
agreed &= ok
line("moved to", f"{x},{y} -> OS reports {got[0]},{got[1]} {'OK' if ok else 'MISMATCH'}")
pyautogui.moveTo(start[0], start[1], duration=0.2)
print()
if agreed:
print(" The OS moved the cursor to every requested point.")
print(" If you saw nothing move, that is your viewer not drawing it —")
print(" the clicks are landing where they should.")
else:
print(" The cursor did NOT land where it was asked to.")
print(" On Windows this is usually display scaling: the process is being")
print(" fed virtualised coordinates. Check the dpi awareness line above,")
print(" and pass --scale to clicker.py to compensate.")
return agreed
def check_foreground() -> None:
print("\nForeground window")
try:
import focus
except ImportError:
line("frontmost", "focus.py not importable")
return
front = focus.frontmost()
line("frontmost", front or "could not determine on this platform")
line("browsers known", ", ".join(focus.browser_ids()) or "none for this platform")
result = focus.ensure_frontmost()
line("raise browser", f"{'OK' if result.ok else 'FAILED'}{result.detail}")
print(" With no browser named, any known one counts — that is this check")
print(" only. A real run raises the browser the extension reported, so if")
print(" this raised one you do not automate, that is not a fault.")
if not result.ok:
print(" Every click is refused while this fails: a click on an")
print(" unfocused window is consumed activating it.")
def main() -> int:
force_utf8_output()
print("AutoFirmer clicker diagnostics")
check_platform()
check_dpi()
ok = check_mouse()
check_foreground()
print()
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())