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
+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