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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user