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>
371 lines
13 KiB
Python
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()})")
|