Raise the browser the extension reported, and add a diagnostics script
focus.py raised the first browser in its list that happened to be running. On a machine with both Chrome and Edge installed that is a coin flip, and losing it is silent: coordinates measured from a tab in one browser, the click delivered into a window of the other. It presents as selectors failing for no reason. A VPS with both installed hit exactly this. The extension now reports which browser is hosting it, and that travels with the measurement, so the clicker raises the browser the coordinates actually came from. Asking for a browser that is not running now fails honestly instead of quietly raising a different one, and the verification step rejects the wrong browser coming forward. Chromium, Opera and Vivaldi are recognised alongside Chrome, Edge and Brave, on both platforms. diagnose.py answers the question a remote desktop makes hard: whether the mouse is really moving or the viewer simply is not drawing it. It moves the cursor and reads the position back from the OS, so the answer does not depend on anything being rendered, and it reports DPI mode, screen size, whether this is an RDP session, and whether the browser can be raised at all. Nothing is clicked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
731fafda0e
commit
3ac9fe060f
+3
-1
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
+78
-34
@@ -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)")
|
||||
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+25
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user