From 37adbf67fc23d065d568e272a3d3f68f44115260 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sun, 30 Aug 2026 13:36:47 -0500 Subject: [PATCH] Document Windows support and the AutoBuyer components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 79 +++++++++++++++++++++++++++++ clicker/README.md | 79 ++++++++++++++++++++++++++--- clicker/clicker.py | 5 ++ clicker/focus.py | 123 +++++++++++++++++++++++++++++++++++++++++---- clicker/runner.py | 7 ++- lib/db.ts | 2 +- 6 files changed, 277 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ac4f9e4..7d21702 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,67 @@ Open the app in your browser and add your firms through the UI: --- +## AutoBuyer (browser automation) + +The AutoBuyer page drives a real browser to buy and reset prop-firm accounts. It +is three pieces, and all three must be running: + +| Piece | What it does | +|---|---| +| the dashboard | Defines automations, queues runs, shows progress | +| `extension/` | A Chromium extension that reads the broker page and measures elements | +| `clicker/runner.py` | A desktop process that moves the real mouse and keyboard | + +Automations are declared in `lib/automations.ts` — one entry per firm, with the +steps inside. Adding a button means editing that file; the page and the runner +pick it up from the server. + +### 1. Load the extension + +`chrome://extensions` → enable **Developer mode** → **Load unpacked** → +select the `extension` folder. + +Chrome does **not** reload an unpacked extension when its files change. After +pulling updates, click the reload icon on its card — the dashboard shows the +version it sees, and a mismatch means the reload did not take. + +Adding a new firm also means adding its host to `host_permissions` in +`extension/manifest.json` and reloading. Without it the extension cannot read +that site, and every step fails to locate. + +### 2. Install the clicker + +```powershell +cd clicker +pip install -r requirements.txt +``` + +See `clicker/README.md` for the per-platform notes — display scaling and +foreground lock both matter on Windows — and for the verification sequence to +run before letting it click anything on a new machine. + +### 3. Start the runner + +```powershell +python clicker\runner.py +``` + +Leave it running. It reports in every two seconds, and the dashboard greys out +the automation buttons when it is not there. A running Python process does not +reload when the source changes, so restart it after pulling updates; the +dashboard warns when its version is behind. + +### Running it + +Turn **Page capture** on from the AutoBuyer page, then press an automation's +button. Progress appears per step, and **Stop** halts a run between steps. + +The browser must be visible and frontmost while a run is in flight — the clicks +are real OS-level input, so the machine cannot be used for anything else, and a +dialog stealing focus fails the step. That makes an RDP session a poor host: +disconnecting can suspend the desktop and break clicks in ways that are hard to +diagnose. + ## Keeping it running (PM2) Install PM2 globally: @@ -119,6 +180,15 @@ npm run build pm2 restart autofirmer ``` +If the update touched the AutoBuyer, two things do **not** reload themselves: + +- **The extension** — click reload on its card in `chrome://extensions`. +- **The runner** — stop it with Ctrl-C and start it again. + +The dashboard reports the version it sees from each, and warns when either is +behind. Most AutoBuyer bugs that look mysterious are one of these two still +running the previous code. + --- ## Firewall @@ -146,3 +216,12 @@ npm install # only needed if dependencies changed npm run build pm2 restart autofirmer ``` + +If the update touched the AutoBuyer, two things do **not** reload themselves: + +- **The extension** — click reload on its card in `chrome://extensions`. +- **The runner** — stop it with Ctrl-C and start it again. + +The dashboard reports the version it sees from each, and warns when either is +behind. Most AutoBuyer bugs that look mysterious are one of these two still +running the previous code. diff --git a/clicker/README.md b/clicker/README.md index 77d6f69..6d2c084 100644 --- a/clicker/README.md +++ b/clicker/README.md @@ -43,9 +43,62 @@ would interleave clicks. pip install -r requirements.txt ``` -On **macOS** you must grant Accessibility permission to whatever runs the script -(Terminal, iTerm, VS Code) — System Settings → Privacy & Security → Accessibility. -Without it `pyautogui` moves nothing and fails silently. +The macOS-only backends are gated behind platform markers, so this installs the +right set on either OS. + +### macOS + +Grant Accessibility permission to whatever runs the script — Terminal, iTerm, +VS Code — under System Settings → Privacy & Security → Accessibility. Without it +`pyautogui` moves nothing and fails silently, which looks identical to a bad +selector. + +### Windows + +No extra permissions, but two things differ. + +**Display scaling.** The runner declares itself DPI-aware at startup, and prints +which mode it got (`per-monitor DPI aware`). That stops Windows reporting a +virtualised screen size and rescaling the coordinates it accepts, which would +otherwise put clicks progressively further off as you move from the top-left. + +**Foreground lock.** Windows refuses to raise a window for a process that doesn't +already own the foreground — it flashes the taskbar instead, and the activation +call returns as if it worked. The runner verifies by reading the foreground +window's process, so a failure is reported rather than clicked into. If runs stop +with *"the browser is not frontmost"*, click the Chrome window once by hand and +try again; something else is holding the foreground. + +Browser windows are matched on the owning process (`chrome.exe`, `msedge.exe`, +`brave.exe`), not the window title, so an editor with `chrome.js` open won't be +mistaken for the browser. + +### First run on a new machine + +Verify in this order — each step is harmless on its own, and the first one that +looks wrong tells you where the problem is: + +```bash +# 1. Can the extension see the page, and do the coordinates look sane? +python clicker.py locate "a.add_account_btn" + +# 2. Does the cursor actually land on the element? Nothing is pressed. +python clicker.py click "a.add_account_btn" --dry-run + +# 3. A real click. +python clicker.py click "a.add_account_btn" +``` + +At step 1, check the reported desktop x,y against where the element actually is +on screen. A consistent offset means display scaling was misdetected — pass +`--scale` explicitly. Only then start the daemon. + +### Linux / X11 + +Not supported. `pygetwindow`, which raises the browser window, has no X11 backend, +so every click is refused with *"window management is unsupported on linux"*. +Making it work needs an `xdotool` or `wmctrl` path in `focus.py`, a real window +manager (Xvfb alone has no focus semantics), and X11 rather than Wayland. ## Use @@ -74,10 +127,11 @@ python clicker.py click ".trade-btn" --index 2 --url "https://tradeify.co/*" | Flag | Meaning | |---|---| -| `--url` | Chrome match pattern for the tab. Without it, the active tab is used. | +| `--url` | Chrome match pattern for the tab. Without it, the hosts in the extension manifest. | +| `--open-url` | Page to open if no tab matches `--url`. | | `--index` | Which match, when the selector hits several (default 0). | | `--api` | Dashboard URL (default `http://localhost:3000`). | -| `--timeout` | Seconds to wait for the extension (default 20). | +| `--timeout` | Seconds to wait for the extension (default 45 — a cold page load takes time). | | `--scale` | CSS-to-desktop pixel ratio. Auto-detected; override if clicks land off. | | `--dry-run` | Move the cursor, don't press. | | `--force` | Click even when something is covering the element. | @@ -144,6 +198,11 @@ On macOS this uses `NSWorkspace` via pyobjc, which needs no Automation permissio activating an app is not scripting it. Without pyobjc it falls back to `osascript`, which does prompt for Automation permission the first time. +On Windows it goes through `pygetwindow`, then confirms by reading the foreground +window's process. That confirmation is the important half: Windows silently +declines to raise a window for a background process, and the activation call +reports success either way. + ## Cursor motion `humanize.py` moves the pointer the way a hand does rather than teleporting: @@ -187,7 +246,13 @@ Exit codes: `0` ok, `1` error, `2` capture switch off, `3` refused to click relative to the primary display's origin. A Chrome window on a secondary monitor usually still works, but verify with `locate` before trusting `click`. - **Display scaling**: the scale factor is inferred by comparing the screen width - the OS reports against the one the browser reports. On macOS and unscaled Windows - this is 1:1. If clicks land at a consistent offset, set `--scale` explicitly. + the OS reports against the one the browser reports, and is only trusted when it + lands on a real scaling factor (1.0, 1.25, 1.5, …). Anything else falls back to + 1:1 with a warning, because on a multi-monitor desktop the two sides describe + different displays and the ratio is meaningless. If clicks land at a consistent + offset, set `--scale` explicitly. +- **Focus is exclusive**: the browser must be frontmost at the moment of each + click, so the machine can't be used for anything else during a run, and a stray + dialog stealing focus fails the step. - The measurement and the click are separate moments. If the page moves the element in between (a re-render, a late-loading banner), the click lands where it *was*. diff --git a/clicker/clicker.py b/clicker/clicker.py index f0daf30..f5f38bb 100644 --- a/clicker/clicker.py +++ b/clicker/clicker.py @@ -174,6 +174,11 @@ def main() -> int: file=sys.stderr) return 1 + # Before anything asks the OS how big the screen is. No-op off Windows. + dpi = focus.enable_dpi_awareness() + if dpi: + print(f" {dpi}") + # ── resolve and vet the text before touching anything ─────────────────── text = "" if args.action == "type": diff --git a/clicker/focus.py b/clicker/focus.py index af3ae32..4d419b2 100644 --- a/clicker/focus.py +++ b/clicker/focus.py @@ -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) diff --git a/clicker/runner.py b/clicker/runner.py index a356e80..d6d9aec 100644 --- a/clicker/runner.py +++ b/clicker/runner.py @@ -32,7 +32,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.11.0" +VERSION = "0.12.0" # Shared with the heartbeat thread: whether a run is currently executing. _busy = threading.Event() @@ -341,6 +341,11 @@ def main() -> int: print("error: pyautogui is not installed — run: pip install -r requirements.txt", file=sys.stderr) return 1 + # Before anything asks the OS how big the screen is. No-op off Windows. + dpi = focus.enable_dpi_awareness() + if dpi: + print(f" {dpi}") + dash = Dashboard(opts.api) print(f"Runner watching {opts.api}" + (" [DRY RUN — nothing will be pressed]" if opts.dry_run else "")) print("Waiting for a run. Press a button on the AutoBuyer page. Ctrl-C to stop.") diff --git a/lib/db.ts b/lib/db.ts index f840594..297041f 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.11.0'; +export const RUNNER_EXPECTED_VERSION = '0.12.0'; export interface RunnerHeartbeat { at: number;