Files
autofirmer-expanded/clicker/focus.py
T
Brandon LiandClaude Opus 5 37adbf67fc Document Windows support and the AutoBuyer components
focus.py now verifies on Windows rather than assuming activation worked. Windows
declines to raise a window for a process that doesn't own the foreground — it
flashes the taskbar and the call returns as if it succeeded — so the runner reads
the foreground window's process back and reports a failure instead of clicking
into a background window. Same gap that was fixed on macOS earlier.

The runner also declares itself DPI-aware at startup. Without it Windows reports
a virtualised screen size 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 drift further off the further they are from the top-left.

Browser windows are matched on the owning process rather than the window title, so
an editor with chrome.js open is no longer mistaken for the browser. Linux now says
window management is unsupported there, rather than reporting no browser found —
pygetwindow has no X11 backend, and "no browser window" reads like Chrome is shut.

The main README gained a section on the AutoBuyer: what the three pieces are, how
to load the extension, and that neither the extension nor the runner reloads
itself when the source changes. That last point has been the cause of most of the
confusing failures so far, so it is called out in Updating too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:36:47 -05:00

246 lines
8.5 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
# Chrome ships under several bundle ids; accept whichever is installed.
MAC_BUNDLES = (
"com.google.Chrome",
"com.google.Chrome.beta",
"com.google.Chrome.dev",
"com.google.Chrome.canary",
"com.brave.Browser",
"com.microsoft.edgemac",
)
# Windows browsers, matched on the owning process. A title match would also hit
# an editor with chrome.js open or a folder named Chrome; a process name cannot
# collide that way.
WINDOWS_PROCESSES = ("chrome.exe", "msedge.exe", "brave.exe")
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() -> tuple[str, ...]:
"""What counts as "the browser" on this platform."""
if sys.platform == "darwin":
return MAC_BUNDLES
if sys.platform == "win32":
return WINDOWS_PROCESSES
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() -> 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 MAC_BUNDLES:
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}")
return FocusResult(False, "no Chrome-family browser is running")
def _is_browser_window(win) -> 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 WINDOWS_PROCESSES
except Exception:
pass # fall through to the title check
return bool(win.title) and "Chrome" in win.title
def _other_activate() -> 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)]
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, "no 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() -> FocusResult:
"""Raise the browser application above everything else."""
result = _mac_activate() if sys.platform == "darwin" else _other_activate()
if result.ok:
time.sleep(SETTLE)
return result
def ensure_frontmost(timeout: float = 1.5) -> 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()
if not result.ok:
return result
ids = browser_ids()
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()})")