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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
65a1103cda
commit
37adbf67fc
+114
-9
@@ -26,9 +26,64 @@ MAC_BUNDLES = (
|
||||
"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
|
||||
@@ -48,14 +103,38 @@ def _mac_workspace():
|
||||
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 of the frontmost application."""
|
||||
"""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
|
||||
|
||||
|
||||
@@ -86,24 +165,49 @@ def _mac_activate() -> FocusResult:
|
||||
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/Linux: pyautogui already depends on pygetwindow, so use it."""
|
||||
"""Windows (and any platform pygetwindow supports)."""
|
||||
try:
|
||||
import pygetwindow
|
||||
except ImportError:
|
||||
return FocusResult(False, "pygetwindow unavailable — cannot raise the browser")
|
||||
|
||||
wins = [w for w in pygetwindow.getAllWindows()
|
||||
if w.title and "Chrome" in w.title and w.visible]
|
||||
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 Chrome window found")
|
||||
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 window {win.title[:40]!r}")
|
||||
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})")
|
||||
|
||||
|
||||
@@ -125,15 +229,16 @@ def ensure_frontmost(timeout: float = 1.5) -> FocusResult:
|
||||
if not result.ok:
|
||||
return result
|
||||
|
||||
if sys.platform != "darwin":
|
||||
return result # no cheap way to verify; activation call succeeded
|
||||
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 MAC_BUNDLES:
|
||||
if front in ids:
|
||||
return FocusResult(True, f"{front} is frontmost")
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user