focus.py raised the first browser in its list that happened to be running. On a machine with both Chrome and Edge installed that is a coin flip, and losing it is silent: coordinates measured from a tab in one browser, the click delivered into a window of the other. It presents as selectors failing for no reason. A VPS with both installed hit exactly this. The extension now reports which browser is hosting it, and that travels with the measurement, so the clicker raises the browser the coordinates actually came from. Asking for a browser that is not running now fails honestly instead of quietly raising a different one, and the verification step rejects the wrong browser coming forward. Chromium, Opera and Vivaldi are recognised alongside Chrome, Edge and Brave, on both platforms. diagnose.py answers the question a remote desktop makes hard: whether the mouse is really moving or the viewer simply is not drawing it. It moves the cursor and reads the position back from the OS, so the answer does not depend on anything being rendered, and it reports DPI mode, screen size, whether this is an RDP session, and whether the browser can be raised at all. Nothing is clicked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
10 KiB
Python
290 lines
10 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 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",),
|
|
"titles": ("Chrome", "Chromium"),
|
|
},
|
|
"edge": {
|
|
"darwin": ("com.microsoft.edgemac",),
|
|
"win32": ("msedge.exe",),
|
|
"titles": ("Edge",),
|
|
},
|
|
"brave": {
|
|
"darwin": ("com.brave.Browser",),
|
|
"win32": ("brave.exe",),
|
|
"titles": ("Brave",),
|
|
},
|
|
"opera": {
|
|
"darwin": ("com.operasoftware.Opera",),
|
|
"win32": ("opera.exe", "launcher.exe"),
|
|
"titles": ("Opera",),
|
|
},
|
|
"vivaldi": {
|
|
"darwin": ("com.vivaldi.Vivaldi",),
|
|
"win32": ("vivaldi.exe",),
|
|
"titles": ("Vivaldi",),
|
|
},
|
|
}
|
|
|
|
|
|
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"
|
|
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 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"):
|
|
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
|
|
|
|
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.
|
|
"""
|
|
result = _mac_activate(browser) if sys.platform == "darwin" else _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()})")
|