Files
autofirmer-expanded/clicker/focus.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

371 lines
13 KiB
Python

"""Bring the browser to the front before clicking.
macOS (and Windows, to a lesser degree) treats a click on an unfocused window as
an activation gesture: the click raises the window and is swallowed there, never
reaching the control underneath. So an automated click against a background Chrome
does nothing at all the first time, then works on the second attempt — which looks
like a flaky clicker but is really the window manager doing its job.
The extension already calls chrome.windows.update({focused: true}), but that only
orders windows *within* Chrome. If the frontmost application is your terminal —
which it is, because that's where this script was launched — Chrome as a whole is
still in the background. This module raises the application itself.
"""
import os
import shutil
import subprocess
import sys
import time
# Per browser, how to recognise it on each platform. The extension reports which
# one is hosting it, because picking by list order raises the wrong browser as
# soon as two are installed — and then clicks land in a window the coordinates
# were never measured from.
BROWSERS = {
"chrome": {
"darwin": ("com.google.Chrome", "com.google.Chrome.beta",
"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 = _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])
def _titles_for(browser: str | None) -> tuple[str, ...]:
if browser and browser in BROWSERS:
return BROWSERS[browser]["titles"]
return tuple(t for b in BROWSERS.values() for t in b["titles"])
# Kept for callers that just want "any known browser".
MAC_BUNDLES = _ids_for(None) if sys.platform == "darwin" else BROWSERS["chrome"]["darwin"]
WINDOWS_PROCESSES = BROWSERS["chrome"]["win32"] + BROWSERS["edge"]["win32"] + BROWSERS["brave"]["win32"]
SETTLE = 0.20 # let the window manager finish raising before measuring or clicking
def enable_dpi_awareness() -> str | None:
"""Tell Windows this process speaks in real pixels.
Without this, Windows virtualises the screen size it reports and rescales the
coordinates it accepts, while the browser keeps reporting CSS pixels. On a
display at 125% or 150% the two disagree and clicks land increasingly far off
as you move away from the top-left. Compensating for that afterwards is worse
than not being lied to in the first place, so declare awareness at startup.
No-op everywhere else. Call once, before anything queries the screen.
"""
if sys.platform != "win32":
return None
import ctypes
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2) # per-monitor
return "per-monitor DPI aware"
except Exception:
try:
ctypes.windll.user32.SetProcessDPIAware() # pre-8.1 fallback
return "system DPI aware"
except Exception as exc:
return f"could not set DPI awareness ({exc})"
def _win_process_name(hwnd) -> str:
"""Executable behind a window handle, lowercased. '' if it can't be read."""
import ctypes
from ctypes import wintypes
pid = wintypes.DWORD()
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if not pid.value:
return ""
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return ""
try:
buf = ctypes.create_unicode_buffer(512)
size = wintypes.DWORD(len(buf))
if ctypes.windll.kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
return buf.value.rsplit("\\", 1)[-1].lower()
return ""
finally:
ctypes.windll.kernel32.CloseHandle(handle)
class FocusResult:
def __init__(self, ok: bool, detail: str):
self.ok = ok
self.detail = detail
def __bool__(self) -> bool:
return self.ok
def _mac_workspace():
"""NSWorkspace via pyobjc. Unlike AppleScript this needs no Automation
permission — activating an app is not scripting it."""
try:
from AppKit import NSWorkspace
except ImportError:
return None
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") or sys.platform.startswith("linux"):
return _ids_for(browser)
return ()
def frontmost() -> str | None:
"""Bundle id (macOS) or process name (Windows) of the foreground application.
None means this platform has no cheap way to ask, and activation goes
unverified — which is the honest answer, not a pass.
"""
if sys.platform == "darwin":
ws = _mac_workspace()
if ws is None:
return None
app = ws.frontmostApplication()
return app.bundleIdentifier() if app else None
if sys.platform == "win32":
try:
import pygetwindow
win = pygetwindow.getActiveWindow()
if win is None:
return None
return _win_process_name(win._hWnd) or None
except Exception:
return None
if sys.platform.startswith("linux"):
return _linux_frontmost()
return None
def _mac_activate(browser: str | None = None) -> FocusResult:
ws = _mac_workspace()
if ws is None:
# pyobjc's AppKit isn't present. osascript works but may prompt for
# Automation permission the first time.
try:
subprocess.run(
["osascript", "-e", 'tell application "Google Chrome" to activate'],
check=True, capture_output=True, timeout=5,
)
return FocusResult(True, "activated via osascript")
except Exception as exc:
return FocusResult(False, f"could not activate Chrome ({exc})")
running = {a.bundleIdentifier(): a for a in ws.runningApplications()}
for bundle in _ids_for(browser):
app = running.get(bundle)
if app is None:
continue
# NSApplicationActivateIgnoringOtherApps — take focus even though the
# terminal currently owns it.
app.activateWithOptions_(1 << 1)
return FocusResult(True, f"activated {bundle}")
wanted = browser or "any known browser"
return FocusResult(False, f"{wanted} is not running")
def _is_browser_window(win, browser: str | None = None) -> bool:
"""Match on the owning process where we can, title only as a fallback.
A title match alone catches an editor with chrome.js open, or a folder window
named Chrome — and activating the wrong window then sends every click into it.
"""
if sys.platform == "win32":
try:
name = _win_process_name(win._hWnd)
if name:
return name in _ids_for(browser)
except Exception:
pass # fall through to the title check
return bool(win.title) and any(t in win.title for t in _titles_for(browser))
def _other_activate(browser: str | None = None) -> FocusResult:
"""Windows (and any platform pygetwindow supports)."""
try:
import pygetwindow
except ImportError:
return FocusResult(False, "pygetwindow unavailable — cannot raise the browser")
try:
wins = [w for w in pygetwindow.getAllWindows()
if w.visible and _is_browser_window(w, browser)]
except NotImplementedError:
# pygetwindow has no X11 backend; say so rather than looking like no
# browser is open.
return FocusResult(False, f"window management is unsupported on {sys.platform}")
if not wins:
return FocusResult(False, f"no {browser or 'browser'} window found")
try:
win = wins[0]
if getattr(win, "isMinimized", False):
win.restore()
win.activate()
return FocusResult(True, f"activated {win.title[:40]!r}")
except Exception as exc:
# Windows refuses SetForegroundWindow to a process that doesn't own the
# foreground; the verification in ensure_frontmost is what catches the
# cases where it fails quietly instead.
return FocusResult(False, f"could not activate window ({exc})")
def activate_browser(browser: str | None = None) -> FocusResult:
"""Raise the browser application above everything else.
`browser` is the id the extension reported ("chrome", "edge", ...). Without
it, any known browser will do — fine on a machine with one installed, wrong
on a machine with two.
"""
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
def ensure_frontmost(timeout: float = 1.5, browser: str | None = None) -> FocusResult:
"""Raise the browser and, where we can check, confirm it actually came forward.
Returning ok=False does not mean the click will fail — only that we could not
verify. The caller decides whether to proceed.
"""
result = activate_browser(browser)
if not result.ok:
return result
ids = browser_ids(browser)
if not ids:
return FocusResult(True, result.detail + " (unverified)")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
front = frontmost()
if front is None:
return FocusResult(True, result.detail + " (unverified)")
if front in ids:
return FocusResult(True, f"{front} is frontmost")
time.sleep(0.05)
return FocusResult(False, f"browser did not come to the front (frontmost is {frontmost()})")