Skip to content

Commit c44cb3b

Browse files
authored
Merge pull request #420 from Integration-Automation/feat/legacy-accessible-batch
Add legacy_accessible: MSAA bridge for old controls UIA can't model
2 parents 194ff16 + 0f0e354 commit c44cb3b

13 files changed

Lines changed: 351 additions & 0 deletions

File tree

WHATS_NEW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# What's New — AutoControl
22

3+
## What's new (2026-06-25) — MSAA Bridge for Legacy Controls (LegacyIAccessible)
4+
5+
Automate the long tail of old Win32 controls that expose nothing via modern UIA. Full reference: [`docs/source/Eng/doc/new_features/v199_features_doc.rst`](docs/source/Eng/doc/new_features/v199_features_doc.rst).
6+
7+
- **`legacy_info` / `legacy_default_action`** (`AC_legacy_info`, `AC_legacy_default_action`): many legacy Win32 / MFC / Delphi controls expose nothing useful via modern UIA patterns (`control_get_value` / `control_invoke` / `control_toggle` all return None), yet they're fully described through the MSAA `IAccessible` bridge — Name, Value, Description, Role, State and a **DefaultAction**. This reads that info and fires the default action via `LegacyIAccessiblePattern` — the last-resort fallback that makes old apps automatable. Dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA in the Windows backend). No `PySide6`.
8+
39
## What's new (2026-06-25) — Move / Resize Elements + Window State (UIA Transform + Window)
410

511
Move a floating panel, resize a control, and know if a window is modal-blocked. Full reference: [`docs/source/Eng/doc/new_features/v198_features_doc.rst`](docs/source/Eng/doc/new_features/v198_features_doc.rst).
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
MSAA Bridge for Legacy Controls (LegacyIAccessible)
2+
===================================================
3+
4+
Many legacy Win32 / MFC / Delphi controls expose **nothing useful** via the modern
5+
UI Automation patterns — ``control_get_value`` / ``control_invoke`` /
6+
``control_toggle`` all return None or do nothing — yet they are fully described
7+
through the MSAA ``IAccessible`` bridge: a Name, Value, Description, Role, State
8+
and a **DefaultAction**. ``legacy_accessible`` is the last-resort fallback that
9+
still reads that info and fires the default action, making the long tail of old
10+
apps automatable.
11+
12+
* :func:`legacy_info` — the MSAA fields ``{name, value, description,
13+
default_action, role, state}``,
14+
* :func:`legacy_default_action` — fire the control's default action.
15+
16+
Each is a thin dispatch onto the injectable ``accessibility.backends.get_backend()``
17+
seam — headless-testable via a fake backend; the real ``LegacyIAccessiblePattern``
18+
calls live in the Windows backend. Imports no ``PySide6``.
19+
20+
Headless API
21+
------------
22+
23+
.. code-block:: python
24+
25+
from je_auto_control import (legacy_info, legacy_default_action,
26+
control_invoke)
27+
28+
# Modern patterns came up empty? Fall back to MSAA:
29+
if not control_invoke(name="Apply"):
30+
info = legacy_info(name="Apply") # {"default_action": "Press", ...}
31+
legacy_default_action(name="Apply") # fires the MSAA default action
32+
33+
The control is located by ``name`` / ``role`` / ``app_name`` / ``automation_id``
34+
(same as the other native-control actions). ``legacy_info`` returns the MSAA info
35+
dict (``role`` / ``state`` are the raw MSAA numbers) or ``None`` if the control or
36+
pattern isn't found; ``legacy_default_action`` returns ``bool``.
37+
38+
Executor commands
39+
-----------------
40+
41+
``AC_legacy_info`` (``{found, info}``) and ``AC_legacy_default_action``. They are
42+
exposed as the matching ``ac_*`` MCP tools (info read-only, the action
43+
destructive) and as Script Builder commands under **Native UI**.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
舊式控制項的 MSAA 橋接(LegacyIAccessible)
2+
==========================================
3+
4+
許多舊式 Win32 / MFC / Delphi 控制項透過現代 UI Automation 模式**完全不提供有用資訊**——
5+
``control_get_value`` / ``control_invoke`` / ``control_toggle`` 都回 None 或毫無作用——但它們透過
6+
MSAA ``IAccessible`` 橋接卻有完整描述:Name、Value、Description、Role、State,以及一個
7+
**DefaultAction**。``legacy_accessible`` 就是那個最後手段的後備:仍能讀取這些資訊並觸發預設動作,
8+
讓大量舊應用程式得以自動化。
9+
10+
* :func:`legacy_info` ——MSAA 欄位 ``{name, value, description, default_action, role, state}``,
11+
* :func:`legacy_default_action` ——觸發控制項的預設動作。
12+
13+
每個都是對可注入的 ``accessibility.backends.get_backend()`` 接縫的薄分派——可透過注入 fake
14+
backend 進行無頭測試;真正的 ``LegacyIAccessiblePattern`` 呼叫位於 Windows 後端。不匯入
15+
``PySide6``。
16+
17+
無頭 API
18+
--------
19+
20+
.. code-block:: python
21+
22+
from je_auto_control import (legacy_info, legacy_default_action,
23+
control_invoke)
24+
25+
# 現代模式撲空?退回 MSAA:
26+
if not control_invoke(name="Apply"):
27+
info = legacy_info(name="Apply") # {"default_action": "Press", ...}
28+
legacy_default_action(name="Apply") # 觸發 MSAA 預設動作
29+
30+
控制項以 ``name`` / ``role`` / ``app_name`` / ``automation_id`` 定位(與其他原生控制動作相同)。
31+
``legacy_info`` 回傳 MSAA 資訊字典(``role`` / ``state`` 為原始 MSAA 數字),找不到控制項或模式
32+
則回傳 ``None``;``legacy_default_action`` 回傳 ``bool``。
33+
34+
執行器指令
35+
----------
36+
37+
``AC_legacy_info``(``{found, info}``)與 ``AC_legacy_default_action``。皆以對應的 ``ac_*`` MCP
38+
工具(info 為唯讀、動作為破壞性)及 Script Builder 指令(位於 **Native UI** 分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@
8080
from je_auto_control.utils.transform_window import (
8181
move_element, resize_element, set_window_state, window_interaction_state,
8282
)
83+
# MSAA bridge for old controls UIA can't model (LegacyIAccessiblePattern)
84+
from je_auto_control.utils.legacy_accessible import (
85+
legacy_default_action, legacy_info,
86+
)
8387
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
8488
from je_auto_control.utils.clipboard_rich_formats import (
8589
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1685,6 +1689,7 @@ def start_autocontrol_gui(*args, **kwargs):
16851689
"table_headers", "table_cell", "cell_by_header",
16861690
"move_element", "resize_element", "set_window_state",
16871691
"window_interaction_state",
1692+
"legacy_info", "legacy_default_action",
16881693
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
16891694
"set_clipboard_rtf", "get_clipboard_rtf",
16901695
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1666,6 +1666,16 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None:
16661666
fields=fields,
16671667
description="Read window readiness (ready/blocked_by_modal/...).",
16681668
))
1669+
specs.append(CommandSpec(
1670+
"AC_legacy_info", "Native UI", "Legacy (MSAA) Info",
1671+
fields=fields,
1672+
description="Read an old control's MSAA info (LegacyIAccessible).",
1673+
))
1674+
specs.append(CommandSpec(
1675+
"AC_legacy_default_action", "Native UI", "Legacy (MSAA) Default Action",
1676+
fields=fields,
1677+
description="Fire an old control's MSAA default action (fallback).",
1678+
))
16691679
specs.append(CommandSpec(
16701680
"AC_get_control_text", "Native UI", "Get Control Text",
16711681
fields=fields,

je_auto_control/utils/accessibility/backends/base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,28 @@ def window_interaction_state(self, name: Optional[str] = None,
213213
``not_responding`` / ``running`` / ``closing`` (WindowPattern), or None."""
214214
self._unsupported("window_interaction_state")
215215

216+
# --- MSAA bridge (LegacyIAccessiblePattern) ----------------------------
217+
218+
def legacy_info(self, name: Optional[str] = None, role: Optional[str] = None,
219+
app_name: Optional[str] = None,
220+
automation_id: Optional[str] = None,
221+
) -> Optional[Dict[str, Any]]:
222+
"""Return the MSAA ``IAccessible`` info of an old control, or None.
223+
224+
``{name, value, description, default_action, role, state}`` — the
225+
last-resort read for legacy Win32 controls that expose nothing useful via
226+
the modern UIA patterns.
227+
"""
228+
self._unsupported("legacy_info")
229+
230+
def legacy_default_action(self, name: Optional[str] = None,
231+
role: Optional[str] = None,
232+
app_name: Optional[str] = None,
233+
automation_id: Optional[str] = None) -> bool:
234+
"""Fire an old control's MSAA default action (DoDefaultAction); True on
235+
success — the fallback when Value / Invoke / Toggle all do nothing."""
236+
self._unsupported("legacy_default_action")
237+
216238
def _unsupported(self, operation: str):
217239
"""Raise a clear error for an action this backend can't perform."""
218240
raise AccessibilityNotAvailableError(

je_auto_control/utils/accessibility/backends/windows_backend.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
_UIA_GRIDITEM_PATTERN_ID = 10007
3737
_UIA_TRANSFORM_PATTERN_ID = 10016
3838
_UIA_WINDOW_PATTERN_ID = 10009
39+
_UIA_LEGACYIACCESSIBLE_PATTERN_ID = 10018
3940
_UIA_AUTOMATIONID_PROPERTY = 30011
4041
_EXPAND_STATES = {0: "collapsed", 1: "expanded", 2: "partial", 3: "leaf"}
4142
_WINDOW_VISUAL_STATES = {"normal": 0, "maximized": 1, "minimized": 2}
@@ -351,6 +352,24 @@ def window_interaction_state(self, name=None, role=None, app_name=None,
351352
except (OSError, AttributeError, ValueError, TypeError):
352353
return None
353354

355+
def legacy_info(self, name=None, role=None, app_name=None,
356+
automation_id=None) -> Optional[Dict[str, Any]]:
357+
raw = self._find_raw(name, role, app_name, automation_id)
358+
pattern = self._pattern(raw, _UIA_LEGACYIACCESSIBLE_PATTERN_ID,
359+
"IUIAutomationLegacyIAccessiblePattern"
360+
) if raw else None
361+
if pattern is None:
362+
return None
363+
return _read_legacy(pattern)
364+
365+
def legacy_default_action(self, name=None, role=None, app_name=None,
366+
automation_id=None):
367+
return self._invoke_pattern_method(
368+
name, role, app_name, automation_id,
369+
_UIA_LEGACYIACCESSIBLE_PATTERN_ID,
370+
"IUIAutomationLegacyIAccessiblePattern",
371+
lambda pattern: pattern.DoDefaultAction())
372+
354373
def get_table_headers(self, name=None, role=None, app_name=None,
355374
automation_id=None) -> Optional[Dict[str, Any]]:
356375
raw = self._find_raw(name, role, app_name, automation_id)
@@ -493,6 +512,28 @@ def _as_text(value) -> str:
493512
return str(value or "")
494513

495514

515+
# (key, LegacyIAccessiblePattern attribute, cast) for the MSAA bridge read.
516+
_LEGACY_READS = (
517+
("name", "CurrentName", _as_text),
518+
("value", "CurrentValue", _as_text),
519+
("description", "CurrentDescription", _as_text),
520+
("default_action", "CurrentDefaultAction", _as_text),
521+
("role", "CurrentRole", int),
522+
("state", "CurrentState", int),
523+
)
524+
525+
526+
def _read_legacy(pattern) -> Dict[str, Any]:
527+
"""Read a LegacyIAccessiblePattern's MSAA fields into a plain dict."""
528+
info: Dict[str, Any] = {}
529+
for key, attribute, cast in _LEGACY_READS:
530+
try:
531+
info[key] = cast(getattr(pattern, attribute))
532+
except (OSError, AttributeError, ValueError, TypeError):
533+
info[key] = None
534+
return info
535+
536+
496537
# (key, UIA element attribute, cast) for the rich properties the flat list omits.
497538
_PROPERTY_READS = (
498539
("enabled", "CurrentIsEnabled", bool),

je_auto_control/utils/executor/action_executor.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2554,6 +2554,25 @@ def _window_interaction_state(name: Optional[str] = None,
25542554
automation_id=automation_id)}
25552555

25562556

2557+
def _legacy_info(name: Optional[str] = None, role: Optional[str] = None,
2558+
app_name: Optional[str] = None,
2559+
automation_id: Optional[str] = None) -> Dict[str, Any]:
2560+
"""Adapter: MSAA IAccessible info of an old control (LegacyIAccessible)."""
2561+
from je_auto_control.utils.legacy_accessible import legacy_info
2562+
info = legacy_info(name=name, role=role, app_name=app_name,
2563+
automation_id=automation_id)
2564+
return {"found": info is not None, "info": info}
2565+
2566+
2567+
def _legacy_default_action(name: Optional[str] = None, role: Optional[str] = None,
2568+
app_name: Optional[str] = None,
2569+
automation_id: Optional[str] = None) -> bool:
2570+
"""Adapter: fire an old control's MSAA default action (Value/Invoke fallback)."""
2571+
from je_auto_control.utils.legacy_accessible import legacy_default_action
2572+
return legacy_default_action(name=name, role=role, app_name=app_name,
2573+
automation_id=automation_id)
2574+
2575+
25572576
def _get_control_text(name: Optional[str] = None, role: Optional[str] = None,
25582577
app_name: Optional[str] = None,
25592578
automation_id: Optional[str] = None) -> Dict[str, Any]:
@@ -6506,6 +6525,8 @@ def __init__(self):
65066525
"AC_resize_element": _resize_element,
65076526
"AC_set_window_state": _set_window_state,
65086527
"AC_window_interaction_state": _window_interaction_state,
6528+
"AC_legacy_info": _legacy_info,
6529+
"AC_legacy_default_action": _legacy_default_action,
65096530
"AC_get_control_text": _get_control_text,
65106531
"AC_get_selected_text": _get_selected_text,
65116532
"AC_get_visible_text": _get_visible_text,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""MSAA bridge for old controls UIA can't model (LegacyIAccessiblePattern)."""
2+
from je_auto_control.utils.legacy_accessible.legacy_accessible import (
3+
legacy_default_action, legacy_info,
4+
)
5+
6+
__all__ = ["legacy_info", "legacy_default_action"]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""MSAA bridge for old controls UIA can't model (LegacyIAccessiblePattern).
2+
3+
Many legacy Win32 / MFC / Delphi controls expose **nothing useful** via the modern
4+
UIA patterns — ``control_get_value`` / ``control_invoke`` / ``control_toggle`` all
5+
return None / do nothing — yet they are fully described through the MSAA
6+
``IAccessible`` bridge: a Name, Value, Description, Role, State and a
7+
**DefaultAction**. ``legacy_accessible`` is the last-resort fallback that still
8+
reads that info and fires the default action, making the long tail of old apps
9+
automatable.
10+
11+
* :func:`legacy_info` — the MSAA fields ``{name, value, description,
12+
default_action, role, state}``,
13+
* :func:`legacy_default_action` — fire the control's default action.
14+
15+
Each is a thin dispatch onto the injectable ``accessibility.backends.get_backend()``
16+
seam — headless-testable via a fake backend; the real ``LegacyIAccessiblePattern``
17+
calls live in the Windows backend. Imports no ``PySide6``.
18+
"""
19+
from typing import Any, Dict, Optional
20+
21+
22+
def legacy_info(name: Optional[str] = None, role: Optional[str] = None,
23+
app_name: Optional[str] = None,
24+
automation_id: Optional[str] = None,
25+
) -> Optional[Dict[str, Any]]:
26+
"""Return the MSAA ``IAccessible`` info of an old control, or None if absent."""
27+
from je_auto_control.utils.accessibility.backends import get_backend
28+
return get_backend().legacy_info(name=name, role=role, app_name=app_name,
29+
automation_id=automation_id)
30+
31+
32+
def legacy_default_action(name: Optional[str] = None, role: Optional[str] = None,
33+
app_name: Optional[str] = None,
34+
automation_id: Optional[str] = None) -> bool:
35+
"""Fire an old control's MSAA default action (the Value/Invoke/Toggle fallback)."""
36+
from je_auto_control.utils.accessibility.backends import get_backend
37+
return get_backend().legacy_default_action(name=name, role=role,
38+
app_name=app_name,
39+
automation_id=automation_id)

0 commit comments

Comments
 (0)