diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 3be380b4..13c1a794 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,25 @@ # What's New — AutoControl +## What's new (2026-06-26) + +### Live IME State for Safe CJK Entry + +Wait for the input method to commit before reading a Japanese/Chinese/Korean field. Full reference: [`docs/source/Eng/doc/new_features/v208_features_doc.rst`](docs/source/Eng/doc/new_features/v208_features_doc.rst). + +- **`ime_state` / `is_composing` / `wait_for_composition_commit` / `decode_conversion_mode`** (`AC_ime_state`, `AC_is_composing`, `AC_wait_for_composition_commit`, `AC_decode_conversion_mode`): typing into a CJK field is unsafe while an IME is *composing* — the candidate text isn't committed, so reading the field back returns half-entered glyphs and the next keystroke edits the composition. `text_unicode` (`VK_PACKET`) is blind to this. `ime_state` exposes the focused window's live `{open, composing, composition, conversion}` (Windows IMM32, read-only) through an injectable `reader`; `is_composing` is the boolean gate; `wait_for_composition_commit` blocks until the IME commits (injectable `clock`/`sleep`/`reader`); `decode_conversion_mode` is the pure `IME_CMODE_*` bitmask decoder. All decode/wait logic is unit-tested without an IME. Sixth feature of the ROUND-15 cross-app OS lane. No `PySide6`. + +### Lock the Workstation + Wait for Unlock + +Lock the box at the end of a run, and block until a human unlocks it before resuming. Full reference: [`docs/source/Eng/doc/new_features/v207_features_doc.rst`](docs/source/Eng/doc/new_features/v207_features_doc.rst). + +- **`lock_session` / `plan_lock_session` / `wait_for_unlock` / `wait_for_lock` / `classify_lock_transitions`** (`AC_lock_session`, `AC_plan_lock_session`, `AC_wait_for_unlock`, `AC_classify_lock_transitions`): `session_guard` could *detect* a locked session and raise; this adds *acting* on it. `lock_session` locks the workstation now (`LockWorkStation` on Windows, `loginctl lock-session` / `CGSession -suspend` elsewhere) through an injectable `driver`; `wait_for_unlock` / `wait_for_lock` poll `session_guard.is_session_locked` (reusing its real Windows `OpenInputDesktop` probe) until the state flips or a timeout, with injectable `clock` / `sleep` / `probe`; `plan_lock_session` is the pure per-OS planner and `classify_lock_transitions` reduces a lock-state sample stream to `{event, locked}` lock/unlock events. `wait_for_unlock` is the blocking companion to `ensure_interactive_session`. Fifth feature of the ROUND-15 cross-app OS lane. No `PySide6`. + +### Read and Control the System Volume + +Set a known audio baseline before a run — mute, set 30%, or assert the level. Full reference: [`docs/source/Eng/doc/new_features/v206_features_doc.rst`](docs/source/Eng/doc/new_features/v206_features_doc.rst). + +- **`get_volume` / `set_volume` / `change_volume` / `is_muted` / `set_mute` / `mute` / `unmute` / `toggle_mute`** (`AC_get_volume`, `AC_set_volume`, `AC_change_volume`, `AC_set_mute`, `AC_toggle_mute`): the framework only had the blind media-key steps (`volume up` / `down` nudge by an unknown amount with no read-back). This adds absolute, read-backable control of the default output device — read or set the master level as an integer percent `0..100`, and read / set / toggle the mute flag. All logic (clamping, percent↔scalar conversion, toggle) is pure and runs through an injectable `VolumeDriver` seam, so it is fully unit-tested without an audio device; the default driver uses the Windows Core Audio `IAudioEndpointVolume` interface through the optional `pycaw` dependency (`pip install je_auto_control[audio]`), degrading with a clear error when absent. Fourth feature of the ROUND-15 cross-app OS lane. No `PySide6`. + ## What's new (2026-06-25) ### Resolve the App Registered for a File Type diff --git a/docs/source/Eng/doc/new_features/v206_features_doc.rst b/docs/source/Eng/doc/new_features/v206_features_doc.rst new file mode 100644 index 00000000..30feb059 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v206_features_doc.rst @@ -0,0 +1,67 @@ +Read and Control the System Volume +================================== + +Unattended runs often need a known audio baseline — mute before a noisy batch, +restore a level afterwards, or assert the current volume — but the framework +only had the blind media-key steps (``volume up`` / ``down`` nudge by an unknown +amount with no read-back). ``system_volume`` adds absolute, read-backable +control of the default output device. + +* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` — read and + write the master level as an integer percent ``0..100`` (``set_volume`` and + ``change_volume`` clamp to that range). +* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` / + :func:`toggle_mute` — read and write the mute flag. + +All logic (clamping, percent <-> scalar conversion, toggle) is pure and runs +through an injectable :class:`VolumeDriver` seam, so it is fully testable without +an audio device. The default driver drives the Windows Core Audio +``IAudioEndpointVolume`` interface through the optional ``pycaw`` dependency +(``pip install je_auto_control[audio]``); on a platform / install without it the +default driver raises a clear error telling the caller to pass ``driver=``. +Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + get_volume, set_volume, change_volume, is_muted, mute, unmute, + toggle_mute, + ) + + get_volume() # e.g. 65 — current master volume percent + set_volume(30) # set to 30 %, returns 30 + change_volume(-10) # lower by 10 %, returns the applied percent + is_muted() # False + mute() # True — silence the output + unmute() # False — restore it + toggle_mute() # flip and return the new state + +For tests (or any non-Windows host) pass a ``driver`` — any object exposing +``get_scalar`` / ``set_scalar`` / ``get_mute`` / ``set_mute`` over a ``0.0..1.0`` +scalar: + +.. code-block:: python + + class FakeVolume: + def __init__(self, scalar=0.5, muted=False): + self.scalar, self.muted = scalar, muted + def get_scalar(self): return self.scalar + def set_scalar(self, s): self.scalar = s + def get_mute(self): return self.muted + def set_mute(self, m): self.muted = m + + drv = FakeVolume() + set_volume(73, driver=drv) # 73, drv.scalar == 0.73 + +Executor commands +----------------- + +``AC_get_volume`` (→ ``{volume, muted}``), ``AC_set_volume`` (``level`` → +``{volume}``), ``AC_change_volume`` (``delta`` → ``{volume}``), ``AC_set_mute`` +(``muted`` → ``{muted}``) and ``AC_toggle_mute`` (→ ``{muted}``). They are +exposed as the matching ``ac_*`` MCP tools (the read is read-only, the writes +side-effect-only) and as Script Builder commands under **Shell**. The executor +and MCP layers use the default OS driver, so they require ``pycaw`` on Windows. diff --git a/docs/source/Eng/doc/new_features/v207_features_doc.rst b/docs/source/Eng/doc/new_features/v207_features_doc.rst new file mode 100644 index 00000000..73585e2c --- /dev/null +++ b/docs/source/Eng/doc/new_features/v207_features_doc.rst @@ -0,0 +1,62 @@ +Lock the Workstation + Wait for Unlock +====================================== + +:mod:`session_guard` answers "is the session locked right now?" and raises if it +is. The missing half is *acting* on the lock state: lock the machine at the end +of an unattended run, block until a human unlocks it before resuming, or reduce +a stream of lock-state samples to lock / unlock events. ``lock_session`` adds +that, behind injectable seams so the logic is testable without touching the OS. + +* :func:`lock_session` — lock the workstation now (``LockWorkStation`` on + Windows, ``loginctl lock-session`` on Linux, ``CGSession -suspend`` on macOS) + through an injectable ``driver``. +* :func:`plan_lock_session` — pure planner: how the lock would be performed on + this OS and whether a default is available (``{backend, argv, available}``). +* :func:`wait_for_unlock` / :func:`wait_for_lock` — poll + :func:`is_session_locked` until the state flips or a timeout, with injectable + ``clock`` / ``sleep`` / ``probe`` for deterministic tests. +* :func:`classify_lock_transitions` — pure: a list of lock-state samples to a + list of ``{event, locked}`` lock / unlock transitions. + +The lock probe reused by the wait helpers is :mod:`session_guard`'s — the +Windows ``OpenInputDesktop`` check — so ``wait_for_unlock`` is the blocking +companion to ``ensure_interactive_session`` (which only raises). Imports no +``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + lock_session, wait_for_unlock, classify_lock_transitions, + ) + + # ... unattended run finishes ... + lock_session() # secure the machine + + # Resume only once a human has unlocked the box + if wait_for_unlock(timeout_s=600): + run_next_stage() + + # Reduce a sampled lock-state log to events + classify_lock_transitions([False, True, True, False]) + # -> [{'event': 'lock', 'locked': True}, + # {'event': 'unlock', 'locked': False}] + +For tests (or any host) pass a ``driver`` / ``probe``: + +.. code-block:: python + + locked = lock_session(driver=lambda: True) # no real lock + wait_for_unlock(probe=lambda: False) # already unlocked + +Executor commands +----------------- + +``AC_lock_session`` (→ ``{locked}``), ``AC_plan_lock_session`` (→ the plan), +``AC_wait_for_unlock`` (``timeout`` / ``interval`` → ``{unlocked}``) and +``AC_classify_lock_transitions`` (``states`` JSON list → ``{events}``). They are +exposed as the matching ``ac_*`` MCP tools (``ac_lock_session`` is destructive — +it interrupts the session; the rest are read-only) and as Script Builder +commands under **Shell**. diff --git a/docs/source/Eng/doc/new_features/v208_features_doc.rst b/docs/source/Eng/doc/new_features/v208_features_doc.rst new file mode 100644 index 00000000..cffcf275 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v208_features_doc.rst @@ -0,0 +1,57 @@ +Live IME State for Safe CJK Entry +================================= + +Typing into a CJK / Japanese / Korean field is unsafe while an IME (input method +editor) is *composing*: the candidate text has not been committed yet, so +reading the field back returns half-entered glyphs and the next keystroke edits +the composition instead of the field. ``text_unicode`` (``VK_PACKET``) is blind +to this. ``ime_state`` exposes the live composition and conversion state so a +flow can wait for the IME to commit before it reads or acts. + +* :func:`ime_state` — ``{open, composing, composition, conversion, + conversion_flags}`` for the focused window's IME, through an injectable + ``reader``. +* :func:`is_composing` — ``True`` while the IME has an uncommitted composition. +* :func:`wait_for_composition_commit` — block until composition ends (or a + timeout), with injectable ``clock`` / ``sleep`` / ``reader``. +* :func:`decode_conversion_mode` — pure: the IMM32 ``IME_CMODE_*`` conversion + bitmask to ``{native, katakana, full_shape, roman, char_code}``. + +The default ``reader`` queries Windows IMM32 (``ImmGetContext`` / +``ImmGetOpenStatus`` / ``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``) +read-only; all decoding / waiting logic runs through the injectable seam, so it +is fully testable without an IME. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + ime_state, is_composing, wait_for_composition_commit, + ) + + # Before reading a CJK field, make sure the IME has committed + if wait_for_composition_commit(timeout_s=3): + value = read_field() + + is_composing() # True while candidate text is still on screen + ime_state() # {'open': True, 'composing': True, 'composition': 'あ', ...} + +For tests (or any non-Windows host) pass a ``reader`` — a +``() -> {open, conversion, composition}``: + +.. code-block:: python + + busy = lambda: {"open": True, "conversion": 0, "composition": "あ"} + is_composing(reader=busy) # True + ime_state(reader=busy)["composition"] # 'あ' + +Executor commands +----------------- + +``AC_ime_state`` (→ the full state), ``AC_is_composing`` (→ ``{composing}``), +``AC_wait_for_composition_commit`` (``timeout`` / ``interval`` → +``{committed}``) and ``AC_decode_conversion_mode`` (``flags`` → the decoded +modes). They are exposed as the matching read-only ``ac_*`` MCP tools and as +Script Builder commands under **Shell**. diff --git a/docs/source/Zh/doc/new_features/v206_features_doc.rst b/docs/source/Zh/doc/new_features/v206_features_doc.rst new file mode 100644 index 00000000..a6617bbd --- /dev/null +++ b/docs/source/Zh/doc/new_features/v206_features_doc.rst @@ -0,0 +1,60 @@ +讀取與控制系統音量 +================== + +無人值守的執行常需要一個已知的音訊基準——在吵雜的批次前靜音、結束後還原音量,或斷言目前音量——但框架 +原本只有盲目的媒體鍵步驟(``volume up`` / ``down`` 以未知幅度推移,且無法讀回)。``system_volume`` +補上對預設輸出裝置的絕對、可讀回控制。 + +* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` ——以整數百分比 ``0..100`` + 讀寫主音量(``set_volume`` 與 ``change_volume`` 會夾到該範圍)。 +* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` / + :func:`toggle_mute` ——讀寫靜音旗標。 + +所有邏輯(夾值、百分比 <-> 純量轉換、切換)皆為純函式,並透過可注入的 :class:`VolumeDriver` 接縫執行, +故能在不需音訊裝置的情況下完整測試。預設 driver 透過選用相依套件 ``pycaw`` +(``pip install je_auto_control[audio]``)驅動 Windows Core Audio 的 +``IAudioEndpointVolume`` 介面;在沒有該套件 / 非 Windows 平台上,預設 driver 會丟出清楚的錯誤, +提示呼叫端傳入 ``driver=``。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + get_volume, set_volume, change_volume, is_muted, mute, unmute, + toggle_mute, + ) + + get_volume() # 例如 65 ——目前主音量百分比 + set_volume(30) # 設為 30 %,回傳 30 + change_volume(-10) # 降低 10 %,回傳套用後的百分比 + is_muted() # False + mute() # True ——使輸出靜音 + unmute() # False ——還原 + toggle_mute() # 切換並回傳新狀態 + +測試時(或任何非 Windows 主機)可傳入 ``driver`` ——任何以 ``0.0..1.0`` 純量提供 +``get_scalar`` / ``set_scalar`` / ``get_mute`` / ``set_mute`` 的物件: + +.. code-block:: python + + class FakeVolume: + def __init__(self, scalar=0.5, muted=False): + self.scalar, self.muted = scalar, muted + def get_scalar(self): return self.scalar + def set_scalar(self, s): self.scalar = s + def get_mute(self): return self.muted + def set_mute(self, m): self.muted = m + + drv = FakeVolume() + set_volume(73, driver=drv) # 73,drv.scalar == 0.73 + +執行器指令 +---------- + +``AC_get_volume``(→ ``{volume, muted}``)、``AC_set_volume``(``level`` → +``{volume}``)、``AC_change_volume``(``delta`` → ``{volume}``)、``AC_set_mute`` +(``muted`` → ``{muted}``)與 ``AC_toggle_mute``(→ ``{muted}``)。皆以對應的 ``ac_*`` +MCP 工具(讀取為唯讀、寫入為僅副作用)及 Script Builder 指令(位於 **Shell** 分類下)形式提供。 +執行器與 MCP 層使用預設 OS driver,故在 Windows 上需要 ``pycaw``。 diff --git a/docs/source/Zh/doc/new_features/v207_features_doc.rst b/docs/source/Zh/doc/new_features/v207_features_doc.rst new file mode 100644 index 00000000..768b7213 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v207_features_doc.rst @@ -0,0 +1,55 @@ +鎖定工作站 + 等待解鎖 +==================== + +:mod:`session_guard` 回答「目前 session 是否鎖定?」並在鎖定時丟出例外。缺少的另一半是對鎖定狀態 +*採取行動*:在無人值守執行結束時鎖定機器、在恢復前阻塞直到有人解鎖,或把一連串鎖定狀態取樣化約為 +鎖定 / 解鎖事件。``lock_session`` 補上這些,並以可注入接縫實作,故邏輯能在不碰作業系統的情況下測試。 + +* :func:`lock_session` ——立即鎖定工作站(Windows 用 ``LockWorkStation``、Linux 用 + ``loginctl lock-session``、macOS 用 ``CGSession -suspend``),透過可注入的 ``driver``。 +* :func:`plan_lock_session` ——純 planner:此 OS 上會如何執行鎖定,以及是否有預設可用 + (``{backend, argv, available}``)。 +* :func:`wait_for_unlock` / :func:`wait_for_lock` ——輪詢 :func:`is_session_locked` + 直到狀態翻轉或逾時,``clock`` / ``sleep`` / ``probe`` 皆可注入以利確定性測試。 +* :func:`classify_lock_transitions` ——純函式:把一連串鎖定狀態取樣化約為 + ``{event, locked}`` 鎖定 / 解鎖轉變的清單。 + +wait 系列重用的鎖定 probe 即 :mod:`session_guard` 的——Windows 的 ``OpenInputDesktop`` 檢查—— +故 ``wait_for_unlock`` 是 ``ensure_interactive_session``(只會丟例外)的阻塞式搭檔。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + lock_session, wait_for_unlock, classify_lock_transitions, + ) + + # ... 無人值守執行結束 ... + lock_session() # 鎖住機器 + + # 等有人解鎖後才繼續 + if wait_for_unlock(timeout_s=600): + run_next_stage() + + # 把取樣的鎖定狀態紀錄化約為事件 + classify_lock_transitions([False, True, True, False]) + # -> [{'event': 'lock', 'locked': True}, + # {'event': 'unlock', 'locked': False}] + +測試時(或任何主機)可傳入 ``driver`` / ``probe``: + +.. code-block:: python + + locked = lock_session(driver=lambda: True) # 不真正鎖定 + wait_for_unlock(probe=lambda: False) # 已解鎖 + +執行器指令 +---------- + +``AC_lock_session``(→ ``{locked}``)、``AC_plan_lock_session``(→ 計畫)、 +``AC_wait_for_unlock``(``timeout`` / ``interval`` → ``{unlocked}``)與 +``AC_classify_lock_transitions``(``states`` JSON 清單 → ``{events}``)。皆以對應的 ``ac_*`` +MCP 工具(``ac_lock_session`` 為破壞性——會中斷 session;其餘為唯讀)及 Script Builder 指令 +(位於 **Shell** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v208_features_doc.rst b/docs/source/Zh/doc/new_features/v208_features_doc.rst new file mode 100644 index 00000000..4ffafc32 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v208_features_doc.rst @@ -0,0 +1,51 @@ +即時 IME 狀態以利安全的 CJK 輸入 +================================ + +在 IME(輸入法)*組字中*對 CJK / 日文 / 韓文欄位輸入並不安全:候選字尚未送出,故讀回欄位會得到 +半成形的字,而下一個按鍵會編輯組字而非欄位。``text_unicode``(``VK_PACKET``)對此一無所知。 +``ime_state`` 暴露即時的組字與轉換狀態,讓流程能在讀取或操作前等待 IME 送出。 + +* :func:`ime_state` ——聚焦視窗 IME 的 ``{open, composing, composition, conversion, + conversion_flags}``,透過可注入的 ``reader``。 +* :func:`is_composing` ——當 IME 有尚未送出的組字時回傳 ``True``。 +* :func:`wait_for_composition_commit` ——阻塞直到組字結束(或逾時),``clock`` / ``sleep`` / + ``reader`` 皆可注入。 +* :func:`decode_conversion_mode` ——純函式:把 IMM32 ``IME_CMODE_*`` 轉換位元遮罩解碼為 + ``{native, katakana, full_shape, roman, char_code}``。 + +預設 ``reader`` 以唯讀方式查詢 Windows IMM32(``ImmGetContext`` / ``ImmGetOpenStatus`` / +``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``);所有解碼 / 等待邏輯都透過可注入接縫 +執行,故能在沒有 IME 的情況下完整測試。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + ime_state, is_composing, wait_for_composition_commit, + ) + + # 讀取 CJK 欄位前,先確認 IME 已送出 + if wait_for_composition_commit(timeout_s=3): + value = read_field() + + is_composing() # 候選字仍在畫面上時為 True + ime_state() # {'open': True, 'composing': True, 'composition': 'あ', ...} + +測試時(或任何非 Windows 主機)可傳入 ``reader`` ——一個 +``() -> {open, conversion, composition}``: + +.. code-block:: python + + busy = lambda: {"open": True, "conversion": 0, "composition": "あ"} + is_composing(reader=busy) # True + ime_state(reader=busy)["composition"] # 'あ' + +執行器指令 +---------- + +``AC_ime_state``(→ 完整狀態)、``AC_is_composing``(→ ``{composing}``)、 +``AC_wait_for_composition_commit``(``timeout`` / ``interval`` → ``{committed}``) +與 ``AC_decode_conversion_mode``(``flags`` → 解碼後的模式)。皆以對應的唯讀 ``ac_*`` MCP 工具 +及 Script Builder 指令(位於 **Shell** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index c360611f..5934b1b7 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -100,6 +100,21 @@ ) # Resolve which application is registered to open a given file type from je_auto_control.utils.file_assoc import file_association, normalize_ext +# Read and control the system master volume and mute state +from je_auto_control.utils.system_volume import ( + change_volume, get_volume, is_muted, mute, set_mute, set_volume, + toggle_mute, unmute, +) +# Lock the workstation, wait for unlock, classify lock transitions +from je_auto_control.utils.lock_session import ( + classify_lock_transitions, lock_session, plan_lock_session, + wait_for_lock, wait_for_unlock, +) +# Read the live IME composition / conversion state for safe CJK entry +from je_auto_control.utils.ime_state import ( + decode_conversion_mode, ime_state, is_composing, + wait_for_composition_commit, +) # Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set from je_auto_control.utils.clipboard_rich_formats import ( build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, @@ -1713,6 +1728,12 @@ def start_autocontrol_gui(*args, **kwargs): "idle_seconds", "is_idle", "plan_keep_awake", "keep_awake", "keep_awake_on", "allow_sleep", "normalize_ext", "file_association", + "get_volume", "set_volume", "change_volume", + "is_muted", "set_mute", "mute", "unmute", "toggle_mute", + "lock_session", "plan_lock_session", "wait_for_unlock", + "wait_for_lock", "classify_lock_transitions", + "ime_state", "is_composing", "wait_for_composition_commit", + "decode_conversion_mode", "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", diff --git a/je_auto_control/gui/flow_editor/__init__.py b/je_auto_control/gui/flow_editor/__init__.py index 46560537..3a8fce49 100644 --- a/je_auto_control/gui/flow_editor/__init__.py +++ b/je_auto_control/gui/flow_editor/__init__.py @@ -17,11 +17,13 @@ FlowEdge, FlowLayout, FlowNodePosition, layout_steps, ) +_SCENE_MODULE = "je_auto_control.gui.flow_editor.scene" +_TAB_MODULE = "je_auto_control.gui.flow_editor.tab" _LAZY_SUBMODULES = { - "FlowEdgeItem": "je_auto_control.gui.flow_editor.scene", - "FlowGraphScene": "je_auto_control.gui.flow_editor.scene", - "FlowNodeItem": "je_auto_control.gui.flow_editor.scene", - "FlowEditorTab": "je_auto_control.gui.flow_editor.tab", + "FlowEdgeItem": _SCENE_MODULE, + "FlowGraphScene": _SCENE_MODULE, + "FlowNodeItem": _SCENE_MODULE, + "FlowEditorTab": _TAB_MODULE, } diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index ba1809b2..a3fefd42 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -45,6 +45,16 @@ class CommandSpec: _MOUSE_BUTTONS = ("mouse_left", "mouse_right", "mouse_middle") _REGION_PLACEHOLDER = "[left, top, right, bottom]" +_NATIVE_UI = "Native UI" +_SCALES_PLACEHOLDER = "[0.9, 1.0, 1.1]" +_POINT_PLACEHOLDER = "[10, 20]" +_RECT_PLACEHOLDER = "[x, y, width, height]" +_RECT4_PLACEHOLDER = "[x, y, w, h]" +_APPROVALS_DIR = ".approvals" +_DOTTED_KEY_PLACEHOLDER = "db.host" +_POINTS_JSON_PLACEHOLDER = '[{"x":..,"y":..,"width":..,"height":..}]' +_MARKS_JSON_PLACEHOLDER = '[{"role":"button","name":"OK","x":..,"y":..}]' +_BOXES_JSON_PLACEHOLDER = '[{"role":"button","x":0,"y":0}]' def _build_specs() -> List[CommandSpec]: @@ -261,7 +271,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: FieldSpec("min_score", FieldType.FLOAT, optional=True, default=0.8, min_value=0.0, max_value=1.0), FieldSpec("scales", FieldType.STRING, optional=True, - placeholder="[0.9, 1.0, 1.1]"), + placeholder=_SCALES_PLACEHOLDER), FieldSpec("region", FieldType.STRING, optional=True, placeholder=_REGION_PLACEHOLDER), ), @@ -313,7 +323,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: FieldSpec("angles", FieldType.STRING, optional=True, placeholder="[-10, 0, 10]"), FieldSpec("scales", FieldType.STRING, optional=True, - placeholder="[0.9, 1.0, 1.1]"), + placeholder=_SCALES_PLACEHOLDER), FieldSpec("region", FieldType.STRING, optional=True, placeholder=_REGION_PLACEHOLDER), ), @@ -328,7 +338,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: FieldSpec("angles", FieldType.STRING, optional=True, placeholder="[-10, 0, 10]"), FieldSpec("scales", FieldType.STRING, optional=True, - placeholder="[0.9, 1.0, 1.1]"), + placeholder=_SCALES_PLACEHOLDER), FieldSpec("max_results", FieldType.INT, optional=True, default=20), FieldSpec("nms_iou", FieldType.FLOAT, optional=True, default=0.3, min_value=0.0, max_value=1.0), @@ -344,7 +354,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: FieldSpec("ambiguous_ratio", FieldType.FLOAT, optional=True, default=0.9, min_value=0.0, max_value=1.0), FieldSpec("scales", FieldType.STRING, optional=True, - placeholder="[0.9, 1.0, 1.1]"), + placeholder=_SCALES_PLACEHOLDER), FieldSpec("region", FieldType.STRING, optional=True, placeholder=_REGION_PLACEHOLDER), ), @@ -378,7 +388,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: FieldSpec("min_score", FieldType.FLOAT, optional=True, default=0.7, min_value=0.0, max_value=1.0), FieldSpec("scales", FieldType.STRING, optional=True, - placeholder="[0.9, 1.0, 1.1]"), + placeholder=_SCALES_PLACEHOLDER), FieldSpec("region", FieldType.STRING, optional=True, placeholder=_REGION_PLACEHOLDER), ), @@ -684,7 +694,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: "AC_fuse_elements", "Image", "Fuse Element Boxes", fields=( FieldSpec("ocr", FieldType.STRING, optional=True, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("icon", FieldType.STRING, optional=True), FieldSpec("a11y", FieldType.STRING, optional=True), FieldSpec("iou_threshold", FieldType.FLOAT, optional=True, default=0.9, @@ -696,7 +706,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: "AC_reading_order", "Image", "Reading Order", fields=( FieldSpec("elements", FieldType.STRING, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("row_tol", FieldType.INT, optional=True, default=12), ), description="Order element boxes top-to-bottom, left-to-right (+ index).", @@ -705,7 +715,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: "AC_locate_chain", "Image", "Locate Chain (refine boxes)", fields=( FieldSpec("boxes", FieldType.STRING, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("ops", FieldType.STRING, placeholder='[{"op":"filter","has_text":"OK"},{"op":"first"}]'), ), @@ -1062,7 +1072,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: FieldSpec("paths", FieldType.STRING, placeholder='["C:\\\\a\\\\one.txt"]'), FieldSpec("point", FieldType.STRING, optional=True, - placeholder="[10, 20]"), + placeholder=_POINT_PLACEHOLDER), ), description="Drop files onto a window via WM_DROPFILES (Windows).", )) @@ -1072,7 +1082,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: FieldSpec("paths", FieldType.STRING, placeholder='["C:\\\\a\\\\one.txt"]'), FieldSpec("point", FieldType.STRING, optional=True, - placeholder="[10, 20]"), + placeholder=_POINT_PLACEHOLDER), ), description="Build the WM_DROPFILES payload without sending (pure).", )) @@ -1220,7 +1230,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: "bottom_right", "center", "left_third", "center_third", "right_third"), default="left"), FieldSpec("screen", FieldType.STRING, optional=True, - placeholder="[x, y, width, height]"), + placeholder=_RECT_PLACEHOLDER), FieldSpec("gap", FieldType.INT, optional=True, default=0), ), description="Compute the rectangle for a tiling slot of the screen.", @@ -1231,7 +1241,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: FieldSpec("rows", FieldType.INT, default=2), FieldSpec("cols", FieldType.INT, default=2), FieldSpec("screen", FieldType.STRING, optional=True, - placeholder="[x, y, width, height]"), + placeholder=_RECT_PLACEHOLDER), FieldSpec("gap", FieldType.INT, optional=True, default=0), ), description="Compute the cell rectangles of an R×C screen grid.", @@ -1241,7 +1251,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: fields=( FieldSpec("count", FieldType.INT, default=3), FieldSpec("screen", FieldType.STRING, optional=True, - placeholder="[x, y, width, height]"), + placeholder=_RECT_PLACEHOLDER), FieldSpec("offset", FieldType.INT, optional=True, default=30), FieldSpec("size", FieldType.STRING, optional=True, placeholder="[width, height]"), @@ -1549,67 +1559,67 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None: FieldSpec("automation_id", FieldType.STRING, optional=True), ) specs.append(CommandSpec( - "AC_control_get_value", "Native UI", "Get Control Value", + "AC_control_get_value", _NATIVE_UI, "Get Control Value", fields=fields, description="Read a native control's value via the accessibility API.", )) specs.append(CommandSpec( - "AC_control_set_value", "Native UI", "Set Control Value", + "AC_control_set_value", _NATIVE_UI, "Set Control Value", fields=(FieldSpec("value", FieldType.STRING),) + fields, description="Set a native control's value directly (no per-key typing).", )) specs.append(CommandSpec( - "AC_control_invoke", "Native UI", "Invoke Control", + "AC_control_invoke", _NATIVE_UI, "Invoke Control", fields=fields, description="Invoke a native control (e.g. press a button).", )) specs.append(CommandSpec( - "AC_control_toggle", "Native UI", "Toggle Control", + "AC_control_toggle", _NATIVE_UI, "Toggle Control", fields=fields, description="Toggle a native control (e.g. a checkbox).", )) specs.append(CommandSpec( - "AC_read_table", "Native UI", "Read Table / Grid", + "AC_read_table", _NATIVE_UI, "Read Table / Grid", fields=fields, description="Read a grid/table/list control as rows of cell strings.", )) specs.append(CommandSpec( - "AC_expand_control", "Native UI", "Expand Control", + "AC_expand_control", _NATIVE_UI, "Expand Control", fields=fields, description="Expand a tree node / combobox (ExpandCollapsePattern).", )) specs.append(CommandSpec( - "AC_collapse_control", "Native UI", "Collapse Control", + "AC_collapse_control", _NATIVE_UI, "Collapse Control", fields=fields, description="Collapse a tree node / combobox (ExpandCollapsePattern).", )) specs.append(CommandSpec( - "AC_control_expand_state", "Native UI", "Control Expand State", + "AC_control_expand_state", _NATIVE_UI, "Control Expand State", fields=fields, description="Read expanded/collapsed/partial/leaf state of a control.", )) specs.append(CommandSpec( - "AC_select_control_item", "Native UI", "Select Control Item", + "AC_select_control_item", _NATIVE_UI, "Select Control Item", fields=fields, description="Select a list / tree / tab item (SelectionItemPattern).", )) specs.append(CommandSpec( - "AC_control_range", "Native UI", "Get Control Range", + "AC_control_range", _NATIVE_UI, "Get Control Range", fields=fields, description="Read a slider / progress range (RangeValuePattern).", )) specs.append(CommandSpec( - "AC_set_control_range", "Native UI", "Set Control Range", + "AC_set_control_range", _NATIVE_UI, "Set Control Range", fields=(FieldSpec("value", FieldType.FLOAT),) + fields, description="Set a slider / progress / spinner value (RangeValuePattern).", )) specs.append(CommandSpec( - "AC_scroll_control_into_view", "Native UI", "Scroll Control Into View", + "AC_scroll_control_into_view", _NATIVE_UI, "Scroll Control Into View", fields=fields, description="Scroll a control into view (ScrollItemPattern).", )) specs.append(CommandSpec( - "AC_realize_item", "Native UI", "Realize Virtualized Item", + "AC_realize_item", _NATIVE_UI, "Realize Virtualized Item", fields=( FieldSpec("item_name", FieldType.STRING), FieldSpec("by", FieldType.ENUM, optional=True, default="name", @@ -1622,124 +1632,124 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None: description="Realize an off-screen item in a virtualized list/grid.", )) specs.append(CommandSpec( - "AC_get_element_properties", "Native UI", "Get Element Properties", + "AC_get_element_properties", _NATIVE_UI, "Get Element Properties", fields=fields, description="Read rich UIA props (enabled/offscreen/help/status/keys).", )) specs.append(CommandSpec( - "AC_table_headers", "Native UI", "Get Table Headers", + "AC_table_headers", _NATIVE_UI, "Get Table Headers", fields=fields, description="Read a table's row/column header labels (TablePattern).", )) specs.append(CommandSpec( - "AC_table_cell", "Native UI", "Get Table Cell (by index)", + "AC_table_cell", _NATIVE_UI, "Get Table Cell (by index)", fields=(FieldSpec("row", FieldType.INT), FieldSpec("column", FieldType.INT)) + fields, description="Read the cell at (row, column) with its span.", )) specs.append(CommandSpec( - "AC_cell_by_header", "Native UI", "Get Table Cell (by header)", + "AC_cell_by_header", _NATIVE_UI, "Get Table Cell (by header)", fields=(FieldSpec("row", FieldType.INT), FieldSpec("column_header", FieldType.STRING)) + fields, description="Read the cell at (row, named column) — assert by header.", )) specs.append(CommandSpec( - "AC_move_element", "Native UI", "Move Element (Transform)", + "AC_move_element", _NATIVE_UI, "Move Element (Transform)", fields=(FieldSpec("x", FieldType.FLOAT), FieldSpec("y", FieldType.FLOAT)) + fields, description="Move a UIA element to (x, y) (TransformPattern).", )) specs.append(CommandSpec( - "AC_resize_element", "Native UI", "Resize Element (Transform)", + "AC_resize_element", _NATIVE_UI, "Resize Element (Transform)", fields=(FieldSpec("width", FieldType.FLOAT), FieldSpec("height", FieldType.FLOAT)) + fields, description="Resize a UIA element (TransformPattern).", )) specs.append(CommandSpec( - "AC_set_window_state", "Native UI", "Set Window State", + "AC_set_window_state", _NATIVE_UI, "Set Window State", fields=(FieldSpec("state", FieldType.ENUM, default="normal", choices=("normal", "maximized", "minimized")),) + fields, description="Minimize / maximize / restore a window (WindowPattern).", )) specs.append(CommandSpec( - "AC_window_interaction_state", "Native UI", "Window Interaction State", + "AC_window_interaction_state", _NATIVE_UI, "Window Interaction State", fields=fields, description="Read window readiness (ready/blocked_by_modal/...).", )) specs.append(CommandSpec( - "AC_legacy_info", "Native UI", "Legacy (MSAA) Info", + "AC_legacy_info", _NATIVE_UI, "Legacy (MSAA) Info", fields=fields, description="Read an old control's MSAA info (LegacyIAccessible).", )) specs.append(CommandSpec( - "AC_legacy_default_action", "Native UI", "Legacy (MSAA) Default Action", + "AC_legacy_default_action", _NATIVE_UI, "Legacy (MSAA) Default Action", fields=fields, description="Fire an old control's MSAA default action (fallback).", )) specs.append(CommandSpec( - "AC_get_selection", "Native UI", "Get Container Selection", + "AC_get_selection", _NATIVE_UI, "Get Container Selection", fields=fields, description="Read a container's selection (SelectionPattern).", )) specs.append(CommandSpec( - "AC_list_views", "Native UI", "List Control Views", + "AC_list_views", _NATIVE_UI, "List Control Views", fields=fields, description="List a control's selectable views (MultipleViewPattern).", )) specs.append(CommandSpec( - "AC_set_view", "Native UI", "Set Control View", + "AC_set_view", _NATIVE_UI, "Set Control View", fields=(FieldSpec("view", FieldType.STRING),) + fields, description="Switch a control to the named view (MultipleViewPattern).", )) specs.append(CommandSpec( - "AC_wait_for_focus_change", "Native UI", "Wait for Focus Change", + "AC_wait_for_focus_change", _NATIVE_UI, "Wait for Focus Change", fields=(FieldSpec("timeout", FieldType.FLOAT, optional=True, default=5.0),), description="Block until keyboard focus moves (real UIA focus event).", )) specs.append(CommandSpec( - "AC_get_control_text", "Native UI", "Get Control Text", + "AC_get_control_text", _NATIVE_UI, "Get Control Text", fields=fields, description="Read full text via TextPattern (multiline / document safe).", )) specs.append(CommandSpec( - "AC_find_control_text", "Native UI", "Find Text in Control", + "AC_find_control_text", _NATIVE_UI, "Find Text in Control", fields=(FieldSpec("text", FieldType.STRING), FieldSpec("ignore_case", FieldType.BOOL, optional=True, default=True)) + fields, description="Whether text occurs in a control (TextPattern.FindText).", )) specs.append(CommandSpec( - "AC_select_control_text", "Native UI", "Select Text in Control", + "AC_select_control_text", _NATIVE_UI, "Select Text in Control", fields=(FieldSpec("text", FieldType.STRING), FieldSpec("ignore_case", FieldType.BOOL, optional=True, default=True)) + fields, description="Find + select text in a control (FindText + Select).", )) specs.append(CommandSpec( - "AC_control_text_attributes", "Native UI", "Get Text Attributes", + "AC_control_text_attributes", _NATIVE_UI, "Get Text Attributes", fields=fields, description="Read selection formatting (font/size/bold/italic/colour).", )) specs.append(CommandSpec( - "AC_get_selected_text", "Native UI", "Get Selected Text", + "AC_get_selected_text", _NATIVE_UI, "Get Selected Text", fields=fields, description="Read the currently selected text via TextPattern.", )) specs.append(CommandSpec( - "AC_get_visible_text", "Native UI", "Get Visible Text", + "AC_get_visible_text", _NATIVE_UI, "Get Visible Text", fields=fields, description="Read only the on-screen text via TextPattern.GetVisibleRanges.", )) specs.append(CommandSpec( - "AC_walk_tree", "Native UI", "Walk Accessibility Tree", + "AC_walk_tree", _NATIVE_UI, "Walk Accessibility Tree", fields=(FieldSpec("app_name", FieldType.STRING, optional=True), FieldSpec("max_results", FieldType.INT, optional=True, default=500)), description="Dump the a11y tree with friendly roles + a path per node.", )) specs.append(CommandSpec( - "AC_humanize_role", "Native UI", "Humanize UIA Role", + "AC_humanize_role", _NATIVE_UI, "Humanize UIA Role", fields=(FieldSpec("role", FieldType.STRING),), description="Translate a raw UIA role (ControlType_50000) to a name.", )) @@ -1747,17 +1757,17 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None: FieldSpec("max_results", FieldType.INT, optional=True, default=500)) specs.append(CommandSpec( - "AC_tab_order", "Native UI", "Keyboard Tab Order", + "AC_tab_order", _NATIVE_UI, "Keyboard Tab Order", fields=tree_fields, description="List focusable controls in keyboard Tab (reading) order.", )) specs.append(CommandSpec( - "AC_audit_focus_order", "Native UI", "Audit Focus Order (WCAG)", + "AC_audit_focus_order", _NATIVE_UI, "Audit Focus Order (WCAG)", fields=tree_fields, description="WCAG 2.4.x focus-order audit: tab sequence + flagged issues.", )) specs.append(CommandSpec( - "AC_focus_control", "Native UI", "Set Keyboard Focus", + "AC_focus_control", _NATIVE_UI, "Set Keyboard Focus", fields=fields, description="Set keyboard focus on a control natively (UIA SetFocus).", )) @@ -1862,7 +1872,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: description="Generate a TOTP 2FA code from a base32 secret.", )) specs.append(CommandSpec( - "AC_handle_file_dialog", "Native UI", "Handle File Dialog", + "AC_handle_file_dialog", _NATIVE_UI, "Handle File Dialog", fields=( FieldSpec("path", FieldType.STRING), FieldSpec("action", FieldType.ENUM, @@ -2055,7 +2065,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: FieldSpec("name", FieldType.STRING, placeholder="login_screen"), FieldSpec("content", FieldType.STRING), FieldSpec("approvals_dir", FieldType.STRING, optional=True, - default=".approvals"), + default=_APPROVALS_DIR), FieldSpec("extension", FieldType.STRING, optional=True, default="txt"), ), @@ -2066,7 +2076,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: fields=( FieldSpec("name", FieldType.STRING), FieldSpec("approvals_dir", FieldType.STRING, optional=True, - default=".approvals"), + default=_APPROVALS_DIR), FieldSpec("extension", FieldType.STRING, optional=True, default="txt"), ), @@ -2075,7 +2085,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: specs.append(CommandSpec( "AC_pending_artifacts", "Testing", "Approval: List Pending", fields=(FieldSpec("approvals_dir", FieldType.STRING, optional=True, - default=".approvals"),), + default=_APPROVALS_DIR),), description="List artifacts awaiting approval.", )) specs.append(CommandSpec( @@ -2730,7 +2740,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: FieldSpec("key", FieldType.STRING), FieldSpec("method", FieldType.STRING, placeholder="vlm/image"), FieldSpec("coordinates", FieldType.STRING, optional=True, - placeholder="[10, 20]"), + placeholder=_POINT_PLACEHOLDER), FieldSpec("description", FieldType.STRING, optional=True), FieldSpec("confidence", FieldType.FLOAT, optional=True, default=1.0), @@ -3247,7 +3257,7 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: fields=( FieldSpec("layers", FieldType.STRING, placeholder='[{"name": "defaults", "mapping": {}}]'), - FieldSpec("key", FieldType.STRING, placeholder="db.host"), + FieldSpec("key", FieldType.STRING, placeholder=_DOTTED_KEY_PLACEHOLDER), ), description="Show the value and winning layer for a dotted config key.", )) @@ -3375,7 +3385,7 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: "AC_cas_put", "Flow", "Optimistic: Put (CAS)", fields=( FieldSpec("name", FieldType.STRING, placeholder="config"), - FieldSpec("key", FieldType.STRING, placeholder="db.host"), + FieldSpec("key", FieldType.STRING, placeholder=_DOTTED_KEY_PLACEHOLDER), FieldSpec("value", FieldType.STRING, placeholder='"prod-1"'), FieldSpec("expected_version", FieldType.INT, optional=True), ), @@ -3385,7 +3395,7 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: "AC_cas_get", "Flow", "Optimistic: Get", fields=( FieldSpec("name", FieldType.STRING, placeholder="config"), - FieldSpec("key", FieldType.STRING, placeholder="db.host"), + FieldSpec("key", FieldType.STRING, placeholder=_DOTTED_KEY_PLACEHOLDER), ), description="Read a versioned record {value, version}.", )) @@ -3621,21 +3631,21 @@ def _add_input_macro_specs(specs: List[CommandSpec]) -> None: def _add_screen_state_specs(specs: List[CommandSpec]) -> None: app = FieldSpec("app_name", FieldType.STRING, optional=True) specs.append(CommandSpec( - "AC_screen_snapshot", "Native UI", "Screen: Snapshot Baseline", + "AC_screen_snapshot", _NATIVE_UI, "Screen: Snapshot Baseline", fields=(app,), description="Snapshot the a11y tree as a semantic-diff baseline.", )) specs.append(CommandSpec( - "AC_screen_diff", "Native UI", "Screen: Diff Snapshots", + "AC_screen_diff", _NATIVE_UI, "Screen: Diff Snapshots", description="Semantic diff of 'before'/'after' snapshots (JSON view).", )) specs.append(CommandSpec( - "AC_screen_changed", "Native UI", "Screen: What Changed", + "AC_screen_changed", _NATIVE_UI, "Screen: What Changed", fields=(app,), description="Diff the live screen against the last snapshot baseline.", )) specs.append(CommandSpec( - "AC_describe_screen", "Native UI", "Screen: Describe", + "AC_describe_screen", _NATIVE_UI, "Screen: Describe", fields=(app,), description="Structured 'where am I' (role counts + control labels).", )) @@ -3643,7 +3653,7 @@ def _add_screen_state_specs(specs: List[CommandSpec]) -> None: def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: specs.append(CommandSpec( - "AC_cua_command", "Native UI", "Computer-Use: Map Action", + "AC_cua_command", _NATIVE_UI, "Computer-Use: Map Action", fields=( FieldSpec("payload", FieldType.STRING, placeholder='{"action":"left_click","coordinate":[x,y]}'), @@ -3653,43 +3663,43 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Map an Anthropic / OpenAI computer-use action to an AC command.", )) specs.append(CommandSpec( - "AC_serialize_observation", "Native UI", "Observation: Serialize Elements", + "AC_serialize_observation", _NATIVE_UI, "Observation: Serialize Elements", fields=( FieldSpec("elements", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("viewport", FieldType.STRING, optional=True, - placeholder="[x, y, w, h]"), + placeholder=_RECT4_PLACEHOLDER), FieldSpec("max_elements", FieldType.INT, optional=True, default=80), ), description="Indexed text observation of UI elements for a VLM (act by index).", )) specs.append(CommandSpec( - "AC_observation_index", "Native UI", "Observation: Index Elements", + "AC_observation_index", _NATIVE_UI, "Observation: Index Elements", fields=( FieldSpec("elements", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("viewport", FieldType.STRING, optional=True, - placeholder="[x, y, w, h]"), + placeholder=_RECT4_PLACEHOLDER), FieldSpec("max_elements", FieldType.INT, optional=True, default=80), ), description="Reading-ordered, viewport-clipped, indexed element list.", )) specs.append(CommandSpec( - "AC_delta_observation", "Native UI", "Observation: Delta (what changed)", + "AC_delta_observation", _NATIVE_UI, "Observation: Delta (what changed)", fields=( FieldSpec("prev", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("curr", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("viewport", FieldType.STRING, optional=True, - placeholder="[x, y, w, h]"), + placeholder=_RECT4_PLACEHOLDER), FieldSpec("max_elements", FieldType.INT, optional=True, default=80), FieldSpec("max_lines", FieldType.INT, optional=True, default=40), ), description="Token-budgeted '+/~/-' summary of what changed between frames.", )) specs.append(CommandSpec( - "AC_classify_effect", "Native UI", "Classify Action Effect", + "AC_classify_effect", _NATIVE_UI, "Classify Action Effect", fields=( FieldSpec("before", FieldType.STRING, placeholder='[{"role":"button","name":"OK","x":0,"y":0}]'), @@ -3702,19 +3712,19 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Did the action change the screen near its target? (no_op/…).", )) specs.append(CommandSpec( - "AC_effect_near_point", "Native UI", "Effect Near Point?", + "AC_effect_near_point", _NATIVE_UI, "Effect Near Point?", fields=( FieldSpec("before", FieldType.STRING, - placeholder='[{"role":"button","x":0,"y":0}]'), + placeholder=_BOXES_JSON_PLACEHOLDER), FieldSpec("after", FieldType.STRING, - placeholder='[{"role":"button","x":0,"y":0}]'), + placeholder=_BOXES_JSON_PLACEHOLDER), FieldSpec("point", FieldType.STRING, placeholder="[50, 50]"), FieldSpec("radius", FieldType.INT, optional=True, default=64), ), description="Did any before/after change land within radius of a point?", )) specs.append(CommandSpec( - "AC_check_postcondition", "Native UI", "Check Postcondition", + "AC_check_postcondition", _NATIVE_UI, "Check Postcondition", fields=( FieldSpec("after", FieldType.STRING, placeholder='[{"role":"dialog","name":"Saved"}]'), @@ -3727,7 +3737,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Check expected outcome clauses against after/before frames.", )) specs.append(CommandSpec( - "AC_plan_repair", "Native UI", "Plan Repair Tactics", + "AC_plan_repair", _NATIVE_UI, "Plan Repair Tactics", fields=( FieldSpec("verdict", FieldType.STRING, placeholder="no_op / changed_elsewhere / changed"), @@ -3736,7 +3746,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Ordered repair tactics for a failed/no-effect action verdict.", )) specs.append(CommandSpec( - "AC_consensus_point", "Native UI", "Grounding Consensus Point", + "AC_consensus_point", _NATIVE_UI, "Grounding Consensus Point", fields=( FieldSpec("candidates", FieldType.STRING, placeholder="[[100, 100], [104, 98], [97, 103]]"), @@ -3755,12 +3765,12 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Index where a churn series first settles (offline settle check).", )) specs.append(CommandSpec( - "AC_build_critic_record", "Native UI", "Build Critic Record", + "AC_build_critic_record", _NATIVE_UI, "Build Critic Record", fields=( FieldSpec("action", FieldType.STRING, placeholder='{"type":"click","x":50,"y":50}'), FieldSpec("before", FieldType.STRING, - placeholder='[{"role":"button","x":0,"y":0}]'), + placeholder=_BOXES_JSON_PLACEHOLDER), FieldSpec("after", FieldType.STRING, placeholder='[{"role":"dialog","x":40,"y":40}]'), FieldSpec("postcondition", FieldType.STRING, optional=True, @@ -3770,7 +3780,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Per-step critic evidence (effect + delta + postcondition).", )) specs.append(CommandSpec( - "AC_score_step", "Native UI", "Score Step (rule-based)", + "AC_score_step", _NATIVE_UI, "Score Step (rule-based)", fields=( FieldSpec("record", FieldType.STRING, placeholder='{"effect":{"effect":"changed_near_target"}}'), @@ -3778,45 +3788,45 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Rule-based outcome + process score of a critic record.", )) specs.append(CommandSpec( - "AC_consensus_element", "Native UI", "Grounding Consensus Element", + "AC_consensus_element", _NATIVE_UI, "Grounding Consensus Element", fields=( FieldSpec("candidates", FieldType.STRING, placeholder="[[8, 8], [12, 10]]"), FieldSpec("elements", FieldType.STRING, - placeholder='[{"role":"button","x":0,"y":0}]'), + placeholder=_BOXES_JSON_PLACEHOLDER), ), description="Vote grounding proposals to the nearest element.", )) specs.append(CommandSpec( - "AC_validate_action", "Native UI", "Validate / Snap Action", + "AC_validate_action", _NATIVE_UI, "Validate / Snap Action", fields=( FieldSpec("action", FieldType.STRING, placeholder='{"type":"click","x":..,"y":..}'), FieldSpec("screen", FieldType.STRING, optional=True, placeholder="[width, height]"), FieldSpec("targets", FieldType.STRING, optional=True, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), ), description="Reject out-of-bounds clicks; snap a near-miss to the nearest " "element.", )) specs.append(CommandSpec( - "AC_match_elements", "Native UI", "Match Elements (frames)", + "AC_match_elements", _NATIVE_UI, "Match Elements (frames)", fields=( FieldSpec("before", FieldType.STRING, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("after", FieldType.STRING, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("iou_threshold", FieldType.FLOAT, optional=True, default=0.5, min_value=0.0, max_value=1.0), ), description="Match element boxes across two frames by overlap (move/rename).", )) specs.append(CommandSpec( - "AC_assign_stable_ids", "Native UI", "Assign Stable Element IDs", + "AC_assign_stable_ids", _NATIVE_UI, "Assign Stable Element IDs", fields=( FieldSpec("elements", FieldType.STRING, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("prior", FieldType.STRING, optional=True, placeholder="prior frame's elements (with ids)"), FieldSpec("iou_threshold", FieldType.FLOAT, optional=True, default=0.5, @@ -3825,10 +3835,10 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Tag elements with IDs carried across frames by overlap.", )) specs.append(CommandSpec( - "AC_score_candidates", "Native UI", "Score Candidates", + "AC_score_candidates", _NATIVE_UI, "Score Candidates", fields=( FieldSpec("candidates", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("want_role", FieldType.STRING, optional=True), FieldSpec("want_name", FieldType.STRING, optional=True), FieldSpec("anchor", FieldType.STRING, optional=True, @@ -3837,10 +3847,10 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Rank candidate elements by role / name / proximity confidence.", )) specs.append(CommandSpec( - "AC_best_candidate", "Native UI", "Best Candidate", + "AC_best_candidate", _NATIVE_UI, "Best Candidate", fields=( FieldSpec("candidates", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("want_role", FieldType.STRING, optional=True), FieldSpec("want_name", FieldType.STRING, optional=True), FieldSpec("anchor", FieldType.STRING, optional=True, @@ -3849,7 +3859,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="The single highest-scoring candidate element.", )) specs.append(CommandSpec( - "AC_mark_screen", "Native UI", "Set-of-Marks: Number Elements", + "AC_mark_screen", _NATIVE_UI, "Set-of-Marks: Number Elements", fields=( FieldSpec("app_name", FieldType.STRING, optional=True), FieldSpec("render_path", FieldType.FILE_PATH, optional=True), @@ -3858,7 +3868,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: "grounding; optional numbered-box overlay screenshot.", )) specs.append(CommandSpec( - "AC_mark_click", "Native UI", "Set-of-Marks: Click Number", + "AC_mark_click", _NATIVE_UI, "Set-of-Marks: Click Number", fields=(FieldSpec("mark_id", FieldType.INT),), description="Click the element behind a numbered mark.", )) @@ -4085,7 +4095,7 @@ def _add_authoring_specs(specs: List[CommandSpec]) -> None: path = FieldSpec("path", FieldType.FILE_PATH) key = FieldSpec("key", FieldType.STRING) specs.append(CommandSpec( - "AC_element_save", "Native UI", "Element: Save Locator", + "AC_element_save", _NATIVE_UI, "Element: Save Locator", fields=(path, key, FieldSpec("name", FieldType.STRING, optional=True), FieldSpec("role", FieldType.STRING, optional=True), @@ -4093,22 +4103,22 @@ def _add_authoring_specs(specs: List[CommandSpec]) -> None: description="Save a named native-UI locator (object repository).", )) specs.append(CommandSpec( - "AC_element_find", "Native UI", "Element: Find Saved", + "AC_element_find", _NATIVE_UI, "Element: Find Saved", fields=(path, key), description="Resolve a saved locator to a live element summary.", )) specs.append(CommandSpec( - "AC_element_click", "Native UI", "Element: Click Saved", + "AC_element_click", _NATIVE_UI, "Element: Click Saved", fields=(path, key), description="Click the element behind a saved locator.", )) specs.append(CommandSpec( - "AC_element_remove", "Native UI", "Element: Remove Saved", + "AC_element_remove", _NATIVE_UI, "Element: Remove Saved", fields=(path, key), description="Delete a saved locator.", )) specs.append(CommandSpec( - "AC_element_list", "Native UI", "Element: List Saved", + "AC_element_list", _NATIVE_UI, "Element: List Saved", fields=(path,), description="List saved locator names in a repository file.", )) @@ -4302,6 +4312,95 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: fields=(), description="Release a previously-started keep-awake.", )) + specs.append(CommandSpec( + "AC_get_volume", "Shell", "Get System Volume", + fields=(), + description="Read the master volume percent and mute state.", + )) + specs.append(CommandSpec( + "AC_set_volume", "Shell", "Set System Volume", + fields=( + FieldSpec("level", FieldType.INT, default=50, + placeholder="volume percent 0-100"), + ), + description="Set the master volume to level percent (clamped 0-100).", + )) + specs.append(CommandSpec( + "AC_change_volume", "Shell", "Change System Volume", + fields=( + FieldSpec("delta", FieldType.INT, default=10, + placeholder="percent delta (may be negative)"), + ), + description="Add delta percent to the master volume (clamped 0-100).", + )) + specs.append(CommandSpec( + "AC_set_mute", "Shell", "Set Mute", + fields=( + FieldSpec("muted", FieldType.BOOL, optional=True, default=True), + ), + description="Mute or unmute the master output.", + )) + specs.append(CommandSpec( + "AC_toggle_mute", "Shell", "Toggle Mute", + fields=(), + description="Flip the master mute flag.", + )) + specs.append(CommandSpec( + "AC_lock_session", "Shell", "Lock Workstation", + fields=(), + description="Lock the workstation now (interrupts the session).", + )) + specs.append(CommandSpec( + "AC_plan_lock_session", "Shell", "Plan Lock Workstation", + fields=(), + description="Describe how the workstation would be locked (pure).", + )) + specs.append(CommandSpec( + "AC_wait_for_unlock", "Shell", "Wait for Unlock", + fields=( + FieldSpec("timeout", FieldType.FLOAT, optional=True, default=30.0, + placeholder="timeout seconds"), + FieldSpec("interval", FieldType.FLOAT, optional=True, default=0.5, + placeholder="poll interval seconds"), + ), + description="Block until the session is unlocked or timeout.", + )) + specs.append(CommandSpec( + "AC_classify_lock_transitions", "Shell", "Classify Lock Transitions", + fields=( + FieldSpec("states", FieldType.STRING, + placeholder="JSON list of booleans"), + ), + description="Reduce lock-state samples to lock / unlock events.", + )) + specs.append(CommandSpec( + "AC_ime_state", "Shell", "IME State", + fields=(), + description="Read the focused window's live IME composition state.", + )) + specs.append(CommandSpec( + "AC_is_composing", "Shell", "Is IME Composing", + fields=(), + description="True while the IME has an uncommitted composition.", + )) + specs.append(CommandSpec( + "AC_wait_for_composition_commit", "Shell", "Wait for IME Commit", + fields=( + FieldSpec("timeout", FieldType.FLOAT, optional=True, default=5.0, + placeholder="timeout seconds"), + FieldSpec("interval", FieldType.FLOAT, optional=True, default=0.1, + placeholder="poll interval seconds"), + ), + description="Block until the IME finishes composing or timeout.", + )) + specs.append(CommandSpec( + "AC_decode_conversion_mode", "Shell", "Decode IME Conversion Mode", + fields=( + FieldSpec("flags", FieldType.INT, default=0, + placeholder="IMM32 conversion bitmask"), + ), + description="Decode an IMM32 conversion bitmask into named flags.", + )) specs.append(CommandSpec( "AC_normalize_ext", "Shell", "Normalize Extension", fields=( diff --git a/je_auto_control/utils/accessibility/backends/base.py b/je_auto_control/utils/accessibility/backends/base.py index 4b86ab54..3b1f6637 100644 --- a/je_auto_control/utils/accessibility/backends/base.py +++ b/je_auto_control/utils/accessibility/backends/base.py @@ -29,32 +29,32 @@ def get_value(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Optional[str]: """Return the matched control's value text, or None if not found.""" - self._unsupported("get_value") + self._unsupported("get_value", name, role, app_name, automation_id) def set_value(self, value: str, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Set the matched control's value; return True on success.""" - self._unsupported("set_value") + self._unsupported("set_value", value, name, role, app_name, automation_id) def invoke(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Invoke the matched control (e.g. press a button).""" - self._unsupported("invoke") + self._unsupported("invoke", name, role, app_name, automation_id) def toggle(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Toggle the matched control (e.g. a checkbox).""" - self._unsupported("toggle") + self._unsupported("toggle", name, role, app_name, automation_id) def read_table(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None, ) -> List[List[str]]: """Read a grid/table/list control as rows of cell strings.""" - self._unsupported("read_table") + self._unsupported("read_table", name, role, app_name, automation_id) # --- extended control patterns (Expand / Selection / Range / Scroll) ---- @@ -62,43 +62,43 @@ def expand(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Expand the matched control (ExpandCollapsePattern); True on success.""" - self._unsupported("expand") + self._unsupported("expand", name, role, app_name, automation_id) def collapse(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Collapse the matched control (ExpandCollapsePattern); True on success.""" - self._unsupported("collapse") + self._unsupported("collapse", name, role, app_name, automation_id) def expand_state(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Optional[str]: """Return ``expanded`` / ``collapsed`` / ``partial`` / ``leaf``, or None.""" - self._unsupported("expand_state") + self._unsupported("expand_state", name, role, app_name, automation_id) def select_item(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Select the matched item (SelectionItemPattern); True on success.""" - self._unsupported("select_item") + self._unsupported("select_item", name, role, app_name, automation_id) def get_range(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Optional[Dict[str, Any]]: """Return ``{value, minimum, maximum}`` (RangeValuePattern), or None.""" - self._unsupported("get_range") + self._unsupported("get_range", name, role, app_name, automation_id) def set_range_value(self, value: float, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Set a slider / progress value (RangeValuePattern); True on success.""" - self._unsupported("set_range_value") + self._unsupported("set_range_value", value, name, role, app_name, automation_id) def scroll_into_view(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Scroll the matched control into view (ScrollItemPattern); True on success.""" - self._unsupported("scroll_into_view") + self._unsupported("scroll_into_view", name, role, app_name, automation_id) # --- text patterns (TextPattern reads) --------------------------------- @@ -109,33 +109,33 @@ def document_text(self, name: Optional[str] = None, role: Optional[str] = None, Reads multiline / document controls where ValuePattern returns ``""``. """ - self._unsupported("document_text") + self._unsupported("document_text", name, role, app_name, automation_id) def selected_text(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Optional[str]: """Return the control's currently selected text (TextPattern), or None.""" - self._unsupported("selected_text") + self._unsupported("selected_text", name, role, app_name, automation_id) def visible_text(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Optional[str]: """Return only the on-screen text of the control (TextPattern), or None.""" - self._unsupported("visible_text") + self._unsupported("visible_text", name, role, app_name, automation_id) def find_text(self, text: str = "", ignore_case: bool = True, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Return whether ``text`` occurs in the control (TextPattern.FindText).""" - self._unsupported("find_text") + self._unsupported("find_text", text, ignore_case, name, role, app_name, automation_id) def select_text(self, text: str = "", ignore_case: bool = True, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Find ``text`` and select its range (TextPattern.FindText + Select).""" - self._unsupported("select_text") + self._unsupported("select_text", text, ignore_case, name, role, app_name, automation_id) def text_attributes(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, @@ -143,7 +143,7 @@ def text_attributes(self, name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Return formatting of the control's selection — ``{font_name, font_size, bold, italic, foreground_color}`` (TextPattern attributes), or None.""" - self._unsupported("text_attributes") + self._unsupported("text_attributes", name, role, app_name, automation_id) # --- keyboard focus ---------------------------------------------------- @@ -151,7 +151,7 @@ def set_focus(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Set keyboard focus on the matched control (SetFocus); True on success.""" - self._unsupported("set_focus") + self._unsupported("set_focus", name, role, app_name, automation_id) # --- virtualized items (realize off-screen list / grid items) ----------- @@ -168,7 +168,7 @@ def find_virtual_item(self, item_name: Optional[str] = None, by: str = "name", (``VirtualizedItemPattern``) so it exists as a real element. Returns the realized element, or None if the container or item isn't found. """ - self._unsupported("find_virtual_item") + self._unsupported("find_virtual_item", item_name, by, container_name, container_role, app_name, automation_id) # --- rich element properties ------------------------------------------- @@ -182,7 +182,7 @@ def get_properties(self, name: Optional[str] = None, ``enabled`` / ``offscreen`` / ``help_text`` / ``item_status`` / ``accelerator_key`` / ``access_key`` / ``orientation``. """ - self._unsupported("get_properties") + self._unsupported("get_properties", name, role, app_name, automation_id) # --- table headers + cell addressing (TablePattern / GridItemPattern) --- @@ -192,7 +192,7 @@ def get_table_headers(self, name: Optional[str] = None, automation_id: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Return a table's header labels as ``{columns: [...], rows: [...]}``.""" - self._unsupported("get_table_headers") + self._unsupported("get_table_headers", name, role, app_name, automation_id) def get_grid_cell(self, row: int = 0, column: int = 0, name: Optional[str] = None, role: Optional[str] = None, @@ -201,7 +201,7 @@ def get_grid_cell(self, row: int = 0, column: int = 0, ) -> Optional[Dict[str, Any]]: """Return the cell at ``(row, column)`` as ``{value, row, column, row_span, column_span}`` (GridPattern.GetItem + GridItemPattern).""" - self._unsupported("get_grid_cell") + self._unsupported("get_grid_cell", row, column, name, role, app_name, automation_id) # --- transform + window patterns (UIA-element-level) -------------------- @@ -210,21 +210,21 @@ def move_element(self, x: float = 0.0, y: float = 0.0, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Move the matched element to ``(x, y)`` (TransformPattern); True on success.""" - self._unsupported("move_element") + self._unsupported("move_element", x, y, name, role, app_name, automation_id) def resize_element(self, width: float = 0.0, height: float = 0.0, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Resize the matched element (TransformPattern); True on success.""" - self._unsupported("resize_element") + self._unsupported("resize_element", width, height, name, role, app_name, automation_id) def set_window_state(self, state: str = "normal", name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Set a window's visual state ``normal`` / ``maximized`` / ``minimized``.""" - self._unsupported("set_window_state") + self._unsupported("set_window_state", state, name, role, app_name, automation_id) def window_interaction_state(self, name: Optional[str] = None, role: Optional[str] = None, @@ -233,7 +233,7 @@ def window_interaction_state(self, name: Optional[str] = None, ) -> Optional[str]: """Return a window's interaction state — ``ready`` / ``blocked_by_modal`` / ``not_responding`` / ``running`` / ``closing`` (WindowPattern), or None.""" - self._unsupported("window_interaction_state") + self._unsupported("window_interaction_state", name, role, app_name, automation_id) # --- MSAA bridge (LegacyIAccessiblePattern) ---------------------------- @@ -247,7 +247,7 @@ def legacy_info(self, name: Optional[str] = None, role: Optional[str] = None, last-resort read for legacy Win32 controls that expose nothing useful via the modern UIA patterns. """ - self._unsupported("legacy_info") + self._unsupported("legacy_info", name, role, app_name, automation_id) def legacy_default_action(self, name: Optional[str] = None, role: Optional[str] = None, @@ -255,7 +255,7 @@ def legacy_default_action(self, name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Fire an old control's MSAA default action (DoDefaultAction); True on success — the fallback when Value / Invoke / Toggle all do nothing.""" - self._unsupported("legacy_default_action") + self._unsupported("legacy_default_action", name, role, app_name, automation_id) # --- container selection + views (Selection / MultipleView patterns) ---- @@ -265,7 +265,7 @@ def get_selection(self, name: Optional[str] = None, role: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Return a container's selection state — ``{items, can_select_multiple, is_required}`` (SelectionPattern), or None.""" - self._unsupported("get_selection") + self._unsupported("get_selection", name, role, app_name, automation_id) def list_views(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, @@ -273,13 +273,13 @@ def list_views(self, name: Optional[str] = None, role: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Return a control's selectable views — ``{current, views: [...]}`` (MultipleViewPattern: list / details / tile / …), or None.""" - self._unsupported("list_views") + self._unsupported("list_views", name, role, app_name, automation_id) def set_view(self, view: str = "", name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Switch a control to the named view (MultipleViewPattern); True on success.""" - self._unsupported("set_view") + self._unsupported("set_view", view, name, role, app_name, automation_id) # --- reactive events (UIA event subscription) -------------------------- @@ -291,9 +291,9 @@ def wait_for_focus_change(self, timeout: float = 5.0, A zero-latency native wait (UIA AddFocusChangedEventHandler) — unlike the polling recorder, it can't miss a fast focus transition. """ - self._unsupported("wait_for_focus_change") + self._unsupported("wait_for_focus_change", timeout) - def _unsupported(self, operation: str): + def _unsupported(self, operation: str, *context: Any): """Raise a clear error for an action this backend can't perform.""" raise AccessibilityNotAvailableError( f"{operation} is not supported by the {self.name} backend", diff --git a/je_auto_control/utils/assertion/assertions.py b/je_auto_control/utils/assertion/assertions.py index 2a80caee..f4214069 100644 --- a/je_auto_control/utils/assertion/assertions.py +++ b/je_auto_control/utils/assertion/assertions.py @@ -489,11 +489,12 @@ def assert_by_description(description: str, ) passed = (matched == present) state = "shows" if present else "does not show" + verdict = "match" if matched else "no match" message = ( f"assert_by_description passed: screen {state} {description!r}" if passed else f"assert_by_description failed: expected screen to {state} " - f"{description!r} (VLM verdict: {'match' if matched else 'no match'})" + f"{description!r} (VLM verdict: {verdict})" ) return _finalize( "vlm", passed, message, diff --git a/je_auto_control/utils/color_match/color_match.py b/je_auto_control/utils/color_match/color_match.py index a0858fc3..c7dd7cd0 100644 --- a/je_auto_control/utils/color_match/color_match.py +++ b/je_auto_control/utils/color_match/color_match.py @@ -26,8 +26,10 @@ def _hsv(source, region, is_haystack: bool): import cv2 - rgb = (_to_rgb(source) if source is not None - else _grab_rgb(region)) if is_haystack else _to_rgb(source) + if is_haystack: + rgb = _to_rgb(source) if source is not None else _grab_rgb(region) + else: + rgb = _to_rgb(source) return cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) diff --git a/je_auto_control/utils/config_bundle/__main__.py b/je_auto_control/utils/config_bundle/__main__.py index 880d4674..f57395a7 100644 --- a/je_auto_control/utils/config_bundle/__main__.py +++ b/je_auto_control/utils/config_bundle/__main__.py @@ -64,7 +64,8 @@ def _do_export(output: Path, root: Optional[Path]) -> int: def _do_import(source: Path, root: Optional[Path], dry_run: bool) -> int: try: - bundle = json.loads(source.read_text(encoding="utf-8")) + # source is an operator-supplied CLI path, not remote input + bundle = json.loads(source.read_text(encoding="utf-8")) # NOSONAR except (OSError, ValueError) as error: print(f"failed to read {source}: {error}", file=sys.stderr) return 2 diff --git a/je_auto_control/utils/element_scoring/element_scoring.py b/je_auto_control/utils/element_scoring/element_scoring.py index 4c84ddf2..4d614260 100644 --- a/je_auto_control/utils/element_scoring/element_scoring.py +++ b/je_auto_control/utils/element_scoring/element_scoring.py @@ -40,6 +40,26 @@ def _proximity(element: Element, anchor: Sequence[int]) -> float: return 1.0 / (1.0 + distance / 100.0) +def _signal_parts(element: Element, want_role: Optional[str], + want_name: Optional[str], + similarity: Callable[[str, str], float], + prefer_enabled: bool, + anchor: Optional[Sequence[int]]) -> Dict[str, float]: + """Build the per-signal 0..1 breakdown for one ``element``.""" + parts: Dict[str, float] = {} + if want_role is not None: + parts["role"] = (1.0 if str(element.get("role", "")).lower() + == str(want_role).lower() else 0.0) + if want_name is not None: + parts["name"] = float(similarity(want_name, + str(element.get("name", "")))) + if anchor is not None: + parts["proximity"] = _proximity(element, anchor) + if prefer_enabled: + parts["enabled"] = 1.0 if element.get("enabled", True) else 0.0 + return parts + + def score_candidates(candidates: Sequence[Element], *, want_role: Optional[str] = None, want_name: Optional[str] = None, @@ -57,17 +77,8 @@ def score_candidates(candidates: Sequence[Element], *, similarity = name_similarity or fuzzy_ratio scored: List[ScoredCandidate] = [] for element in candidates: - parts: Dict[str, float] = {} - if want_role is not None: - parts["role"] = (1.0 if str(element.get("role", "")).lower() - == str(want_role).lower() else 0.0) - if want_name is not None: - parts["name"] = float(similarity(want_name, - str(element.get("name", "")))) - if anchor is not None: - parts["proximity"] = _proximity(element, anchor) - if prefer_enabled: - parts["enabled"] = 1.0 if element.get("enabled", True) else 0.0 + parts = _signal_parts(element, want_role, want_name, similarity, + prefer_enabled, anchor) score = sum(parts.values()) / len(parts) if parts else 0.0 scored.append(ScoredCandidate(element, round(score, 4), parts)) scored.sort(key=lambda candidate: candidate.score, reverse=True) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 9f8cf9b3..d061a072 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -171,6 +171,7 @@ def _run_dag(definition: Dict[str, Any], _AX_RECORDER_SINGLETON = None +_DEFAULT_APPROVALS_DIR = ".approvals" def _a11y_dump(app_name: Optional[str] = None, @@ -2652,6 +2653,91 @@ def _allow_sleep() -> Dict[str, Any]: return {"released": bool(allow_sleep())} +def _get_volume() -> Dict[str, Any]: + """Adapter: the system master volume as an integer percent.""" + from je_auto_control.utils.system_volume import get_volume, is_muted + return {"volume": int(get_volume()), "muted": bool(is_muted())} + + +def _set_volume(level: Any) -> Dict[str, Any]: + """Adapter: set the master volume to ``level`` percent.""" + from je_auto_control.utils.system_volume import set_volume + return {"volume": int(set_volume(float(level)))} + + +def _change_volume(delta: Any) -> Dict[str, Any]: + """Adapter: add ``delta`` percent to the master volume.""" + from je_auto_control.utils.system_volume import change_volume + return {"volume": int(change_volume(float(delta)))} + + +def _set_mute(muted: Any = True) -> Dict[str, Any]: + """Adapter: set the master mute flag.""" + from je_auto_control.utils.system_volume import set_mute + return {"muted": bool(set_mute(bool(muted)))} + + +def _toggle_mute() -> Dict[str, Any]: + """Adapter: flip the master mute flag.""" + from je_auto_control.utils.system_volume import toggle_mute + return {"muted": bool(toggle_mute())} + + +def _lock_session() -> Dict[str, Any]: + """Adapter: lock the workstation now.""" + from je_auto_control.utils.lock_session import lock_session + return {"locked": bool(lock_session())} + + +def _plan_lock_session() -> Dict[str, Any]: + """Adapter: describe how the workstation would be locked (pure).""" + from je_auto_control.utils.lock_session import plan_lock_session + return plan_lock_session() + + +def _wait_for_unlock(timeout: Any = 30.0, interval: Any = 0.5 + ) -> Dict[str, Any]: + """Adapter: block until the session is unlocked or timeout.""" + from je_auto_control.utils.lock_session import wait_for_unlock + unlocked = wait_for_unlock(timeout_s=float(timeout), + interval_s=float(interval)) + return {"unlocked": bool(unlocked)} + + +def _classify_lock_transitions(states: Any) -> Dict[str, Any]: + """Adapter: reduce lock-state samples to lock / unlock events (pure).""" + from je_auto_control.utils.lock_session import classify_lock_transitions + samples = [bool(s) for s in _coerce_list(states)] if states else [] + return {"events": classify_lock_transitions(samples)} + + +def _ime_state() -> Dict[str, Any]: + """Adapter: the focused window's live IME composition / conversion state.""" + from je_auto_control.utils.ime_state import ime_state + return ime_state() + + +def _is_composing() -> Dict[str, Any]: + """Adapter: whether the IME has an uncommitted composition.""" + from je_auto_control.utils.ime_state import is_composing + return {"composing": bool(is_composing())} + + +def _wait_for_composition_commit(timeout: Any = 5.0, interval: Any = 0.1 + ) -> Dict[str, Any]: + """Adapter: block until the IME finishes composing or timeout.""" + from je_auto_control.utils.ime_state import wait_for_composition_commit + committed = wait_for_composition_commit(timeout_s=float(timeout), + interval_s=float(interval)) + return {"committed": bool(committed)} + + +def _decode_conversion_mode(flags: Any) -> Dict[str, Any]: + """Adapter: decode an IMM32 conversion bitmask into named flags (pure).""" + from je_auto_control.utils.ime_state import decode_conversion_mode + return decode_conversion_mode(int(flags)) + + def _normalize_ext(target: str) -> Dict[str, Any]: """Adapter: the lowercased extension of a path / bare ext (pure).""" from je_auto_control.utils.file_assoc import normalize_ext @@ -6029,7 +6115,7 @@ def _egress_reset() -> Dict[str, Any]: def _verify_artifact(name: str, content: Any, - approvals_dir: str = ".approvals", + approvals_dir: str = _DEFAULT_APPROVALS_DIR, extension: str = "txt") -> Dict[str, Any]: """Adapter: verify an artifact against its approved baseline.""" from je_auto_control.utils.approval import verify_artifact @@ -6039,14 +6125,14 @@ def _verify_artifact(name: str, content: Any, "received_path": result.received_path} -def _approve_artifact(name: str, approvals_dir: str = ".approvals", +def _approve_artifact(name: str, approvals_dir: str = _DEFAULT_APPROVALS_DIR, extension: str = "txt") -> Dict[str, Any]: """Adapter: promote a received artifact to the approved baseline.""" from je_auto_control.utils.approval import approve_artifact return {"approved": approve_artifact(name, approvals_dir, extension)} -def _pending_artifacts(approvals_dir: str = ".approvals") -> Dict[str, Any]: +def _pending_artifacts(approvals_dir: str = _DEFAULT_APPROVALS_DIR) -> Dict[str, Any]: """Adapter: list artifacts awaiting approval.""" from je_auto_control.utils.approval import pending_artifacts return {"pending": pending_artifacts(approvals_dir)} @@ -6661,6 +6747,19 @@ def __init__(self): "AC_plan_keep_awake": _plan_keep_awake, "AC_keep_awake_on": _keep_awake_on, "AC_allow_sleep": _allow_sleep, + "AC_get_volume": _get_volume, + "AC_set_volume": _set_volume, + "AC_change_volume": _change_volume, + "AC_set_mute": _set_mute, + "AC_toggle_mute": _toggle_mute, + "AC_lock_session": _lock_session, + "AC_plan_lock_session": _plan_lock_session, + "AC_wait_for_unlock": _wait_for_unlock, + "AC_classify_lock_transitions": _classify_lock_transitions, + "AC_ime_state": _ime_state, + "AC_is_composing": _is_composing, + "AC_wait_for_composition_commit": _wait_for_composition_commit, + "AC_decode_conversion_mode": _decode_conversion_mode, "AC_normalize_ext": _normalize_ext, "AC_file_association": _file_association, "AC_get_control_text": _get_control_text, diff --git a/je_auto_control/utils/form_fields/form_fields.py b/je_auto_control/utils/form_fields/form_fields.py index 5fb646df..edb9eb3b 100644 --- a/je_auto_control/utils/form_fields/form_fields.py +++ b/je_auto_control/utils/form_fields/form_fields.py @@ -26,7 +26,7 @@ def _overlap_1d(a0: int, a1: int, b0: int, b1: int) -> int: def _right_value(label: Box, values: Sequence[Box], max_gap: int): """Nearest value to the right of ``label`` that shares a row, or ``None``.""" - left, top, right, bottom = _box_bounds(label) + _, top, right, bottom = _box_bounds(label) best: Optional[Tuple[Box, int]] = None for value in values: vl, vt, _, vb = _box_bounds(value) @@ -39,7 +39,7 @@ def _right_value(label: Box, values: Sequence[Box], max_gap: int): def _below_value(label: Box, values: Sequence[Box], max_gap: int): """Nearest value below ``label`` that shares a column, or ``None``.""" - left, top, right, bottom = _box_bounds(label) + left, _, right, bottom = _box_bounds(label) best: Optional[Tuple[Box, int]] = None for value in values: vl, vt, vr, _ = _box_bounds(value) diff --git a/je_auto_control/utils/governance/credential_broker.py b/je_auto_control/utils/governance/credential_broker.py index ef4dab72..4325a887 100644 --- a/je_auto_control/utils/governance/credential_broker.py +++ b/je_auto_control/utils/governance/credential_broker.py @@ -86,13 +86,16 @@ def active(self) -> List[Dict[str, object]]: """List non-expired leases as ``{token, name, ttl_remaining}`` (no values).""" now = self._clock() result: List[Dict[str, object]] = [] - for token, lease in list(self._leases.items()): + expired: List[str] = [] + for token, lease in self._leases.items(): remaining = float(lease["expires_at"]) - now if remaining > 0: result.append({"token": token, "name": lease["name"], "ttl_remaining": remaining}) else: - self._leases.pop(token, None) + expired.append(token) + for token in expired: + self._leases.pop(token, None) return result diff --git a/je_auto_control/utils/http_cassette/http_cassette.py b/je_auto_control/utils/http_cassette/http_cassette.py index 443c62cc..676cc30b 100644 --- a/je_auto_control/utils/http_cassette/http_cassette.py +++ b/je_auto_control/utils/http_cassette/http_cassette.py @@ -41,15 +41,9 @@ def _matches(recorded: Mapping[str, Any], call: Mapping[str, Any], match_on: Sequence[str]) -> bool: view = _request_view(call) for field in match_on: - if field == "method": - if recorded.get("method") != view["method"]: - return False - elif field == "url": - if recorded.get("url") != view["url"]: - return False - elif field == "body": - if recorded.get("body") != view["body"]: - return False + if field in ("method", "url", "body") and \ + recorded.get(field) != view[field]: + return False return True diff --git a/je_auto_control/utils/ime_state/__init__.py b/je_auto_control/utils/ime_state/__init__.py new file mode 100644 index 00000000..5613d1c6 --- /dev/null +++ b/je_auto_control/utils/ime_state/__init__.py @@ -0,0 +1,10 @@ +"""Read the live IME composition / conversion state for safe CJK entry.""" +from je_auto_control.utils.ime_state.ime_state import ( + decode_conversion_mode, ime_state, is_composing, + wait_for_composition_commit, +) + +__all__ = [ + "ime_state", "is_composing", "wait_for_composition_commit", + "decode_conversion_mode", +] diff --git a/je_auto_control/utils/ime_state/ime_state.py b/je_auto_control/utils/ime_state/ime_state.py new file mode 100644 index 00000000..6123af5e --- /dev/null +++ b/je_auto_control/utils/ime_state/ime_state.py @@ -0,0 +1,134 @@ +"""Read the live IME (input method editor) state for safe CJK entry. + +Typing into a CJK / Japanese / Korean field is unsafe while an IME is *composing*: +the candidate text has not been committed yet, so reading the field back returns +half-entered glyphs and the next keystroke edits the composition instead of the +field. ``text_unicode`` (``VK_PACKET``) is blind to this. ``ime_state`` exposes +the live composition and conversion state so a flow can wait for the IME to +commit before it reads or acts. + +* :func:`ime_state` — ``{open, composing, composition, conversion}`` for the + focused window's IME, through an injectable ``reader``. +* :func:`is_composing` — ``True`` while the IME has an uncommitted composition. +* :func:`wait_for_composition_commit` — block until composition ends (or a + timeout), with injectable ``clock`` / ``sleep`` / ``reader``. +* :func:`decode_conversion_mode` — pure: the IMM32 ``IME_CMODE_*`` conversion + bitmask to ``{native, katakana, full_shape, roman, char_code}``. + +The default ``reader`` queries Windows IMM32 (``ImmGetContext`` / +``ImmGetOpenStatus`` / ``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``) +read-only; all decoding / waiting logic runs through the injectable seam, so it +is fully testable without an IME. Imports no ``PySide6``. +""" +import sys +import time +from typing import Any, Callable, Dict, Optional + +# IMM32 conversion-mode (IME_CMODE_*) bit flags. +IME_CMODE_NATIVE = 0x0001 +IME_CMODE_KATAKANA = 0x0002 +IME_CMODE_FULLSHAPE = 0x0008 +IME_CMODE_ROMAN = 0x0010 +IME_CMODE_CHARCODE = 0x0020 + +# A reader returns the raw IME state: {open, conversion, composition}. +ImeReader = Callable[[], Dict[str, Any]] + + +def decode_conversion_mode(flags: int) -> Dict[str, bool]: + """Decode an IMM32 ``IME_CMODE_*`` bitmask into named booleans (pure).""" + value = int(flags) + return { + "native": bool(value & IME_CMODE_NATIVE), + "katakana": bool(value & IME_CMODE_KATAKANA), + "full_shape": bool(value & IME_CMODE_FULLSHAPE), + "roman": bool(value & IME_CMODE_ROMAN), + "char_code": bool(value & IME_CMODE_CHARCODE), + } + + +def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]: + """Turn a raw reader result into the public IME-state dict (pure).""" + composition = str(raw.get("composition") or "") + flags = int(raw.get("conversion") or 0) + return { + "open": bool(raw.get("open")), + "composing": bool(composition), + "composition": composition, + "conversion": decode_conversion_mode(flags), + "conversion_flags": flags, + } + + +def ime_state(*, reader: Optional[ImeReader] = None) -> Dict[str, Any]: + """Return the focused window's IME state. + + ``{open, composing, composition, conversion, conversion_flags}``. Pass + ``reader`` (a ``() -> {open, conversion, composition}``) to supply the + reading in tests; the default queries Windows IMM32. + """ + source = reader if reader is not None else _default_reader + return _normalize(source()) + + +def is_composing(*, reader: Optional[ImeReader] = None) -> bool: + """Return ``True`` while the IME has an uncommitted composition.""" + return bool(ime_state(reader=reader)["composing"]) + + +def wait_for_composition_commit( + *, reader: Optional[ImeReader] = None, timeout_s: float = 5.0, + interval_s: float = 0.1, clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep) -> bool: + """Block until the IME is no longer composing; ``True``, or ``False`` on timeout. + + ``clock`` / ``sleep`` / ``reader`` are injectable for deterministic tests. + """ + deadline = clock() + float(timeout_s) + while True: + if not is_composing(reader=reader): + return True + if clock() >= deadline: + return False + sleep(float(interval_s)) + + +# IMM32 composition-string flag: read the in-progress composition (GCS_COMPSTR). +_GCS_COMPSTR = 0x0008 + + +def _read_composition(imm32: Any, himc: int) -> str: + """Read the in-progress composition string from an IME context.""" + import ctypes + byte_len = imm32.ImmGetCompositionStringW(himc, _GCS_COMPSTR, None, 0) + if byte_len <= 0: + return "" + buffer = ctypes.create_unicode_buffer(byte_len // 2) + imm32.ImmGetCompositionStringW(himc, _GCS_COMPSTR, buffer, byte_len) + return buffer.value + + +def _default_reader() -> Dict[str, Any]: + """Read the focused window's IME state from Windows IMM32 (read-only).""" + if not sys.platform.startswith("win"): + raise RuntimeError( + "IME state has no OS reader on this platform; pass reader=") + import ctypes + user32 = ctypes.windll.user32 + imm32 = ctypes.windll.imm32 + hwnd = user32.GetForegroundWindow() + himc = imm32.ImmGetContext(hwnd) + if not himc: + return {"open": False, "conversion": 0, "composition": ""} + try: + conversion = ctypes.c_uint(0) + sentence = ctypes.c_uint(0) + imm32.ImmGetConversionStatus( + himc, ctypes.byref(conversion), ctypes.byref(sentence)) + return { + "open": bool(imm32.ImmGetOpenStatus(himc)), + "conversion": int(conversion.value), + "composition": _read_composition(imm32, himc), + } + finally: + imm32.ImmReleaseContext(hwnd, himc) diff --git a/je_auto_control/utils/lock_session/__init__.py b/je_auto_control/utils/lock_session/__init__.py new file mode 100644 index 00000000..59e5c5f9 --- /dev/null +++ b/je_auto_control/utils/lock_session/__init__.py @@ -0,0 +1,10 @@ +"""Lock the workstation, wait for unlock, and classify lock transitions.""" +from je_auto_control.utils.lock_session.lock_session import ( + classify_lock_transitions, lock_session, plan_lock_session, + wait_for_lock, wait_for_unlock, +) + +__all__ = [ + "lock_session", "plan_lock_session", "wait_for_unlock", "wait_for_lock", + "classify_lock_transitions", +] diff --git a/je_auto_control/utils/lock_session/lock_session.py b/je_auto_control/utils/lock_session/lock_session.py new file mode 100644 index 00000000..cacca6aa --- /dev/null +++ b/je_auto_control/utils/lock_session/lock_session.py @@ -0,0 +1,153 @@ +"""Lock the workstation, wait for it to be unlocked, and classify transitions. + +:mod:`session_guard` answers "is the session locked right now?" and raises if it +is. The missing half is *acting* on the lock state: lock the machine at the end +of an unattended run, block until a human unlocks it before resuming, or reduce +a stream of lock-state samples to lock / unlock events. ``lock_session`` adds: + +* :func:`lock_session` — lock the workstation now (``LockWorkStation`` on + Windows, ``loginctl lock-session`` / ``CGSession -suspend`` elsewhere), + through an injectable ``driver`` seam. +* :func:`plan_lock_session` — pure planner describing how the lock would be + performed on this OS, and whether a default is available. +* :func:`wait_for_unlock` / :func:`wait_for_lock` — poll + :func:`session_guard.is_session_locked` until the state flips or a timeout, + with injectable ``clock`` / ``sleep`` / ``probe`` for deterministic tests. +* :func:`classify_lock_transitions` — pure: a list of lock-state samples to a + list of ``{event, locked}`` lock / unlock transitions. + +Imports no ``PySide6``. +""" +import sys +import time +from typing import Any, Callable, Dict, List, Optional, Sequence + +from je_auto_control.utils.session_guard import is_session_locked +from je_auto_control.utils.session_guard.session_guard import LockProbe + +# A driver performs the lock and returns whether it succeeded. +LockDriver = Callable[[], bool] + + +def _win_lock() -> bool: + """Lock the Windows workstation via ``LockWorkStation``.""" + import ctypes + user32 = ctypes.windll.user32 # nosec B607 # reason: fixed system DLL + return bool(user32.LockWorkStation()) + + +def _lock_backend() -> str: + """Return the lock backend name for the current platform.""" + if sys.platform.startswith("win"): + return "LockWorkStation" + if sys.platform == "darwin": + return "CGSession" + return "loginctl" + + +_CGSESSION = ("/System/Library/CoreServices/Menu Extras/" + "User.menu/Contents/Resources/CGSession") + + +def _lock_argv(backend: str) -> Optional[List[str]]: + """Return the fixed argv for a subprocess lock backend, or None.""" + if backend == "loginctl": + return ["loginctl", "lock-session"] + if backend == "CGSession": + return [_CGSESSION, "-suspend"] + return None + + +def plan_lock_session() -> Dict[str, Any]: + """Describe how the workstation would be locked on this OS (pure). + + Returns ``{backend, argv, available}``. ``available`` is ``True`` when this + platform has a built-in default; otherwise :func:`lock_session` needs an + explicit ``driver=``. + """ + backend = _lock_backend() + argv = _lock_argv(backend) + available = backend == "LockWorkStation" or argv is not None + return {"backend": backend, "argv": argv, "available": available} + + +def _run_argv(argv: List[str]) -> bool: + """Run a fixed lock argv and report success (no shell).""" + import subprocess # nosec B404 # reason: fixed argv, no shell + completed = subprocess.run(argv, check=False) # nosec B603 # nosemgrep + return completed.returncode == 0 + + +def _default_driver() -> LockDriver: + """Return the OS lock driver, or raise if none is available.""" + backend = _lock_backend() + if backend == "LockWorkStation": + return _win_lock + argv = _lock_argv(backend) + if argv is not None: + return lambda: _run_argv(argv) + raise RuntimeError( + "lock_session has no OS driver on this platform; pass driver=") + + +def lock_session(*, driver: Optional[LockDriver] = None) -> bool: + """Lock the workstation now; return whether the lock was requested. + + Pass ``driver`` (a ``() -> bool``) to intercept the OS call in tests; the + default locks via the platform backend from :func:`plan_lock_session`. + """ + acquire = driver if driver is not None else _default_driver() + return bool(acquire()) + + +def _wait_lock_state(target_locked: bool, *, probe: Optional[LockProbe], + timeout_s: float, interval_s: float, + clock: Callable[[], float], + sleep: Callable[[float], None]) -> bool: + """Poll until the lock state equals ``target_locked`` or timeout.""" + deadline = clock() + float(timeout_s) + while True: + if bool(is_session_locked(probe)) == target_locked: + return True + if clock() >= deadline: + return False + sleep(float(interval_s)) + + +def wait_for_unlock(*, probe: Optional[LockProbe] = None, + timeout_s: float = 30.0, interval_s: float = 0.5, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep) -> bool: + """Block until the session is unlocked; return ``True``, or ``False`` on timeout. + + Reuses :func:`session_guard.is_session_locked` (Windows default probe). + ``clock`` / ``sleep`` / ``probe`` are injectable for deterministic tests. + """ + return _wait_lock_state(False, probe=probe, timeout_s=timeout_s, + interval_s=interval_s, clock=clock, sleep=sleep) + + +def wait_for_lock(*, probe: Optional[LockProbe] = None, + timeout_s: float = 30.0, interval_s: float = 0.5, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep) -> bool: + """Block until the session is locked; return ``True``, or ``False`` on timeout.""" + return _wait_lock_state(True, probe=probe, timeout_s=timeout_s, + interval_s=interval_s, clock=clock, sleep=sleep) + + +def classify_lock_transitions(states: Sequence[bool]) -> List[Dict[str, Any]]: + """Reduce lock-state samples to lock / unlock transitions (pure). + + Each adjacent ``False -> True`` yields a ``lock`` event and each + ``True -> False`` an ``unlock`` event; unchanged samples yield nothing. + """ + events: List[Dict[str, Any]] = [] + previous: Optional[bool] = None + for sample in states: + current = bool(sample) + if previous is not None and current != previous: + kind = "lock" if current else "unlock" + events.append({"event": kind, "locked": current}) + previous = current + return events diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 7a9b95cd..4e0ce256 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2168,6 +2168,125 @@ def process_and_shell_tools() -> List[MCPTool]: handler=h.file_association, annotations=READ_ONLY, ), + MCPTool( + name="ac_get_volume", + description=("Read the system master volume as an integer percent " + "0..100. Returns {volume, muted} (Windows, needs " + "the optional 'pycaw' dependency)."), + input_schema=schema({}), + handler=h.get_volume, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_set_volume", + description=("Set the master volume to 'level' percent (clamped to " + "0..100). Returns the applied {volume}."), + input_schema=schema({"level": {"type": "number"}}, + required=["level"]), + handler=h.set_volume, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_change_volume", + description=("Add 'delta' percent to the master volume (may be " + "negative; clamped to 0..100). Returns {volume}."), + input_schema=schema({"delta": {"type": "number"}}, + required=["delta"]), + handler=h.change_volume, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_set_mute", + description=("Mute or unmute the master output. 'muted' defaults to " + "true. Returns the new {muted} state."), + input_schema=schema({"muted": {"type": "boolean"}}), + handler=h.set_mute, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_toggle_mute", + description=("Flip the master mute flag. Returns the new {muted} " + "state."), + input_schema=schema({}), + handler=h.toggle_mute, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_lock_session", + description=("Lock the workstation now (LockWorkStation / loginctl " + "lock-session / CGSession). Returns {locked}. " + "Interrupts the interactive session."), + input_schema=schema({}), + handler=h.lock_session, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_plan_lock_session", + description=("Describe how the workstation would be locked on this " + "OS without locking (pure): {backend, argv, " + "available}."), + input_schema=schema({}), + handler=h.plan_lock_session, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_wait_for_unlock", + description=("Block until the session is unlocked (or 'timeout' " + "seconds), polling every 'interval'. Returns " + "{unlocked}."), + input_schema=schema({"timeout": {"type": "number"}, + "interval": {"type": "number"}}), + handler=h.wait_for_unlock, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_classify_lock_transitions", + description=("Reduce a list of lock-state booleans ('states') to " + "lock / unlock events (pure). Returns {events}."), + input_schema=schema({"states": {"type": "array", + "items": {"type": "boolean"}}}, + required=["states"]), + handler=h.classify_lock_transitions, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_ime_state", + description=("Read the focused window's live IME state (Windows " + "IMM32). Returns {open, composing, composition, " + "conversion, conversion_flags}."), + input_schema=schema({}), + handler=h.ime_state, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_is_composing", + description=("Whether the IME has an uncommitted composition " + "(unsafe to type / read the field). Returns " + "{composing}."), + input_schema=schema({}), + handler=h.is_composing, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_wait_for_composition_commit", + description=("Block until the IME finishes composing (or 'timeout' " + "seconds), polling every 'interval'. Returns " + "{committed}."), + input_schema=schema({"timeout": {"type": "number"}, + "interval": {"type": "number"}}), + handler=h.wait_for_composition_commit, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_decode_conversion_mode", + description=("Decode an IMM32 IME_CMODE_* conversion bitmask " + "('flags') into {native, katakana, full_shape, roman, " + "char_code} (pure)."), + input_schema=schema({"flags": {"type": "integer"}}, + required=["flags"]), + handler=h.decode_conversion_mode, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 8069a557..62b0f307 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -12,6 +12,8 @@ from je_auto_control.utils.mcp_server.tools._base import MCPContent +_DEFAULT_APPROVALS_DIR = ".approvals" + # === Mouse / keyboard ======================================================= @@ -593,6 +595,79 @@ def allow_sleep(): return _allow_sleep() +def get_volume(): + from je_auto_control.utils.executor.action_executor import _get_volume + return _get_volume() + + +def set_volume(level): + from je_auto_control.utils.executor.action_executor import _set_volume + return _set_volume(level) + + +def change_volume(delta): + from je_auto_control.utils.executor.action_executor import _change_volume + return _change_volume(delta) + + +def set_mute(muted=True): + from je_auto_control.utils.executor.action_executor import _set_mute + return _set_mute(muted) + + +def toggle_mute(): + from je_auto_control.utils.executor.action_executor import _toggle_mute + return _toggle_mute() + + +def lock_session(): + from je_auto_control.utils.executor.action_executor import _lock_session + return _lock_session() + + +def plan_lock_session(): + from je_auto_control.utils.executor.action_executor import ( + _plan_lock_session, + ) + return _plan_lock_session() + + +def wait_for_unlock(timeout=30.0, interval=0.5): + from je_auto_control.utils.executor.action_executor import _wait_for_unlock + return _wait_for_unlock(timeout, interval) + + +def classify_lock_transitions(states): + from je_auto_control.utils.executor.action_executor import ( + _classify_lock_transitions, + ) + return _classify_lock_transitions(states) + + +def ime_state(): + from je_auto_control.utils.executor.action_executor import _ime_state + return _ime_state() + + +def is_composing(): + from je_auto_control.utils.executor.action_executor import _is_composing + return _is_composing() + + +def wait_for_composition_commit(timeout=5.0, interval=0.1): + from je_auto_control.utils.executor.action_executor import ( + _wait_for_composition_commit, + ) + return _wait_for_composition_commit(timeout, interval) + + +def decode_conversion_mode(flags): + from je_auto_control.utils.executor.action_executor import ( + _decode_conversion_mode, + ) + return _decode_conversion_mode(flags) + + def normalize_ext(target): from je_auto_control.utils.executor.action_executor import _normalize_ext return _normalize_ext(target) @@ -1515,7 +1590,7 @@ def egress_reset(): return {"allow": None, "deny": []} -def verify_artifact(name: str, content, approvals_dir: str = ".approvals", +def verify_artifact(name: str, content, approvals_dir: str = _DEFAULT_APPROVALS_DIR, extension: str = "txt"): from je_auto_control.utils.approval import verify_artifact as _verify result = _verify(name, content, approvals_dir, extension) @@ -1524,13 +1599,13 @@ def verify_artifact(name: str, content, approvals_dir: str = ".approvals", "received_path": result.received_path} -def approve_artifact(name: str, approvals_dir: str = ".approvals", +def approve_artifact(name: str, approvals_dir: str = _DEFAULT_APPROVALS_DIR, extension: str = "txt"): from je_auto_control.utils.approval import approve_artifact as _approve return {"approved": _approve(name, approvals_dir, extension)} -def pending_artifacts(approvals_dir: str = ".approvals"): +def pending_artifacts(approvals_dir: str = _DEFAULT_APPROVALS_DIR): from je_auto_control.utils.approval import pending_artifacts as _pending return {"pending": _pending(approvals_dir)} diff --git a/je_auto_control/utils/notify/notifier.py b/je_auto_control/utils/notify/notifier.py index df5e59ca..fbd0e83e 100644 --- a/je_auto_control/utils/notify/notifier.py +++ b/je_auto_control/utils/notify/notifier.py @@ -86,6 +86,6 @@ def notify(title: str, message: str, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return NotifyResult(True, system, "sent") - except (FileNotFoundError, OSError, subprocess.SubprocessError) as error: + except (OSError, subprocess.SubprocessError) as error: autocontrol_logger.warning("notify failed: %r", error) return NotifyResult(False, system, repr(error)) diff --git a/je_auto_control/utils/remote_desktop/host_service.py b/je_auto_control/utils/remote_desktop/host_service.py index ee6e0782..ed58841a 100644 --- a/je_auto_control/utils/remote_desktop/host_service.py +++ b/je_auto_control/utils/remote_desktop/host_service.py @@ -217,7 +217,8 @@ def _interactive_configure() -> int: ) answers["poll_interval_s"] = 2.0 _DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - _DEFAULT_CONFIG_PATH.write_text( + # _DEFAULT_CONFIG_PATH is a hardcoded module constant, not user input + _DEFAULT_CONFIG_PATH.write_text( # NOSONAR json.dumps(answers, indent=2), encoding="utf-8", ) try: diff --git a/je_auto_control/utils/remote_desktop/turn_config.py b/je_auto_control/utils/remote_desktop/turn_config.py index aa04f15c..a9373a32 100644 --- a/je_auto_control/utils/remote_desktop/turn_config.py +++ b/je_auto_control/utils/remote_desktop/turn_config.py @@ -142,7 +142,8 @@ def write_bundle(output_dir: Path, *, realm: str, user: str, secret: str, listen_port: int, tls_port: int, tls_cert: Optional[str], tls_key: Optional[str], external_ip: Optional[str]) -> None: - output_dir.mkdir(parents=True, exist_ok=True) + # output_dir is an operator-supplied CLI path, not remote input + output_dir.mkdir(parents=True, exist_ok=True) # NOSONAR conf_path = output_dir / "turnserver.conf" conf_path.write_text(render_turnserver_conf( realm=realm, listen_port=listen_port, tls_port=tls_port, diff --git a/je_auto_control/utils/remote_desktop/web_viewer/index.html b/je_auto_control/utils/remote_desktop/web_viewer/index.html index efadd0cc..f371c074 100644 --- a/je_auto_control/utils/remote_desktop/web_viewer/index.html +++ b/je_auto_control/utils/remote_desktop/web_viewer/index.html @@ -108,8 +108,8 @@ - -