Skip to content

Commit 81f89b0

Browse files
abrichrclaude
andauthored
feat: add Win32 API foreground check as alternative to a11y-based detection (#114)
* feat: add Win32 API foreground check as alternative to a11y-based detection Add _check_foreground_win32() method that uses GetForegroundWindow() + GetWindowText() via PowerShell P/Invoke for fast, reliable foreground window title checking. This replaces the slow a11y-based check as the default, while keeping a11y available via the focus_check_method config. - New config field: focus_check_method (win32, a11y, or both) - New CLI flag: --focus-check-method for run and live subcommands - Detection of known-bad foreground states (Document Recovery, Start Center) - Dispatch method routes to win32, a11y, or both (win32 first, a11y fallback) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: update setup handler tests to mock win32 foreground check The focus check default changed from a11y to win32, so tests need to mock run_powershell instead of requests.get for the /accessibility endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 06fe650 commit 81f89b0

3 files changed

Lines changed: 149 additions & 31 deletions

File tree

openadapt_evals/adapters/waa/live.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ class WAALiveConfig:
384384
waa_image_version: str | None = None
385385
strict_setup_readiness: bool = False
386386
setup_readiness_retries: int = 3
387+
focus_check_method: str = "win32" # "win32", "a11y", or "both"
387388

388389

389390
class WAALiveAdapter(BenchmarkAdapter):
@@ -1997,7 +1998,7 @@ def _try_activate_patterns(
19971998
)
19981999
if resp.status_code == 200:
19992000
time.sleep(0.5)
2000-
if self._check_foreground_matches(patterns, requests_module):
2001+
if self._check_foreground_dispatch(patterns, requests_module):
20012002
logger.info(
20022003
"Post-setup focus: activated '%s' on attempt %d",
20032004
pattern,
@@ -2022,6 +2023,123 @@ def _try_activate_patterns(
20222023
time.sleep(delay)
20232024
return False
20242025

2026+
# Known-bad foreground window titles that indicate the app is not ready.
2027+
_BAD_FOREGROUND_TITLES = [
2028+
"document recovery",
2029+
"libreoffice start center",
2030+
]
2031+
2032+
def _check_foreground_win32(self, patterns: list[str]) -> bool:
2033+
"""Check foreground window title using Win32 API (fast, reliable).
2034+
2035+
Runs a minimal PowerShell script that calls ``GetForegroundWindow()``
2036+
and ``GetWindowText()`` via P/Invoke to retrieve the current foreground
2037+
window title, then checks whether it contains any of the expected
2038+
patterns (case-insensitive).
2039+
2040+
Args:
2041+
patterns: Window title substrings to match (case-insensitive).
2042+
2043+
Returns:
2044+
True if the foreground window title contains any of the patterns.
2045+
"""
2046+
script = r"""
2047+
Add-Type -TypeDefinition @"
2048+
using System;
2049+
using System.Runtime.InteropServices;
2050+
using System.Text;
2051+
public static class FgWin {
2052+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
2053+
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
2054+
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
2055+
public static string GetTitle() {
2056+
var sb = new StringBuilder(512);
2057+
GetWindowText(GetForegroundWindow(), sb, sb.Capacity);
2058+
return sb.ToString();
2059+
}
2060+
}
2061+
"@
2062+
[FgWin]::GetTitle()
2063+
"""
2064+
try:
2065+
output = self.run_powershell(script).strip()
2066+
# Take the last non-empty line (PowerShell may emit warnings before).
2067+
title = ""
2068+
for line in reversed(output.splitlines()):
2069+
line = line.strip()
2070+
if line:
2071+
title = line
2072+
break
2073+
2074+
self._last_foreground_title = title
2075+
2076+
# Detect known-bad foreground states.
2077+
title_lower = title.lower()
2078+
if not title:
2079+
logger.warning(
2080+
"Win32 foreground check: window title is empty/blank"
2081+
)
2082+
return False
2083+
for bad in self._BAD_FOREGROUND_TITLES:
2084+
if bad in title_lower:
2085+
logger.warning(
2086+
"Win32 foreground check: detected known-bad title '%s'",
2087+
title[:120],
2088+
)
2089+
return False
2090+
2091+
for pattern in patterns:
2092+
if pattern.lower() in title_lower:
2093+
logger.debug(
2094+
"Win32 foreground check: matched '%s' in '%s'",
2095+
pattern,
2096+
title[:100],
2097+
)
2098+
return True
2099+
logger.debug(
2100+
"Win32 foreground check: no pattern matched in '%s'",
2101+
title[:100],
2102+
)
2103+
except Exception as e:
2104+
logger.debug("Win32 foreground check failed: %s", e)
2105+
return False
2106+
2107+
def _check_foreground_dispatch(
2108+
self,
2109+
patterns: list[str],
2110+
requests_module: Any,
2111+
) -> bool:
2112+
"""Dispatch foreground check based on configured method.
2113+
2114+
Args:
2115+
patterns: Window title substrings to match (case-insensitive).
2116+
requests_module: The ``requests`` module (needed for a11y fallback).
2117+
2118+
Returns:
2119+
True if the foreground window matches any pattern.
2120+
"""
2121+
method = self.config.focus_check_method
2122+
2123+
if method == "win32":
2124+
return self._check_foreground_win32(patterns)
2125+
elif method == "a11y":
2126+
return self._check_foreground_matches(patterns, requests_module)
2127+
elif method == "both":
2128+
# Try fast Win32 first; fall back to a11y if it fails.
2129+
result = self._check_foreground_win32(patterns)
2130+
if result:
2131+
return True
2132+
logger.debug(
2133+
"Win32 foreground check negative; falling back to a11y"
2134+
)
2135+
return self._check_foreground_matches(patterns, requests_module)
2136+
else:
2137+
logger.warning(
2138+
"Unknown focus_check_method '%s'; defaulting to win32",
2139+
method,
2140+
)
2141+
return self._check_foreground_win32(patterns)
2142+
20252143
def _check_foreground_matches(
20262144
self,
20272145
patterns: list[str],

openadapt_evals/benchmarks/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ def cmd_run(args: argparse.Namespace) -> int:
345345
clean_desktop=getattr(args, "clean_desktop", False),
346346
force_tray_icons=getattr(args, "force_tray_icons", False),
347347
waa_image_version=getattr(args, "waa_image_version", None),
348+
focus_check_method=getattr(args, "focus_check_method", "win32"),
348349
)
349350
adapter = WAALiveAdapter(config)
350351

@@ -551,6 +552,7 @@ def cmd_live(args: argparse.Namespace) -> int:
551552
clean_desktop=getattr(args, "clean_desktop", False),
552553
force_tray_icons=getattr(args, "force_tray_icons", False),
553554
waa_image_version=getattr(args, "waa_image_version", None),
555+
focus_check_method=getattr(args, "focus_check_method", "win32"),
554556
)
555557
adapter = WAALiveAdapter(config)
556558

@@ -961,6 +963,7 @@ def patch_evaluate_endpoint() -> bool:
961963
clean_desktop=getattr(args, "clean_desktop", False),
962964
force_tray_icons=getattr(args, "force_tray_icons", False),
963965
waa_image_version=getattr(args, "waa_image_version", None),
966+
focus_check_method=getattr(args, "focus_check_method", "win32"),
964967
)
965968
)
966969

@@ -2426,6 +2429,9 @@ def main() -> int:
24262429
help="Max times to override premature 'done' (default: 3)")
24272430
run_parser.add_argument("--done-gate-threshold", type=float, default=1.0,
24282431
help="Minimum score to accept 'done' (default: 1.0)")
2432+
run_parser.add_argument("--focus-check-method", type=str, default="win32",
2433+
choices=["win32", "a11y", "both"],
2434+
help="Method for foreground window check: win32 (fast, default), a11y, or both")
24292435

24302436
# Live evaluation (full control)
24312437
live_parser = subparsers.add_parser("live", help="Run live evaluation against WAA server (full control)")
@@ -2460,6 +2466,9 @@ def main() -> int:
24602466
help="Max times to override premature 'done' (default: 3)")
24612467
live_parser.add_argument("--done-gate-threshold", type=float, default=1.0,
24622468
help="Minimum score to accept 'done' (default: 1.0)")
2469+
live_parser.add_argument("--focus-check-method", type=str, default="win32",
2470+
choices=["win32", "a11y", "both"],
2471+
help="Method for foreground window check: win32 (fast, default), a11y, or both")
24632472

24642473
# Probe server
24652474
probe_parser = subparsers.add_parser("probe", help="Check if WAA server is reachable")

tests/test_setup_handlers.py

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -597,9 +597,9 @@ def test_calls_activate_window_with_patterns(self):
597597
adapter = self._make_adapter()
598598

599599
# Simulate activate_window success (/setup) and foreground check success
600-
# via accessibility endpoint (/accessibility).
600+
# via win32 API (default focus_check_method).
601601
setup_calls = []
602-
a11y_calls = []
602+
ps_calls = []
603603

604604
def _fake_post(url, **kwargs):
605605
setup_calls.append(url)
@@ -609,30 +609,27 @@ def _fake_post(url, **kwargs):
609609
resp.text = '{"results": [{"type": "activate_window", "status": "ok"}]}'
610610
return resp
611611

612-
def _fake_get(url, **kwargs):
613-
a11y_calls.append(url)
614-
resp = MagicMock()
615-
resp.status_code = 200
616-
resp.json.return_value = {"AT": {"name": "LibreOffice Calc - data.xlsx"}}
617-
return resp
612+
def _fake_powershell(script, **kwargs):
613+
ps_calls.append(script)
614+
return "LibreOffice Calc - data.xlsx"
618615

619616
with patch("requests.post", side_effect=_fake_post), \
620-
patch("requests.get", side_effect=_fake_get), \
617+
patch.object(adapter, "run_powershell", side_effect=_fake_powershell), \
621618
patch("time.sleep"):
622619
adapter._ensure_app_focused({
623620
"related_apps": ["libreoffice_calc"],
624621
})
625622

626-
# Should have called activate_window at least once and check at least once
623+
# Should have called activate_window at least once and win32 check at least once
627624
assert len(setup_calls) >= 1
628-
assert len(a11y_calls) >= 1
625+
assert len(ps_calls) >= 1
629626

630627
def test_retries_on_foreground_mismatch(self):
631628
"""Retries when foreground check does not match expected pattern."""
632629
adapter = self._make_adapter()
633630

634631
setup_calls = []
635-
a11y_calls = []
632+
ps_calls = []
636633

637634
def _fake_post(url, **kwargs):
638635
setup_calls.append(url)
@@ -642,24 +639,21 @@ def _fake_post(url, **kwargs):
642639
resp.text = '{"results": [{"type": "activate_window", "status": "ok"}]}'
643640
return resp
644641

645-
def _fake_get(url, **kwargs):
646-
a11y_calls.append(url)
647-
resp = MagicMock()
648-
resp.status_code = 200
642+
def _fake_powershell(script, **kwargs):
643+
ps_calls.append(script)
649644
# Always report desktop as foreground (mismatch).
650-
resp.json.return_value = {"AT": {"name": "Desktop"}}
651-
return resp
645+
return "Desktop"
652646

653647
with patch("requests.post", side_effect=_fake_post), \
654-
patch("requests.get", side_effect=_fake_get), \
648+
patch.object(adapter, "run_powershell", side_effect=_fake_powershell), \
655649
patch("time.sleep"):
656650
adapter._ensure_app_focused({
657651
"related_apps": ["notepad"],
658652
})
659653

660-
# Should have tried multiple setup activations and foreground checks.
654+
# Should have tried multiple setup activations and win32 foreground checks.
661655
assert len(setup_calls) >= 3 # At least 3 retry attempts
662-
assert len(a11y_calls) >= 3 # At least 3 foreground checks
656+
assert len(ps_calls) >= 3 # At least 3 foreground checks
663657

664658
def test_succeeds_on_second_attempt(self):
665659
"""If first attempt fails but second succeeds, returns after second."""
@@ -674,20 +668,17 @@ def _fake_post(url, **kwargs):
674668
resp.text = '{"results": []}'
675669
return resp
676670

677-
def _fake_get(url, **kwargs):
671+
def _fake_powershell(script, **kwargs):
678672
attempt_count[0] += 1
679-
resp = MagicMock()
680-
resp.status_code = 200
681673
if attempt_count[0] <= 2:
682-
# First attempt: wrong window
683-
resp.json.return_value = {"AT": {"name": "Desktop"}}
674+
# First attempts: wrong window
675+
return "Desktop"
684676
else:
685-
# Second attempt: correct window
686-
resp.json.return_value = {"AT": {"name": "LibreOffice Calc"}}
687-
return resp
677+
# Later attempt: correct window
678+
return "LibreOffice Calc"
688679

689680
with patch("requests.post", side_effect=_fake_post), \
690-
patch("requests.get", side_effect=_fake_get), \
681+
patch.object(adapter, "run_powershell", side_effect=_fake_powershell), \
691682
patch("time.sleep"):
692683
adapter._ensure_app_focused({
693684
"related_apps": ["libreoffice_calc"],

0 commit comments

Comments
 (0)