diff --git a/clicker/actions.py b/clicker/actions.py index 57b3d60..2ff8549 100644 --- a/clicker/actions.py +++ b/clicker/actions.py @@ -162,7 +162,9 @@ def perform( # A click on a background window is consumed activating it and never reaches # the page, so raise the browser immediately before pressing. if activate: - focused = focus.ensure_frontmost() + # Raise the browser the extension actually measured from, not whichever + # one happens to be first in the list. + focused = focus.ensure_frontmost(browser=found.get("browser")) say(focused.detail) if not focused.ok: raise StepError( diff --git a/clicker/diagnose.py b/clicker/diagnose.py new file mode 100644 index 0000000..221a887 --- /dev/null +++ b/clicker/diagnose.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Check whether this machine can actually be driven. + +Run it on the box that will do the clicking, before trusting a run: + + python diagnose.py + +It answers the question a remote desktop makes hard — is the mouse really moving, +or is the viewer just not drawing it? The cursor position is read back from the +OS after each move, so the answer doesn't depend on anything being rendered. + +Nothing is clicked. The cursor is moved and put back where it started. +""" + +import sys +import time + + +def line(label: str, value: str) -> None: + print(f" {label:<22} {value}") + + +def check_platform() -> None: + print("\nPlatform") + line("os", sys.platform) + line("python", sys.version.split()[0]) + + if sys.platform != "win32": + return + + import ctypes + + # SM_REMOTESESSION: non-zero when this process is running inside an RDP + # session rather than at the physical console. + remote = ctypes.windll.user32.GetSystemMetrics(0x1000) + line("remote session", "yes — RDP/terminal services" if remote else "no — physical console") + if remote: + print(" Input injection still works over RDP, but the session's desktop") + print(" is locked when you disconnect, and clicks go nowhere until you") + print(" reconnect. Keep the window open for the duration of a run.") + + +def check_dpi() -> None: + print("\nDisplay") + try: + import focus + except ImportError: + line("dpi awareness", "focus.py not importable — run this from clicker/") + return + + result = focus.enable_dpi_awareness() + line("dpi awareness", result or "n/a (not Windows)") + + try: + import pyautogui + except ImportError: + line("screen size", "pyautogui not installed") + return + size = pyautogui.size() + line("screen size", f"{size.width}x{size.height} (as the OS reports it)") + print(" Compare against what `clicker.py locate` reports for screenSize.") + print(" A mismatch that isn't a clean scaling factor means clicks land off.") + + +def check_mouse() -> bool: + """Move the cursor and read it back. Returns True if the OS agreed.""" + print("\nMouse") + try: + import pyautogui + except ImportError: + line("result", "pyautogui not installed — run: pip install -r requirements.txt") + return False + + pyautogui.FAILSAFE = False # a deliberate corner move would abort us + start = pyautogui.position() + line("start position", f"{start[0]},{start[1]}") + + width, height = pyautogui.size() + targets = [(width // 4, height // 4), (width // 2, height // 2)] + + agreed = True + for x, y in targets: + pyautogui.moveTo(x, y, duration=0.3) + time.sleep(0.1) + got = pyautogui.position() + ok = abs(got[0] - x) <= 2 and abs(got[1] - y) <= 2 + agreed &= ok + line("moved to", f"{x},{y} -> OS reports {got[0]},{got[1]} {'OK' if ok else 'MISMATCH'}") + + pyautogui.moveTo(start[0], start[1], duration=0.2) + + print() + if agreed: + print(" The OS moved the cursor to every requested point.") + print(" If you saw nothing move, that is your viewer not drawing it —") + print(" the clicks are landing where they should.") + else: + print(" The cursor did NOT land where it was asked to.") + print(" On Windows this is usually display scaling: the process is being") + print(" fed virtualised coordinates. Check the dpi awareness line above,") + print(" and pass --scale to clicker.py to compensate.") + return agreed + + +def check_foreground() -> None: + print("\nForeground window") + try: + import focus + except ImportError: + line("frontmost", "focus.py not importable") + return + + front = focus.frontmost() + line("frontmost", front or "could not determine on this platform") + line("browsers known", ", ".join(focus.browser_ids()) or "none for this platform") + + result = focus.ensure_frontmost() + line("raise browser", f"{'OK' if result.ok else 'FAILED'} — {result.detail}") + print(" With no browser named, any known one counts — that is this check") + print(" only. A real run raises the browser the extension reported, so if") + print(" this raised one you do not automate, that is not a fault.") + if not result.ok: + print(" Every click is refused while this fails: a click on an") + print(" unfocused window is consumed activating it.") + + +def main() -> int: + print("AutoFirmer clicker diagnostics") + check_platform() + check_dpi() + ok = check_mouse() + check_foreground() + print() + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/clicker/focus.py b/clicker/focus.py index 4d419b2..6d32283 100644 --- a/clicker/focus.py +++ b/clicker/focus.py @@ -16,20 +16,59 @@ 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", -) +# 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",), + }, +} -# 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") + +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 @@ -103,12 +142,10 @@ 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 +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 () @@ -138,7 +175,7 @@ def frontmost() -> str | None: return None -def _mac_activate() -> FocusResult: +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 @@ -153,7 +190,7 @@ def _mac_activate() -> FocusResult: return FocusResult(False, f"could not activate Chrome ({exc})") running = {a.bundleIdentifier(): a for a in ws.runningApplications()} - for bundle in MAC_BUNDLES: + for bundle in _ids_for(browser): app = running.get(bundle) if app is None: continue @@ -162,10 +199,11 @@ def _mac_activate() -> FocusResult: app.activateWithOptions_(1 << 1) return FocusResult(True, f"activated {bundle}") - return FocusResult(False, "no Chrome-family browser is running") + wanted = browser or "any known browser" + return FocusResult(False, f"{wanted} is not running") -def _is_browser_window(win) -> bool: +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 @@ -175,13 +213,13 @@ def _is_browser_window(win) -> bool: try: name = _win_process_name(win._hWnd) if name: - return name in WINDOWS_PROCESSES + return name in _ids_for(browser) except Exception: pass # fall through to the title check - return bool(win.title) and "Chrome" in win.title + return bool(win.title) and any(t in win.title for t in _titles_for(browser)) -def _other_activate() -> FocusResult: +def _other_activate(browser: str | None = None) -> FocusResult: """Windows (and any platform pygetwindow supports).""" try: import pygetwindow @@ -189,14 +227,15 @@ def _other_activate() -> FocusResult: 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)] + 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, "no browser window found") + return FocusResult(False, f"no {browser or 'browser'} window found") try: win = wins[0] @@ -211,25 +250,30 @@ def _other_activate() -> FocusResult: 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() +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) -> FocusResult: +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() + result = activate_browser(browser) if not result.ok: return result - ids = browser_ids() + ids = browser_ids(browser) if not ids: return FocusResult(True, result.detail + " (unverified)") diff --git a/clicker/runner.py b/clicker/runner.py index b2e35c8..e937d5f 100644 --- a/clicker/runner.py +++ b/clicker/runner.py @@ -33,7 +33,7 @@ HEARTBEAT_SECONDS = 2.0 # Bumped whenever the step vocabulary or the locate protocol changes. Reported in # the heartbeat so the dashboard can say "restart your runner" instead of letting # a stale process fail on a step type it has never heard of. -VERSION = "0.13.0" +VERSION = "0.14.0" # Shared with the heartbeat thread: whether a run is currently executing. _busy = threading.Event() diff --git a/extension/background.js b/extension/background.js index b9a256a..3fb6a12 100644 --- a/extension/background.js +++ b/extension/background.js @@ -154,6 +154,24 @@ async function captureAndSend(cfg) { }); } +/** Which browser is hosting this extension. + * + * The clicker has to raise *this* browser before clicking, and picking by list + * order gets it wrong the moment two are installed — it would raise Edge while + * the coordinates came from a tab in Chrome, landing every click in the wrong + * window. So the answer travels with the measurement. + */ +function detectBrowser() { + const ua = navigator.userAgent || ''; + if (/\bEdg\//.test(ua)) return 'edge'; + if (/\bOPR\//.test(ua)) return 'opera'; + if (/\bVivaldi\//.test(ua)) return 'vivaldi'; + try { + if (navigator.brave) return 'brave'; // Brave otherwise reports as Chrome + } catch { /* not Brave */ } + return 'chrome'; +} + // ── Locate: turn a CSS selector into desktop coordinates ──────────────────── /** Runs in the page. Scrolls the element into view, then reports where it ended up. */ @@ -322,7 +340,13 @@ async function serveLocateRequest(cfg) { if (Date.now() >= deadline) throw new Error(out.error); await new Promise((r) => setTimeout(r, 350)); } - result = { ...out, tabId: tab.id, windowId: tab.windowId, openedTab: opened }; + result = { + ...out, + tabId: tab.id, + windowId: tab.windowId, + openedTab: opened, + browser: detectBrowser(), + }; } catch (err) { error = err.message; } diff --git a/extension/manifest.json b/extension/manifest.json index 819cbe6..acf9570 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "AutoFirmer Capture", - "version": "0.6.0", + "version": "0.7.0", "description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.", "permissions": ["scripting", "tabs", "storage", "alarms"], "host_permissions": [ diff --git a/lib/db.ts b/lib/db.ts index 2065fca..5366e3b 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -604,7 +604,7 @@ export const RUNNER_TIMEOUT_MS = 7000; /** The runner version this server's step vocabulary requires. A running process * doesn't reload when the source changes, so an older one silently fails on * steps it predates — the dashboard warns instead. */ -export const RUNNER_EXPECTED_VERSION = '0.13.0'; +export const RUNNER_EXPECTED_VERSION = '0.14.0'; export interface RunnerHeartbeat { at: number;