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;