diff --git a/README.md b/README.md index 39205ad7..3bb0141e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ - **LLM Action Planner** — translate a plain-language description into a validated `AC_*` action list using Claude - **Runtime Variables & Control Flow** — `${var}` substitution at execution time, plus `AC_set_var` / `AC_inc_var` / `AC_if_var` / `AC_for_each` / `AC_loop` / `AC_retry` for data-driven scripts - **Remote Desktop** — stream this machine's screen and accept remote input over a token-authenticated TCP protocol, *or* connect to another machine and view + control it (host + viewer GUIs included). Optional TLS (HTTPS-grade encryption), WebSocket transport (ws:// + wss:// for browser / firewall-friendly clients), persistent 9-digit Host ID, host→viewer audio streaming, bidirectional clipboard sync (text + image), and chunked file transfer (drag-drop + progress bar; arbitrary destination path; no size cap). Plus folder sync (additive mirror — local deletions never propagate) and a self-hosted coturn TURN config bundle generator (turnserver.conf + systemd unit + docker-compose + README). **AnyDesk-style popout**: when the viewer authenticates, the live remote desktop opens in its own resizable top-level window so the control panel stays uncluttered. The Remote Desktop tabs are wrapped in `QScrollArea` so the panel stays usable on small windows and stretches edge-to-edge on 4K displays. Driveable headlessly via `je_auto_control` and over MCP through the new `ac_remote_*` tools +- **Driver-level input backends (opt-in)** — for games / apps that ignore SendInput (Win) or XTest (Linux): **Interception driver backend** for Windows (HID-layer keyboard / mouse injection via Oblita's WHQL-signed driver, opt-in via `JE_AUTOCONTROL_WIN32_BACKEND=interception`), **uinput backend** for Linux (kernel `/dev/uinput` synthetic HID device, opt-in via `JE_AUTOCONTROL_LINUX_BACKEND=uinput`), and **ViGEm virtual gamepad** for Windows games that read controllers (virtual Xbox 360 pad with friendly button / dpad / stick / trigger API, exposed as `AC_gamepad_*` executor commands and `ac_gamepad_*` MCP tools). All three fall back gracefully when the driver isn't installed, so existing deployments keep working unchanged - **Clipboard** — read/write system clipboard text on Windows, macOS, and Linux - **Screenshot & Screen Recording** — capture full screen or regions as images, record screen to video (AVI/MP4) - **Action Recording & Playback** — record mouse/keyboard events and replay them diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index f50df980..136a3971 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -63,6 +63,7 @@ - **LLM 动作规划器** — 用 Claude 把自然语言描述翻译成验证过的 `AC_*` 动作清单 - **运行期变量与流程控制** — 执行时 `${var}` 替换,加上 `AC_set_var` / `AC_inc_var` / `AC_if_var` / `AC_for_each` / `AC_loop` / `AC_retry` 让脚本数据驱动 - **远程桌面** — 用 token 认证的 TCP 协议串流本机画面并接收输入,**或** 连接到他机观看与控制(host + viewer GUI 内置)。可选 TLS(HTTPS 级加密)、WebSocket 传输(``ws://`` + ``wss://``,穿墙/浏览器友好)、持久化 9 位数 Host ID、host→viewer 音频串流、双向剪贴板同步(文字 + 图片)、分块文件传输(拖放 + 进度条;任意目的路径;无大小上限)。另含文件夹同步(增量镜像 — 本地删除不会传出去)与自建 coturn TURN 配置包生成器(turnserver.conf + systemd unit + docker-compose + README)。**AnyDesk 风格弹出窗口**:viewer 认证成功后远程桌面会开在独立的可调整大小顶层窗口,控制面板保持简洁;Remote Desktop 子分页外层包了 `QScrollArea`,小窗口下可滚动、4K 屏幕下会铺满。同时支持 headless API 与 MCP 工具 (`ac_remote_*`) 直接驱动 +- **驱动级输入后端(可选)** — 针对忽略 SendInput(Win)或 XTest(Linux)的游戏/应用:**Interception driver 后端**(Windows,HID 层鍵鼠注入,使用 Oblita WHQL-signed driver,通过 `JE_AUTOCONTROL_WIN32_BACKEND=interception` 启用)、**uinput 后端**(Linux,kernel `/dev/uinput` 合成 HID 设备,通过 `JE_AUTOCONTROL_LINUX_BACKEND=uinput` 启用),以及 **ViGEm 虚拟手柄**(Windows,针对只认手柄的游戏,提供虚拟 Xbox 360 手柄 + 友善的 button / dpad / stick / trigger API,并暴露为 `AC_gamepad_*` 执行器命令与 `ac_gamepad_*` MCP 工具)。三者在 driver 没装时都会优雅 fallback,不影响既有部署 - **剪贴板** — 于 Windows / macOS / Linux 读写系统剪贴板文本 - **截图与屏幕录制** — 捕获全屏或指定区域为图片,录制屏幕为视频(AVI/MP4) - **动作录制与回放** — 录制鼠标/键盘事件并重新播放 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 67c1207d..c7c7ee21 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -63,6 +63,7 @@ - **LLM 動作規劃器** — 用 Claude 把自然語言描述翻譯成驗證過的 `AC_*` 動作清單 - **執行期變數與流程控制** — 執行時 `${var}` 取代,加上 `AC_set_var` / `AC_inc_var` / `AC_if_var` / `AC_for_each` / `AC_loop` / `AC_retry` 讓腳本資料驅動 - **遠端桌面** — 用 token 認證的 TCP 協定串流本機畫面並接收輸入,**或** 連線到他機觀看與控制(host + viewer GUI 皆內建)。可選 TLS(HTTPS 級加密)、WebSocket 傳輸(``ws://`` + ``wss://``,穿牆/瀏覽器友善)、持久化 9 位數 Host ID、host→viewer 音訊串流、雙向剪貼簿同步(文字 + 圖片)、分塊檔案傳輸(拖放 + 進度條;任意目的路徑;無大小上限)。另含資料夾同步(增量鏡像 — 本地刪除不會傳出去)與自架 coturn TURN 設定包產生器(turnserver.conf + systemd unit + docker-compose + README)。**AnyDesk 風格彈出視窗**:viewer 認證成功後遠端桌面會開在獨立的可調整大小頂層視窗,控制面板維持簡潔;Remote Desktop 子分頁外層包了 `QScrollArea`,小視窗下可捲動、4K 螢幕下會延展到整寬。同時可由 headless API 與 MCP 工具(`ac_remote_*`)直接驅動 +- **驅動層輸入後端(可選)** — 針對忽略 SendInput(Win)或 XTest(Linux)的遊戲/應用:**Interception driver 後端**(Windows,HID 層鍵鼠注入,使用 Oblita WHQL-signed driver,以 `JE_AUTOCONTROL_WIN32_BACKEND=interception` 啟用)、**uinput 後端**(Linux,kernel `/dev/uinput` 合成 HID 裝置,以 `JE_AUTOCONTROL_LINUX_BACKEND=uinput` 啟用),以及 **ViGEm 虛擬手把**(Windows,針對只認手把的遊戲,提供虛擬 Xbox 360 手把 + 友善的 button / dpad / stick / trigger API,並暴露為 `AC_gamepad_*` 執行器指令與 `ac_gamepad_*` MCP 工具)。三者在 driver 沒裝時都會優雅 fallback,不影響既有部署 - **剪貼簿** — 於 Windows / macOS / Linux 讀寫系統剪貼簿文字 - **截圖與螢幕錄製** — 擷取全螢幕或指定區域為圖片,錄製螢幕為影片(AVI/MP4) - **動作錄製與回放** — 錄製滑鼠/鍵盤事件並重新播放 diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 050cc547..4b85b7ff 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -790,3 +790,123 @@ The status / observer tools (``ac_remote_host_status``, server's ``--readonly`` filter; everything that mutates state is correctly tagged ``destructiveHint: true`` so MCP clients can prompt for user confirmation. + + +Driver-level input backends — drive games that ignore SendInput / XTest +======================================================================== + +The default Windows (SendInput) and Linux (XTest) input paths sit at +the user-mode / X-server layer. Modern games that read input via +``GetRawInputData`` (Win) or ``evdev`` (Linux) skip those layers +entirely and ignore synthetic events. Three optional backends bridge +the gap. + +Interception (Windows) +---------------------- + +Oblita's WHQL-signed Interception driver +(https://github.com/oblitum/Interception) injects keyboard / mouse +events at the HID layer; the OS sees them as real-hardware events. + +* New sub-package: ``je_auto_control/windows/interception/`` + (``_dll.py`` ctypes bindings + ``keyboard.py`` + ``mouse.py``). +* Same public surface as ``win32_ctype_keyboard_control`` / + ``win32_ctype_mouse_control`` — the platform wrapper just swaps + modules, no caller changes. +* Opt-in via ``JE_AUTOCONTROL_WIN32_BACKEND=interception``; the + wrapper falls back to SendInput with a warning when the driver is + missing, so deployments can roll the driver out lazily. +* Override device IDs with ``JE_AUTOCONTROL_INTERCEPTION_KEYBOARD`` + / ``JE_AUTOCONTROL_INTERCEPTION_MOUSE`` (defaults: ``1`` / ``11``). + +Operator setup:: + + # 1. Install the driver as Administrator (one-time, requires reboot) + install-interception.exe /install + + # 2. Tell AutoControl to route through it + setx JE_AUTOCONTROL_WIN32_BACKEND interception + +uinput (Linux) +-------------- + +The kernel's synthetic-input gateway. Events emitted via +``/dev/uinput`` show up as a brand-new HID device, so anything reading +``evdev`` (most games + SDL2 apps) sees them as real input. + +* New sub-package: ``je_auto_control/linux_with_x11/uinput/`` + (``_device.py`` ctypes wrapper around ``ioctl`` + ``keyboard.py`` + + ``mouse.py``). +* No third-party dependency — direct ``ctypes`` + ``ioctl`` to + ``/dev/uinput``. +* Opt-in via ``JE_AUTOCONTROL_LINUX_BACKEND=uinput``; falls back to + XTest with a warning when ``/dev/uinput`` isn't writable. + +Operator setup:: + + # Load the kernel module if it isn't already. + sudo modprobe uinput + + # Grant write access. For one-off testing: + sudo chmod 666 /dev/uinput + + # For persistent provisioning, drop a udev rule: + echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' \ + | sudo tee /etc/udev/rules.d/99-autocontrol-uinput.rules + sudo udevadm control --reload && sudo udevadm trigger + sudo usermod -aG input $USER # log out / back in to apply + + # Then opt in: + export JE_AUTOCONTROL_LINUX_BACKEND=uinput + +ViGEm virtual gamepad (Windows) +------------------------------- + +For games that don't take keyboard input at all but read controllers, +ViGEmBus exposes a virtual Xbox 360 / DualShock 4 controller that +AutoControl drives through the third-party ``vgamepad`` Python +package. + +* New module: ``je_auto_control/utils/gamepad/`` with a friendly + ``VirtualGamepad`` API (string-keyed buttons / dpad / sticks / + triggers, context manager). +* Headless:: + + from je_auto_control import VirtualGamepad + with VirtualGamepad() as pad: + pad.click_button("a") # face button A + pad.set_left_stick(16000, 0) # int16 stick offsets + pad.set_right_trigger(255) # 0..255 pressure + pad.set_dpad("up") # hold dpad up + pad.update() # flush → driver + +* Executor commands: ``AC_gamepad_press``, ``AC_gamepad_release``, + ``AC_gamepad_click``, ``AC_gamepad_dpad``, + ``AC_gamepad_left_stick`` / ``_right_stick``, + ``AC_gamepad_left_trigger`` / ``_right_trigger``, and + ``AC_gamepad_reset``. + +* MCP tools: same names with the ``ac_`` prefix + (``ac_gamepad_press``, ``ac_gamepad_left_stick``, …) — so a model + can play a gamepad-only game over MCP. + +Operator setup:: + + # 1. Install the ViGEmBus driver (one-time, requires reboot) + # https://github.com/nefarius/ViGEmBus/releases + # 2. Install the Python wrapper: + pip install vgamepad + +Anti-cheat caveat (all three) +----------------------------- + +Driver-level injection is harder to detect than SendInput / XTest, +but anti-cheat systems with a kernel-mode driver of their own +(Vanguard, Easy Anti-Cheat with kernel module, BattlEye) can still +enumerate Interception / ViGEmBus / a freshly-created uinput device +and refuse to launch. + +These backends target legitimate use cases — accessibility software, +GUI testing of games that lock out user-mode input, controlling a +remote game-running machine from a headless setup — and aren't a +generic anti-cheat bypass. diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index 2c6da59a..97bd74c6 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -739,3 +739,118 @@ registry 包成工具,工廠函式為 ``ac_remote_viewer_status``)為唯讀,可以通過 MCP server 的 ``--readonly`` 過濾;會修改狀態的工具都正確帶上 ``destructiveHint: true``,MCP client 端可以據此跳出使用者確認。 + + +驅動層輸入後端 — 驅動不接受 SendInput / XTest 的遊戲 +===================================================== + +預設的 Windows(SendInput)與 Linux(XTest)輸入路徑落在 user-mode +/ X-server 那一層;會用 ``GetRawInputData``(Win)或 ``evdev`` +(Linux)直接讀 raw input 的遊戲會跳過這些層,完全忽略合成事件。 +新增三個可選的後端可以解決這個問題。 + +Interception(Windows) +------------------------ + +Oblita 的 WHQL-signed Interception driver +(https://github.com/oblitum/Interception)在 HID 層注入鍵鼠事件, +OS 看到的就是「真實裝置」事件。 + +* 新增子套件:``je_auto_control/windows/interception/`` + (``_dll.py`` ctypes bindings + ``keyboard.py`` + ``mouse.py``)。 +* 與 ``win32_ctype_keyboard_control`` / + ``win32_ctype_mouse_control`` 公開介面完全一致 — wrapper 在啟動 + 時直接換模組,呼叫端不需要任何修改。 +* 透過 ``JE_AUTOCONTROL_WIN32_BACKEND=interception`` 啟用;若 + driver 沒裝,wrapper 會打 warning 並回到 SendInput,所以可以分 + 階段佈署。 +* 用 ``JE_AUTOCONTROL_INTERCEPTION_KEYBOARD`` / + ``JE_AUTOCONTROL_INTERCEPTION_MOUSE`` 覆寫 device id(預設 + ``1`` / ``11``)。 + +操作步驟:: + + # 1. 以系統管理員身份安裝 driver(一次性,需要重開機) + install-interception.exe /install + + # 2. 告訴 AutoControl 走這條路 + setx JE_AUTOCONTROL_WIN32_BACKEND interception + +uinput(Linux) +---------------- + +kernel 自帶的合成輸入閘道。透過 ``/dev/uinput`` 送出的事件會被 +建立成一個全新的 HID 裝置,任何讀 ``evdev`` 的程式(包含大部分 +遊戲與 SDL2 app)都會視為真實輸入。 + +* 新增子套件:``je_auto_control/linux_with_x11/uinput/`` + (``_device.py`` 直接用 ctypes + ioctl 包 ``/dev/uinput`` + + ``keyboard.py`` + ``mouse.py``)。 +* 無第三方依賴 — 全程 ctypes + ioctl。 +* 透過 ``JE_AUTOCONTROL_LINUX_BACKEND=uinput`` 啟用;若 + ``/dev/uinput`` 沒寫入權限,會 warning 後回退到 XTest。 + +操作步驟:: + + # 載入 kernel module + sudo modprobe uinput + + # 一次性測試:直接放寬權限 + sudo chmod 666 /dev/uinput + + # 持續性權限,寫一個 udev rule: + echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' \ + | sudo tee /etc/udev/rules.d/99-autocontrol-uinput.rules + sudo udevadm control --reload && sudo udevadm trigger + sudo usermod -aG input $USER # 重新登入後生效 + + # 啟用後端 + export JE_AUTOCONTROL_LINUX_BACKEND=uinput + +ViGEm 虛擬手把(Windows) +------------------------- + +針對「完全不吃鍵鼠、只認手把」的遊戲,可以用 ViGEmBus 建立一個虛 +擬 Xbox 360 / DualShock 4 控制器;AutoControl 透過第三方 ``vgamepad`` +套件來驅動它。 + +* 新增模組:``je_auto_control/utils/gamepad/`` 提供友善的 + ``VirtualGamepad`` API(字串名稱的 button / dpad / stick / + trigger,支援 context manager)。 +* Headless:: + + from je_auto_control import VirtualGamepad + with VirtualGamepad() as pad: + pad.click_button("a") # A 鍵 + pad.set_left_stick(16000, 0) # int16 stick 偏移 + pad.set_right_trigger(255) # 0..255 力度 + pad.set_dpad("up") # 按住方向鍵上 + pad.update() # 把狀態 flush 給 driver + +* Executor 指令:``AC_gamepad_press``、``AC_gamepad_release``、 + ``AC_gamepad_click``、``AC_gamepad_dpad``、 + ``AC_gamepad_left_stick`` / ``_right_stick``、 + ``AC_gamepad_left_trigger`` / ``_right_trigger``,以及 + ``AC_gamepad_reset``。 + +* MCP 工具:同名加上 ``ac_`` 前綴(``ac_gamepad_press``、 + ``ac_gamepad_left_stick`` …),所以模型可以透過 MCP 玩只支援 + 手把的遊戲。 + +操作步驟:: + + # 1. 安裝 ViGEmBus driver(一次性,需要重開機) + # https://github.com/nefarius/ViGEmBus/releases + # 2. 安裝 Python wrapper: + pip install vgamepad + +反作弊注意事項 +--------------- + +驅動層注入比 SendInput / XTest 更難偵測,但帶 kernel-mode driver +的反作弊(Vanguard、有 kernel module 的 Easy Anti-Cheat、 +BattlEye)依然可以列舉 Interception / ViGEmBus / 新建立的 uinput +裝置然後拒絕啟動。 + +這三個後端針對的是合法用途 — 輔助科技、遊戲 GUI 測試、從 headless +環境控制執行遊戲的遠端機器 — **不是** 通用反作弊繞過工具。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index d5353621..1fe4b775 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -78,6 +78,11 @@ from je_auto_control.utils.remote_desktop.registry import ( registry as remote_desktop_registry, ) +from je_auto_control.utils.gamepad import ( + GamepadUnavailable, VirtualGamepad, + default_gamepad as default_virtual_gamepad, + is_available as is_virtual_gamepad_available, +) # MCP server (headless stdio bridge for Claude / other MCP clients) from je_auto_control.utils.mcp_server import ( AuditLogger, HttpMCPServer, MCPContent, MCPPrompt, MCPPromptArgument, @@ -122,6 +127,15 @@ ConfigBundleExporter, ConfigBundleImporter, ImportReport, export_config_bundle, import_config_bundle, ) +# Profiler (headless) +from je_auto_control.utils.profiler import ( + ActionProfiler, ActionStats, default_profiler, +) +# Secrets (headless) +from je_auto_control.utils.secrets import ( + SecretManager, SecretStoreError, SecretStoreLocked, + default_secret_manager, default_secret_store_path, +) # Run history (headless) from je_auto_control.utils.run_history.history_store import ( HistoryStore, RunRecord, default_history_store, @@ -131,6 +145,12 @@ FilePathTrigger, ImageAppearsTrigger, PixelColorTrigger, TriggerEngine, WindowAppearsTrigger, default_trigger_engine, ) +from je_auto_control.utils.triggers.webhook_server import ( + WebhookTrigger, WebhookTriggerServer, default_webhook_server, +) +from je_auto_control.utils.triggers.email_trigger import ( + EmailTrigger, EmailTriggerWatcher, default_email_trigger_watcher, +) # Recording editor (headless helpers) from je_auto_control.utils.recording_edit.editor import ( adjust_delays, filter_actions, insert_action, remove_action, @@ -297,6 +317,14 @@ def start_autocontrol_gui(*args, **kwargs): "TriggerEngine", "default_trigger_engine", "ImageAppearsTrigger", "WindowAppearsTrigger", "PixelColorTrigger", "FilePathTrigger", + "WebhookTrigger", "WebhookTriggerServer", "default_webhook_server", + "EmailTrigger", "EmailTriggerWatcher", + "default_email_trigger_watcher", + # Profiler + "ActionProfiler", "ActionStats", "default_profiler", + # Secret manager + "SecretManager", "SecretStoreError", "SecretStoreLocked", + "default_secret_manager", "default_secret_store_path", # Run history "HistoryStore", "RunRecord", "default_history_store", # Accessibility @@ -312,6 +340,9 @@ def start_autocontrol_gui(*args, **kwargs): "RemoteDesktopHost", "RemoteDesktopViewer", "RemoteDesktopAuthError", "RemoteDesktopInputError", "RemoteDesktopProtocolError", "remote_desktop_registry", + # Virtual gamepad (ViGEm) + "VirtualGamepad", "GamepadUnavailable", + "default_virtual_gamepad", "is_virtual_gamepad_available", "generate_html", "generate_html_report", "generate_json", "generate_json_report", "generate_xml", "generate_xml_report", "get_dir_files_as_list", "create_project_dir", "start_autocontrol_socket_server", "callback_executor", "package_manager", "ShellManager", "default_shell_manager", diff --git a/je_auto_control/gui/__init__.py b/je_auto_control/gui/__init__.py index 350707f6..26e60a0b 100644 --- a/je_auto_control/gui/__init__.py +++ b/je_auto_control/gui/__init__.py @@ -1,11 +1,22 @@ -import sys +"""GUI package — kept import-light so individual tabs can be loaded without +pulling in the full main window (which transitively requires the optional +``webrtc`` extra). The launcher imports its dependencies lazily inside +:func:`start_autocontrol_gui` so:: -from PySide6.QtWidgets import QApplication + from je_auto_control.gui.profiler_tab import ProfilerTab -from je_auto_control.gui.main_window import AutoControlGUIUI +works in environments that have not installed PyAV / aiortc. +""" -def start_autocontrol_gui(): +def start_autocontrol_gui() -> None: + """Open the AutoControl GUI; pulls in PySide6 + WebRTC stack lazily.""" + import sys + + from PySide6.QtWidgets import QApplication + + from je_auto_control.gui.main_window import AutoControlGUIUI + app = QApplication(sys.argv) window = AutoControlGUIUI() window.show() diff --git a/je_auto_control/gui/email_triggers_tab.py b/je_auto_control/gui/email_triggers_tab.py new file mode 100644 index 00000000..275867fc --- /dev/null +++ b/je_auto_control/gui/email_triggers_tab.py @@ -0,0 +1,230 @@ +"""Email Triggers tab: bind IMAP mailboxes to action scripts.""" +from typing import Optional + +from PySide6.QtCore import QTimer, Qt +from PySide6.QtWidgets import ( + QAbstractItemView, QCheckBox, QFileDialog, QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QLineEdit, QMessageBox, QPushButton, + QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +from je_auto_control.utils.triggers.email_trigger import ( + default_email_trigger_watcher, +) + + +_REFRESH_MS = 1500 + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +class EmailTriggersTab(TranslatableMixin, QWidget): + """GUI front-end for :data:`default_email_trigger_watcher`.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._host_input = QLineEdit() + self._host_input.setPlaceholderText("imap.example.com") + self._port_input = QSpinBox() + self._port_input.setRange(0, 65535) + self._port_input.setValue(993) + self._user_input = QLineEdit() + self._user_input.setPlaceholderText("user@example.com") + self._password_input = QLineEdit() + self._password_input.setEchoMode(QLineEdit.Password) + self._password_input.setPlaceholderText(_t("eml_password_placeholder")) + self._mailbox_input = QLineEdit("INBOX") + self._search_input = QLineEdit("UNSEEN") + self._poll_input = QSpinBox() + self._poll_input.setRange(5, 86_400) + self._poll_input.setValue(60) + self._script_input = QLineEdit() + self._ssl_check = self._tr(QCheckBox(), "eml_ssl") + self._ssl_check.setChecked(True) + self._mark_seen_check = self._tr(QCheckBox(), "eml_mark_seen") + self._mark_seen_check.setChecked(True) + self._status_label = QLabel() + self._table = QTableWidget(0, 7) + self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._table.setSelectionBehavior(QAbstractItemView.SelectRows) + self._table.verticalHeader().setVisible(False) + self._table.horizontalHeader().setSectionResizeMode( + QHeaderView.Interactive, + ) + self._table.horizontalHeader().setStretchLastSection(True) + self._apply_table_headers() + self._build_layout() + self._timer = QTimer(self) + self._timer.setInterval(_REFRESH_MS) + self._timer.timeout.connect(self._refresh) + self._timer.start() + self._refresh() + + def retranslate(self) -> None: + TranslatableMixin.retranslate(self) + self._apply_table_headers() + self._refresh() + + def _apply_table_headers(self) -> None: + self._table.setHorizontalHeaderLabels([ + _t("eml_col_id"), _t("eml_col_host"), _t("eml_col_user"), + _t("eml_col_mailbox"), _t("eml_col_script"), + _t("eml_col_fired"), _t("eml_col_error"), + ]) + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + + engine_box = self._tr(QGroupBox(), "eml_engine_group") + engine_layout = QHBoxLayout(engine_box) + start_btn = self._tr(QPushButton(), "eml_start") + start_btn.clicked.connect(self._on_start) + engine_layout.addWidget(start_btn) + stop_btn = self._tr(QPushButton(), "eml_stop") + stop_btn.clicked.connect(self._on_stop) + engine_layout.addWidget(stop_btn) + poll_btn = self._tr(QPushButton(), "eml_poll_now") + poll_btn.clicked.connect(self._on_poll_now) + engine_layout.addWidget(poll_btn) + engine_layout.addWidget(self._status_label) + engine_layout.addStretch() + root.addWidget(engine_box) + + add_box = self._tr(QGroupBox(), "eml_add_group") + add_layout = QVBoxLayout(add_box) + host_row = QHBoxLayout() + host_row.addWidget(self._tr(QLabel(), "eml_host_label")) + host_row.addWidget(self._host_input) + host_row.addWidget(self._tr(QLabel(), "eml_port_label")) + host_row.addWidget(self._port_input) + host_row.addWidget(self._ssl_check) + add_layout.addLayout(host_row) + creds_row = QHBoxLayout() + creds_row.addWidget(self._tr(QLabel(), "eml_user_label")) + creds_row.addWidget(self._user_input) + creds_row.addWidget(self._tr(QLabel(), "eml_password_label")) + creds_row.addWidget(self._password_input) + add_layout.addLayout(creds_row) + mb_row = QHBoxLayout() + mb_row.addWidget(self._tr(QLabel(), "eml_mailbox_label")) + mb_row.addWidget(self._mailbox_input) + mb_row.addWidget(self._tr(QLabel(), "eml_search_label")) + mb_row.addWidget(self._search_input) + add_layout.addLayout(mb_row) + poll_row = QHBoxLayout() + poll_row.addWidget(self._tr(QLabel(), "eml_poll_label")) + poll_row.addWidget(self._poll_input) + poll_row.addWidget(self._mark_seen_check) + poll_row.addStretch() + add_layout.addLayout(poll_row) + script_row = QHBoxLayout() + script_row.addWidget(self._tr(QLabel(), "eml_script_label")) + script_row.addWidget(self._script_input) + browse_btn = self._tr(QPushButton(), "eml_browse") + browse_btn.clicked.connect(self._on_browse) + script_row.addWidget(browse_btn) + register_btn = self._tr(QPushButton(), "eml_register") + register_btn.clicked.connect(self._on_register) + script_row.addWidget(register_btn) + add_layout.addLayout(script_row) + root.addWidget(add_box) + + root.addWidget(self._table, stretch=1) + action_row = QHBoxLayout() + remove_btn = self._tr(QPushButton(), "eml_remove") + remove_btn.clicked.connect(self._on_remove) + action_row.addWidget(remove_btn) + action_row.addStretch() + root.addLayout(action_row) + + def _on_start(self) -> None: + default_email_trigger_watcher.start() + self._refresh() + + def _on_stop(self) -> None: + default_email_trigger_watcher.stop() + self._refresh() + + def _on_poll_now(self) -> None: + try: + fired = default_email_trigger_watcher.poll_once() + except (OSError, RuntimeError) as error: + QMessageBox.warning(self, _t("eml_poll_now"), str(error)) + return + QMessageBox.information( + self, _t("eml_poll_now"), + _t("eml_poll_done").replace("{n}", str(fired)), + ) + self._refresh() + + def _on_browse(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, _t("eml_browse"), "", "JSON (*.json)", + ) + if path: + self._script_input.setText(path) + + def _on_register(self) -> None: + host = self._host_input.text().strip() + user = self._user_input.text().strip() + password = self._password_input.text() + script = self._script_input.text().strip() + if not host or not user or not password or not script: + QMessageBox.warning(self, _t("eml_register"), + _t("eml_required_fields")) + return + try: + default_email_trigger_watcher.add( + host=host, username=user, password=password, + script_path=script, + port=int(self._port_input.value()), + use_ssl=self._ssl_check.isChecked(), + mailbox=self._mailbox_input.text().strip() or "INBOX", + search_criteria=self._search_input.text().strip() or "UNSEEN", + mark_seen=self._mark_seen_check.isChecked(), + poll_seconds=float(self._poll_input.value()), + ) + except ValueError as error: + QMessageBox.warning(self, _t("eml_register"), str(error)) + return + self._password_input.clear() + self._refresh() + + def _on_remove(self) -> None: + row = self._table.currentRow() + if row < 0: + return + item = self._table.item(row, 0) + if item is None: + return + default_email_trigger_watcher.remove(item.text()) + self._refresh() + + def _refresh(self) -> None: + running = default_email_trigger_watcher.is_running + self._status_label.setText( + _t("eml_running") if running else _t("eml_stopped"), + ) + rows = default_email_trigger_watcher.list_triggers() + self._table.setRowCount(len(rows)) + for row, trigger in enumerate(rows): + values = ( + trigger.trigger_id, + f"{trigger.host}:{trigger.port}", + trigger.username, + trigger.mailbox, + trigger.script_path, + str(trigger.fired), + trigger.last_error or "", + ) + for col, text in enumerate(values): + item = QTableWidgetItem(text) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self._table.setItem(row, col, item) diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index ca717432..8fd0e906 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -25,15 +25,19 @@ "tab_live_hud": "Live HUD", "tab_hotkeys": "Hotkeys", "tab_triggers": "Triggers", + "tab_webhooks": "Webhooks", + "tab_email_triggers": "Email Triggers", "tab_plugins": "Plugins", "tab_screen_record": "Screen Recording", "tab_shell": "Shell Command", "tab_report": "Report", "tab_run_history": "Run History", + "tab_profiler": "Profiler", "tab_accessibility": "Accessibility", "tab_vlm": "AI Locator", "tab_ocr_reader": "OCR Reader", "tab_variables": "Variables", + "tab_secrets": "Secrets", "tab_llm_planner": "LLM Planner", "tab_remote_desktop": "Remote Desktop", "tab_rest_api": "REST API", @@ -658,6 +662,111 @@ "rh_open_artifact": "Open artifact", "rh_no_artifact": "Selected run has no artifact.", "rh_artifact_missing": "Artifact file no longer exists.", + "rh_timeline_heading": "Timeline (oldest left → newest right)", + "rh_preview_heading": "Preview", + "rh_preview_empty": "Select a run to preview.", + "rh_preview_no_artifact": "No screenshot for this run.", + + # Email triggers tab + "eml_engine_group": "Polling engine", + "eml_add_group": "New IMAP trigger", + "eml_start": "Start polling", + "eml_stop": "Stop polling", + "eml_poll_now": "Poll now", + "eml_poll_done": "Fired {n} message(s) on this pass.", + "eml_running": "Polling is active.", + "eml_stopped": "Polling is stopped.", + "eml_host_label": _HOST_LABEL, + "eml_port_label": _PORT_LABEL, + "eml_user_label": "User:", + "eml_password_label": "Password:", + "eml_password_placeholder": "IMAP password / app password", + "eml_mailbox_label": "Mailbox:", + "eml_search_label": "Search:", + "eml_poll_label": "Poll (s):", + "eml_script_label": _SCRIPT_LABEL, + "eml_browse": "Browse", + "eml_register": "Register trigger", + "eml_remove": _REMOVE_SELECTED, + "eml_ssl": "Use SSL", + "eml_mark_seen": "Mark as seen after firing", + "eml_required_fields": "Host, user, password, and script are required.", + "eml_col_id": "ID", + "eml_col_host": "Host", + "eml_col_user": "User", + "eml_col_mailbox": "Mailbox", + "eml_col_script": "Script", + "eml_col_fired": "Fired", + "eml_col_error": "Last error", + + # Webhooks tab + "wh_server_group": "HTTP server", + "wh_add_group": "New webhook", + "wh_host_label": _HOST_LABEL, + "wh_port_label": _PORT_LABEL, + "wh_start": "Start", + "wh_stop": "Stop", + "wh_started": "Listening on {host}:{port}", + "wh_running": "Running on {host}:{port}", + "wh_stopped": "Server is stopped.", + "wh_path_label": "Path:", + "wh_script_label": _SCRIPT_LABEL, + "wh_browse": "Browse", + "wh_methods_label": "Methods:", + "wh_token_label": "Token:", + "wh_token_placeholder": "optional bearer token", + "wh_register": "Register webhook", + "wh_remove": _REMOVE_SELECTED, + "wh_path_and_script_required": "Path and script file are required.", + "wh_col_id": "ID", + "wh_col_path": "Path", + "wh_col_methods": "Methods", + "wh_col_script": "Script", + "wh_col_fired": "Fired", + "wh_col_token": "Auth?", + "wh_yes": "Yes", + "wh_no": "No", + + # Secrets tab + "secret_unlock_group": "Vault", + "secret_manage_group": "Secrets", + "secret_passphrase_label": "Passphrase:", + "secret_passphrase_placeholder": "vault passphrase", + "secret_init": "Create vault", + "secret_init_done": "Vault created and unlocked.", + "secret_unlock": "Unlock", + "secret_lock": "Lock", + "secret_add": "Add secret", + "secret_remove": "Remove", + "secret_change_passphrase": "Change passphrase", + "secret_change_done": "Vault re-encrypted with the new passphrase.", + "secret_status_uninitialized": "Vault not created yet.", + "secret_status_unlocked": "Vault is unlocked.", + "secret_status_locked": "Vault is locked.", + "secret_passphrase_required": "Enter a passphrase first.", + "secret_wrong_passphrase": "Wrong passphrase.", + "secret_unlock_first": "Unlock the vault before managing secrets.", + "secret_name_prompt": "Secret name (use as ${secrets.NAME}):", + "secret_value_prompt": "Secret value:", + "secret_old_passphrase_prompt": "Current passphrase:", + "secret_new_passphrase_prompt": "New passphrase:", + + # Profiler tab + "prof_enable": "Enable profiler", + "prof_disable": "Disable profiler", + "prof_reset": "Reset stats", + "prof_refresh": "Refresh", + "prof_running": "Profiler is recording.", + "prof_paused": "Profiler is off — enable to record durations.", + "prof_total_label": "{n} actions tracked", + "prof_total_empty": "No samples yet.", + "prof_col_name": "Action", + "prof_col_calls": "Calls", + "prof_col_total": "Total", + "prof_col_avg": "Avg", + "prof_col_min": "Min", + "prof_col_max": "Max", + "prof_col_share": "Share", # Accessibility Tab "a11y_app_label": "App:", diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index 8fbf5a3a..a182938a 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -1,5 +1,7 @@ _SCRIPT = "スクリプト" _SCRIPT_LABEL = "スクリプト:" +_SCRIPT_LABEL_FW = "スクリプト:" # full-width colon variant +_REMOVE_SEL_FW = "選択を削除" _REMOVE_SELECTED = "選択項目を削除" _SELECT_SCRIPT = "スクリプトを選択" _TOKEN_LABEL = "トークン:" @@ -23,11 +25,15 @@ "tab_live_hud": "ライブ HUD", "tab_hotkeys": "ホットキー", "tab_triggers": "トリガー", + "tab_webhooks": "Webhook", + "tab_email_triggers": "Email トリガー", "tab_plugins": "プラグイン", "tab_screen_record": "画面録画", "tab_shell": "シェル", "tab_report": "レポート", "tab_run_history": "実行履歴", + "tab_profiler": "プロファイラ", + "tab_secrets": "シークレット", "tab_accessibility": "アクセシビリティ", "tab_vlm": "AI ロケーター", "tab_ocr_reader": "OCR リーダー", @@ -127,7 +133,7 @@ # 管理コンソールタブ "admin_add_group": "ホストを登録", "admin_add": "追加", - "admin_remove": "選択を削除", + "admin_remove": _REMOVE_SEL_FW, "admin_refresh": "全件ポーリング", "admin_label": "ラベル:", "admin_url": "ベース URL:", @@ -225,7 +231,7 @@ "rd_webrtc_host_id_required": "Host ID が必要", # 信頼リスト / 受け入れダイアログ "rd_webrtc_trusted_group": "信頼済みビューア(自動承認)", - "rd_webrtc_remove_trusted": "選択を削除", + "rd_webrtc_remove_trusted": _REMOVE_SEL_FW, "rd_webrtc_clear_trusted": _CLEAR_ALL_JA, "rd_webrtc_clear_trust_confirm": "信頼済みビューアをすべて削除しますか?", "rd_webrtc_pending_viewer_title": "新規接続要求", @@ -656,6 +662,111 @@ "rh_open_artifact": "スクリーンショットを開く", "rh_no_artifact": "選択した実行にスクリーンショットはありません。", "rh_artifact_missing": "スクリーンショットファイルが存在しません。", + "rh_timeline_heading": "タイムライン(左:古い → 右:新しい)", + "rh_preview_heading": "プレビュー", + "rh_preview_empty": "実行を選択するとプレビューが表示されます。", + "rh_preview_no_artifact": "この実行のスクリーンショットはありません。", + + # Email triggers tab + "eml_engine_group": "ポーリングエンジン", + "eml_add_group": "新規 IMAP トリガー", + "eml_start": "ポーリング開始", + "eml_stop": "ポーリング停止", + "eml_poll_now": "今すぐポーリング", + "eml_poll_done": "今回 {n} 件のメッセージで発火しました。", + "eml_running": "ポーリング中。", + "eml_stopped": "ポーリング停止中。", + "eml_host_label": "ホスト:", + "eml_port_label": "ポート:", + "eml_user_label": "ユーザー:", + "eml_password_label": "パスワード:", + "eml_password_placeholder": "IMAP パスワード / アプリパスワード", + "eml_mailbox_label": "メールボックス:", + "eml_search_label": "検索条件:", + "eml_poll_label": "ポーリング (秒):", + "eml_script_label": "スクリプト:", + "eml_browse": "参照", + "eml_register": "トリガーを登録", + "eml_remove": _REMOVE_SEL_FW, + "eml_ssl": "SSL を使用", + "eml_mark_seen": "発火後に既読にする", + "eml_required_fields": "ホスト、ユーザー、パスワード、スクリプトが必要です。", + "eml_col_id": "ID", + "eml_col_host": "ホスト", + "eml_col_user": "ユーザー", + "eml_col_mailbox": "メールボックス", + "eml_col_script": _SCRIPT, + "eml_col_fired": "発火回数", + "eml_col_error": "最近のエラー", + + # Webhooks tab + "wh_server_group": "HTTP サーバー", + "wh_add_group": "新規 webhook", + "wh_host_label": "ホスト:", + "wh_port_label": "ポート:", + "wh_start": "開始", + "wh_stop": "停止", + "wh_started": "{host}:{port} で待機中", + "wh_running": "稼働中 {host}:{port}", + "wh_stopped": "サーバー停止中。", + "wh_path_label": "パス:", + "wh_script_label": _SCRIPT_LABEL_FW, + "wh_browse": "参照", + "wh_methods_label": "メソッド:", + "wh_token_label": "Token:", + "wh_token_placeholder": "Bearer トークン (任意)", + "wh_register": "Webhook を登録", + "wh_remove": _REMOVE_SEL_FW, + "wh_path_and_script_required": "パスとスクリプトファイルが必要です。", + "wh_col_id": "ID", + "wh_col_path": "パス", + "wh_col_methods": "メソッド", + "wh_col_script": _SCRIPT, + "wh_col_fired": "発火回数", + "wh_col_token": "認証?", + "wh_yes": "あり", + "wh_no": "なし", + + # Secrets tab + "secret_unlock_group": "ボールト", + "secret_manage_group": "シークレット", + "secret_passphrase_label": "パスフレーズ:", + "secret_passphrase_placeholder": "ボールトパスフレーズ", + "secret_init": "ボールトを作成", + "secret_init_done": "ボールトを作成し、解錠しました。", + "secret_unlock": "解錠", + "secret_lock": "ロック", + "secret_add": "シークレットを追加", + "secret_remove": "削除", + "secret_change_passphrase": "パスフレーズ変更", + "secret_change_done": "新しいパスフレーズで再暗号化しました。", + "secret_status_uninitialized": "ボールト未作成。", + "secret_status_unlocked": "ボールト解錠中。", + "secret_status_locked": "ボールトはロック中。", + "secret_passphrase_required": "パスフレーズを入力してください。", + "secret_wrong_passphrase": "パスフレーズが違います。", + "secret_unlock_first": "先にボールトを解錠してください。", + "secret_name_prompt": "シークレット名 (${secrets.NAME} で参照):", + "secret_value_prompt": "シークレット値:", + "secret_old_passphrase_prompt": "現在のパスフレーズ:", + "secret_new_passphrase_prompt": "新しいパスフレーズ:", + + # Profiler tab + "prof_enable": "プロファイラを有効化", + "prof_disable": "プロファイラを無効化", + "prof_reset": "統計をリセット", + "prof_refresh": "更新", + "prof_running": "プロファイラ計測中。", + "prof_paused": "プロファイラは停止中です。", + "prof_total_label": "{n} アクションを記録", + "prof_total_empty": "サンプルがまだありません。", + "prof_col_name": "アクション", + "prof_col_calls": "回数", + "prof_col_total": "合計", + "prof_col_avg": "平均", + "prof_col_min": "最小", + "prof_col_max": "最大", + "prof_col_share": "割合", # Accessibility Tab "a11y_app_label": "アプリ:", diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index fba36921..696547ae 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -15,11 +15,15 @@ "tab_live_hud": "实时监看", "tab_hotkeys": "全局热键", "tab_triggers": "事件触发器", + "tab_webhooks": "Webhook 触发", + "tab_email_triggers": "Email 触发", "tab_plugins": "插件", "tab_screen_record": "屏幕录像", "tab_shell": "Shell 命令", "tab_report": "报告生成", "tab_run_history": "执行记录", + "tab_profiler": "性能分析", + "tab_secrets": "密钥管理", "tab_accessibility": "无障碍树", "tab_vlm": "AI 定位", "tab_ocr_reader": "OCR 读取", @@ -646,6 +650,111 @@ "rh_open_artifact": "打开截图", "rh_no_artifact": "所选记录没有截图。", "rh_artifact_missing": "截图文件已不存在。", + "rh_timeline_heading": "时间轴(左旧右新)", + "rh_preview_heading": "预览", + "rh_preview_empty": "请选择一条记录预览。", + "rh_preview_no_artifact": "此次执行没有截图。", + + # Email triggers tab + "eml_engine_group": "轮询引擎", + "eml_add_group": "新增 IMAP 触发", + "eml_start": "开始轮询", + "eml_stop": "停止轮询", + "eml_poll_now": "立即轮询", + "eml_poll_done": "本次共触发 {n} 封邮件。", + "eml_running": "轮询中。", + "eml_stopped": "轮询已停止。", + "eml_host_label": "主机:", + "eml_port_label": "端口:", + "eml_user_label": "用户:", + "eml_password_label": "密码:", + "eml_password_placeholder": "IMAP 密码 / 应用密码", + "eml_mailbox_label": "邮箱:", + "eml_search_label": "搜索条件:", + "eml_poll_label": "轮询(秒):", + "eml_script_label": "脚本:", + "eml_browse": "浏览", + "eml_register": "注册触发", + "eml_remove": "删除所选", + "eml_ssl": "使用 SSL", + "eml_mark_seen": "触发后标记为已读", + "eml_required_fields": "请填入主机、用户、密码和脚本。", + "eml_col_id": "ID", + "eml_col_host": "主机", + "eml_col_user": "用户", + "eml_col_mailbox": "邮箱", + "eml_col_script": "脚本", + "eml_col_fired": "触发次数", + "eml_col_error": "最近错误", + + # Webhooks tab + "wh_server_group": "HTTP 服务器", + "wh_add_group": "新增 webhook", + "wh_host_label": "主机:", + "wh_port_label": "端口:", + "wh_start": "启动", + "wh_stop": "停止", + "wh_started": "正在监听 {host}:{port}", + "wh_running": "运行中 {host}:{port}", + "wh_stopped": "服务器已停止。", + "wh_path_label": "路径:", + "wh_script_label": "脚本:", + "wh_browse": "浏览", + "wh_methods_label": "方法:", + "wh_token_label": "Token:", + "wh_token_placeholder": "可选 Bearer token", + "wh_register": "注册 webhook", + "wh_remove": "删除所选", + "wh_path_and_script_required": "请输入路径和脚本文件。", + "wh_col_id": "ID", + "wh_col_path": "路径", + "wh_col_methods": "方法", + "wh_col_script": "脚本", + "wh_col_fired": "触发次数", + "wh_col_token": "需验证?", + "wh_yes": "是", + "wh_no": "否", + + # Secrets tab + "secret_unlock_group": "密钥库", + "secret_manage_group": "密钥", + "secret_passphrase_label": "通行码:", + "secret_passphrase_placeholder": "密钥库通行码", + "secret_init": "创建密钥库", + "secret_init_done": "密钥库已创建并解锁。", + "secret_unlock": "解锁", + "secret_lock": "锁定", + "secret_add": "添加密钥", + "secret_remove": "删除", + "secret_change_passphrase": "修改通行码", + "secret_change_done": "密钥库已使用新通行码重新加密。", + "secret_status_uninitialized": "尚未创建密钥库。", + "secret_status_unlocked": "密钥库已解锁。", + "secret_status_locked": "密钥库已锁定。", + "secret_passphrase_required": "请先输入通行码。", + "secret_wrong_passphrase": "通行码错误。", + "secret_unlock_first": "请先解锁密钥库再管理密钥。", + "secret_name_prompt": "密钥名称(脚本以 ${secrets.NAME} 引用):", + "secret_value_prompt": "密钥内容:", + "secret_old_passphrase_prompt": "当前通行码:", + "secret_new_passphrase_prompt": "新通行码:", + + # Profiler tab + "prof_enable": "启用性能分析", + "prof_disable": "停用性能分析", + "prof_reset": "清除统计", + "prof_refresh": "刷新", + "prof_running": "性能分析中。", + "prof_paused": "尚未启用性能分析。", + "prof_total_label": "已追踪 {n} 个动作", + "prof_total_empty": "暂无数据。", + "prof_col_name": "动作", + "prof_col_calls": "次数", + "prof_col_total": "总时间", + "prof_col_avg": "平均", + "prof_col_min": "最短", + "prof_col_max": "最长", + "prof_col_share": "占比", # Accessibility Tab "a11y_app_label": "应用:", diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index 327e06e6..bbae7eaf 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -16,15 +16,19 @@ "tab_live_hud": "即時監看", "tab_hotkeys": "全域熱鍵", "tab_triggers": "事件觸發器", + "tab_webhooks": "Webhook 觸發", + "tab_email_triggers": "Email 觸發", "tab_plugins": "外掛", "tab_screen_record": "螢幕錄影", "tab_shell": "Shell 命令", "tab_report": "報告產生", "tab_run_history": "執行紀錄", + "tab_profiler": "效能分析", "tab_accessibility": "無障礙樹", "tab_vlm": "AI 定位", "tab_ocr_reader": "OCR 讀取", "tab_variables": "執行期變數", + "tab_secrets": "密鑰管理", "tab_llm_planner": "LLM 腳本規劃", "tab_remote_desktop": "遠端桌面", "tab_rest_api": "REST API", @@ -647,6 +651,111 @@ "rh_open_artifact": "開啟截圖", "rh_no_artifact": "所選紀錄沒有截圖。", "rh_artifact_missing": "截圖檔案已不存在。", + "rh_timeline_heading": "時間軸(左舊右新)", + "rh_preview_heading": "預覽", + "rh_preview_empty": "請選擇一筆紀錄以預覽。", + "rh_preview_no_artifact": "此次執行沒有截圖。", + + # Email triggers tab + "eml_engine_group": "輪詢引擎", + "eml_add_group": "新增 IMAP 觸發", + "eml_start": "開始輪詢", + "eml_stop": "停止輪詢", + "eml_poll_now": "立即輪詢", + "eml_poll_done": "本次共觸發 {n} 封郵件。", + "eml_running": "輪詢中。", + "eml_stopped": "輪詢已停止。", + "eml_host_label": "主機:", + "eml_port_label": "埠號:", + "eml_user_label": "使用者:", + "eml_password_label": "密碼:", + "eml_password_placeholder": "IMAP 密碼 / 應用程式密碼", + "eml_mailbox_label": "信箱:", + "eml_search_label": "搜尋條件:", + "eml_poll_label": "輪詢(秒):", + "eml_script_label": "腳本:", + "eml_browse": "瀏覽", + "eml_register": "註冊觸發", + "eml_remove": "移除所選", + "eml_ssl": "使用 SSL", + "eml_mark_seen": "觸發後標為已讀", + "eml_required_fields": "請填入主機、使用者、密碼與腳本。", + "eml_col_id": "ID", + "eml_col_host": "主機", + "eml_col_user": "使用者", + "eml_col_mailbox": "信箱", + "eml_col_script": "腳本", + "eml_col_fired": "觸發次數", + "eml_col_error": "最近錯誤", + + # Webhooks tab + "wh_server_group": "HTTP 伺服器", + "wh_add_group": "新增 webhook", + "wh_host_label": "主機:", + "wh_port_label": "埠號:", + "wh_start": "啟動", + "wh_stop": "停止", + "wh_started": "正在監聽 {host}:{port}", + "wh_running": "運行中 {host}:{port}", + "wh_stopped": "伺服器已停止。", + "wh_path_label": "路徑:", + "wh_script_label": "腳本:", + "wh_browse": "瀏覽", + "wh_methods_label": "方法:", + "wh_token_label": "Token:", + "wh_token_placeholder": "可選的 Bearer token", + "wh_register": "註冊 webhook", + "wh_remove": "移除所選", + "wh_path_and_script_required": "請輸入路徑與腳本檔案。", + "wh_col_id": "ID", + "wh_col_path": "路徑", + "wh_col_methods": "方法", + "wh_col_script": "腳本", + "wh_col_fired": "觸發次數", + "wh_col_token": "需驗證?", + "wh_yes": "是", + "wh_no": "否", + + # Secrets tab + "secret_unlock_group": "密鑰庫", + "secret_manage_group": "密鑰", + "secret_passphrase_label": "通行碼:", + "secret_passphrase_placeholder": "密鑰庫通行碼", + "secret_init": "建立密鑰庫", + "secret_init_done": "密鑰庫已建立並解鎖。", + "secret_unlock": "解鎖", + "secret_lock": "鎖定", + "secret_add": "新增密鑰", + "secret_remove": "移除", + "secret_change_passphrase": "變更通行碼", + "secret_change_done": "密鑰庫已用新通行碼重新加密。", + "secret_status_uninitialized": "尚未建立密鑰庫。", + "secret_status_unlocked": "密鑰庫已解鎖。", + "secret_status_locked": "密鑰庫已鎖定。", + "secret_passphrase_required": "請先輸入通行碼。", + "secret_wrong_passphrase": "通行碼錯誤。", + "secret_unlock_first": "請先解鎖密鑰庫再管理密鑰。", + "secret_name_prompt": "密鑰名稱(在腳本以 ${secrets.NAME} 引用):", + "secret_value_prompt": "密鑰內容:", + "secret_old_passphrase_prompt": "目前通行碼:", + "secret_new_passphrase_prompt": "新通行碼:", + + # Profiler tab + "prof_enable": "啟用效能分析", + "prof_disable": "停用效能分析", + "prof_reset": "清除統計", + "prof_refresh": "重新整理", + "prof_running": "效能分析中。", + "prof_paused": "尚未啟用效能分析。", + "prof_total_label": "已追蹤 {n} 個動作", + "prof_total_empty": "尚無資料。", + "prof_col_name": "動作", + "prof_col_calls": "次數", + "prof_col_total": "總時間", + "prof_col_avg": "平均", + "prof_col_min": "最短", + "prof_col_max": "最長", + "prof_col_share": "佔比", # Accessibility Tab "a11y_app_label": "應用程式:", diff --git a/je_auto_control/gui/main_widget.py b/je_auto_control/gui/main_widget.py index b29a89a4..9aefd6e8 100644 --- a/je_auto_control/gui/main_widget.py +++ b/je_auto_control/gui/main_widget.py @@ -1,5 +1,6 @@ import json from dataclasses import dataclass +from typing import Optional from PySide6.QtCore import QTimer, Signal, QObject from PySide6.QtGui import QIntValidator, QDoubleValidator, QKeyEvent, Qt @@ -19,6 +20,8 @@ from je_auto_control.gui.llm_planner_tab import LLMPlannerTab from je_auto_control.gui.ocr_tab import OCRReaderTab from je_auto_control.gui.plugins_tab import PluginsTab +from je_auto_control.gui.profiler_tab import ProfilerTab +from je_auto_control.gui.secrets_tab import SecretsTab from je_auto_control.gui.admin_console_tab import AdminConsoleTab from je_auto_control.gui.audit_log_tab import AuditLogTab from je_auto_control.gui.diagnostics_tab import DiagnosticsTab @@ -26,13 +29,24 @@ from je_auto_control.gui.recording_editor_tab import RecordingEditorTab from je_auto_control.gui.usb_browser_tab import UsbBrowserTab from je_auto_control.gui.usb_devices_tab import UsbDevicesTab -from je_auto_control.gui.remote_desktop_tab import RemoteDesktopTab +# Remote desktop relies on the optional `webrtc` extra (aiortc + PyAV). +# Importing it eagerly would break embedders (e.g. PyBreeze) that install +# je_auto_control without the extra; fall back to a placeholder tab that +# tells the user how to enable it. +try: + from je_auto_control.gui.remote_desktop_tab import RemoteDesktopTab + _REMOTE_DESKTOP_IMPORT_ERROR: Optional[ImportError] = None +except ImportError as _remote_desktop_error: + RemoteDesktopTab = None # type: ignore[assignment] + _REMOTE_DESKTOP_IMPORT_ERROR = _remote_desktop_error from je_auto_control.gui.rest_api_tab import RestApiTab from je_auto_control.gui.run_history_tab import RunHistoryTab from je_auto_control.gui.scheduler_tab import SchedulerTab from je_auto_control.gui.script_builder import ScriptBuilderTab from je_auto_control.gui.selector import crop_template_to_file, open_region_selector from je_auto_control.gui.triggers_tab import TriggersTab +from je_auto_control.gui.webhooks_tab import WebhooksTab +from je_auto_control.gui.email_triggers_tab import EmailTriggersTab from je_auto_control.gui.variables_tab import VariablesTab from je_auto_control.gui.vlm_tab import VLMTab from je_auto_control.gui.window_tab import WindowManagerTab @@ -87,12 +101,17 @@ def __init__(self, parent=None): self.tabs.setTabsClosable(True) self.tabs.tabCloseRequested.connect(self._on_tab_close_requested) + # Default UI keeps only the last three of the previously-visible + # tabs (record / script_builder / remote_desktop) so the launcher + # opens on a focused capture+script+remote workflow. The earlier + # core tabs (auto_click / screenshot / image_detect) are still + # registered and reachable from the View menu's "show tab" list. self._add_tab("auto_click", "tab_auto_click", self._build_auto_click_tab(), - category="core", default_visible=True) + category="core") self._add_tab("screenshot", "tab_screenshot", self._build_screenshot_tab(), - category="core", default_visible=True) + category="core") self._add_tab("image_detect", "tab_image_detect", self._build_image_detect_tab(), - category="core", default_visible=True) + category="core") self._add_tab("record", "tab_record", self._build_record_tab(), category="core", default_visible=True) self._add_tab("script_builder", "tab_script_builder", ScriptBuilderTab(), @@ -103,6 +122,8 @@ def __init__(self, parent=None): category="editing") self._add_tab("variables", "tab_variables", VariablesTab(), category="editing") + self._add_tab("secrets", "tab_secrets", SecretsTab(), + category="editing") self._add_tab("vlm", "tab_vlm", VLMTab(), category="detection") self._add_tab("ocr_reader", "tab_ocr_reader", OCRReaderTab(), @@ -119,14 +140,23 @@ def __init__(self, parent=None): category="automation") self._add_tab("triggers", "tab_triggers", TriggersTab(), category="automation") + self._add_tab("webhooks", "tab_webhooks", WebhooksTab(), + category="automation") + self._add_tab("email_triggers", "tab_email_triggers", + EmailTriggersTab(), category="automation") self._add_tab("run_history", "tab_run_history", RunHistoryTab(), category="automation") + self._add_tab("profiler", "tab_profiler", ProfilerTab(), + category="automation") self._add_tab("window_manager", "tab_window_manager", WindowManagerTab(), category="system") self._add_tab("plugins", "tab_plugins", PluginsTab(), category="system") - self._add_tab("remote_desktop", "tab_remote_desktop", RemoteDesktopTab(), - category="system", default_visible=True) + self._add_tab( + "remote_desktop", "tab_remote_desktop", + self._build_remote_desktop_tab(), + category="system", default_visible=True, + ) self._add_tab("rest_api", "tab_rest_api", RestApiTab(), category="system") self._add_tab("admin_console", "tab_admin_console", AdminConsoleTab(), @@ -152,6 +182,26 @@ def __init__(self, parent=None): self.repeat_max = 0 self._record_data = [] + @staticmethod + def _build_remote_desktop_tab() -> QWidget: + """Return the real remote-desktop tab, or a placeholder if the + ``webrtc`` extra is not installed.""" + if RemoteDesktopTab is not None: + return RemoteDesktopTab() + placeholder = QWidget() + layout = QVBoxLayout(placeholder) + message = QLabel( + "Remote Desktop is unavailable: the optional 'webrtc' extra " + "(aiortc + PyAV) is not installed.\n\n" + "Install with:\n pip install je_auto_control[webrtc]\n\n" + f"Underlying error: {_REMOTE_DESKTOP_IMPORT_ERROR!r}", + ) + message.setWordWrap(True) + message.setTextInteractionFlags(Qt.TextSelectableByMouse) + layout.addWidget(message) + layout.addStretch() + return placeholder + # --- tab registry API ---------------------------------------------------- def _add_tab( diff --git a/je_auto_control/gui/profiler_tab.py b/je_auto_control/gui/profiler_tab.py new file mode 100644 index 00000000..853f41f7 --- /dev/null +++ b/je_auto_control/gui/profiler_tab.py @@ -0,0 +1,136 @@ +"""Profiler tab: visualise per-action wall-clock hot spots.""" +from typing import List, Optional + +from PySide6.QtCore import QTimer, Qt +from PySide6.QtWidgets import ( + QAbstractItemView, QHBoxLayout, QHeaderView, QLabel, QProgressBar, + QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +from je_auto_control.utils.profiler import default_profiler +from je_auto_control.utils.profiler.profiler import ActionStats + +_REFRESH_INTERVAL_MS = 1000 +_COLUMN_COUNT = 7 + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +def _ms(seconds: float) -> str: + if seconds <= 0: + return "0 ms" + if seconds < 1.0: + return f"{seconds * 1000:.1f} ms" + return f"{seconds:.3f} s" + + +class ProfilerTab(TranslatableMixin, QWidget): + """Hot-spot table backed by :data:`default_profiler`.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._table = QTableWidget(0, _COLUMN_COUNT) + self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._table.setSelectionBehavior(QAbstractItemView.SelectRows) + self._table.verticalHeader().setVisible(False) + self._apply_table_headers() + header = self._table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + self._totalbar = QProgressBar() + self._totalbar.setRange(0, 100) + self._totalbar.setTextVisible(True) + self._status = QLabel() + self._build_layout() + self._timer = QTimer(self) + self._timer.setInterval(_REFRESH_INTERVAL_MS) + self._timer.timeout.connect(self._refresh) + self._timer.start() + self._refresh() + + def retranslate(self) -> None: + TranslatableMixin.retranslate(self) + self._apply_table_headers() + self._refresh() + + def _apply_table_headers(self) -> None: + self._table.setHorizontalHeaderLabels([ + _t("prof_col_name"), _t("prof_col_calls"), + _t("prof_col_total"), _t("prof_col_avg"), + _t("prof_col_min"), _t("prof_col_max"), + _t("prof_col_share"), + ]) + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + controls = QHBoxLayout() + self._enable_btn = self._tr(QPushButton(), "prof_enable") + self._enable_btn.clicked.connect(self._toggle_enable) + controls.addWidget(self._enable_btn) + reset_btn = self._tr(QPushButton(), "prof_reset") + reset_btn.clicked.connect(self._on_reset) + controls.addWidget(reset_btn) + refresh_btn = self._tr(QPushButton(), "prof_refresh") + refresh_btn.clicked.connect(self._refresh) + controls.addWidget(refresh_btn) + controls.addStretch() + root.addLayout(controls) + root.addWidget(self._table, stretch=1) + root.addWidget(self._totalbar) + root.addWidget(self._status) + + def _toggle_enable(self) -> None: + if default_profiler.enabled: + default_profiler.disable() + else: + default_profiler.enable() + self._refresh() + + def _on_reset(self) -> None: + default_profiler.reset() + self._refresh() + + def _refresh(self) -> None: + rows: List[ActionStats] = default_profiler.stats() + self._table.setRowCount(len(rows)) + total_seconds = sum(r.total_seconds for r in rows) + for index, row in enumerate(rows): + share = 0.0 if total_seconds <= 0 else row.total_seconds / total_seconds + self._set_row(index, row, share) + self._totalbar.setValue(min(100, int(total_seconds * 1000) % 101)) + if rows: + self._totalbar.setFormat( + _t("prof_total_label").replace("{n}", str(len(rows))) + + f" • {_ms(total_seconds)}", + ) + else: + self._totalbar.setFormat(_t("prof_total_empty")) + running_text = _t("prof_running") if default_profiler.enabled \ + else _t("prof_paused") + self._status.setText(running_text) + self._enable_btn.setText( + _t("prof_disable") if default_profiler.enabled + else _t("prof_enable"), + ) + + def _set_row(self, row: int, stats: ActionStats, share: float) -> None: + values = ( + stats.name, + str(stats.calls), + _ms(stats.total_seconds), + _ms(stats.average_seconds), + _ms(stats.min_seconds), + _ms(stats.max_seconds), + f"{share * 100:.1f}%", + ) + for col, text in enumerate(values): + item = QTableWidgetItem(text) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self._table.setItem(row, col, item) diff --git a/je_auto_control/gui/run_history_tab.py b/je_auto_control/gui/run_history_tab.py index aec57621..baae3d75 100644 --- a/je_auto_control/gui/run_history_tab.py +++ b/je_auto_control/gui/run_history_tab.py @@ -1,13 +1,13 @@ """Run History tab: browse past scheduler / trigger / hotkey fires.""" import datetime as _dt from pathlib import Path -from typing import Optional +from typing import List, Optional from PySide6.QtCore import QTimer, Qt, QUrl -from PySide6.QtGui import QDesktopServices +from PySide6.QtGui import QDesktopServices, QPixmap from PySide6.QtWidgets import ( - QAbstractItemView, QComboBox, QHBoxLayout, QHeaderView, QLabel, - QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, + QAbstractItemView, QComboBox, QFrame, QHBoxLayout, QHeaderView, QLabel, + QMessageBox, QPushButton, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -15,9 +15,10 @@ from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( language_wrapper, ) +from je_auto_control.gui.run_history_timeline import RunHistoryTimeline from je_auto_control.utils.run_history.history_store import ( SOURCE_HOTKEY, SOURCE_MANUAL, SOURCE_REST, SOURCE_SCHEDULER, - SOURCE_TRIGGER, STATUS_ERROR, STATUS_OK, STATUS_RUNNING, + SOURCE_TRIGGER, STATUS_ERROR, STATUS_OK, STATUS_RUNNING, RunRecord, default_history_store, ) @@ -63,6 +64,7 @@ class RunHistoryTab(TranslatableMixin, QWidget): def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._tr_init() + self._records: List[RunRecord] = [] self._filter = QComboBox() self._populate_filter() self._filter.currentIndexChanged.connect(self._refresh) @@ -75,6 +77,17 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: header.setSectionResizeMode(QHeaderView.Interactive) header.setStretchLastSection(True) self._count_label = QLabel() + self._timeline = RunHistoryTimeline() + self._timeline.run_clicked.connect(self._on_timeline_clicked) + self._thumb_label = QLabel() + self._thumb_label.setAlignment(Qt.AlignCenter) + self._thumb_label.setMinimumSize(220, 160) + self._thumb_label.setFrameShape(QFrame.StyledPanel) + self._thumb_label.setScaledContents(False) + self._thumb_caption = QLabel() + self._thumb_caption.setWordWrap(True) + self._thumb_caption.setAlignment(Qt.AlignTop) + self._thumb_caption.setTextInteractionFlags(Qt.TextSelectableByMouse) self._timer = QTimer(self) self._timer.setInterval(_REFRESH_INTERVAL_MS) self._timer.timeout.connect(self._refresh) @@ -89,6 +102,10 @@ def retranslate(self) -> None: self._repopulate_filter_labels() self._refresh() + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._refresh_preview() + def _populate_filter(self) -> None: self._filter.blockSignals(True) for label_key, source_value in _SOURCES: @@ -124,8 +141,27 @@ def _build_layout(self) -> None: clear_btn.clicked.connect(self._on_clear) top.addWidget(clear_btn) root.addLayout(top) - root.addWidget(self._table, stretch=1) + + self._tr(QLabel(), "rh_timeline_heading") + timeline_label = self._tr(QLabel(), "rh_timeline_heading") + root.addWidget(timeline_label) + root.addWidget(self._timeline) + + splitter = QSplitter(Qt.Horizontal) + splitter.addWidget(self._table) + preview = QWidget() + preview_layout = QVBoxLayout(preview) + preview_layout.setContentsMargins(0, 0, 0, 0) + preview_layout.addWidget(self._tr(QLabel(), "rh_preview_heading")) + preview_layout.addWidget(self._thumb_label, stretch=1) + preview_layout.addWidget(self._thumb_caption) + splitter.addWidget(preview) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 1) + root.addWidget(splitter, stretch=1) + self._table.cellDoubleClicked.connect(self._on_cell_double_clicked) + self._table.itemSelectionChanged.connect(self._on_selection_changed) open_row = QHBoxLayout() self._open_artifact_btn = self._tr(QPushButton(), "rh_open_artifact") self._open_artifact_btn.clicked.connect(self._open_selected_artifact) @@ -149,12 +185,15 @@ def _refresh(self) -> None: runs = default_history_store.list_runs(limit=500, source_type=source) except ValueError: runs = [] + self._records = runs self._table.setRowCount(len(runs)) for row, record in enumerate(runs): self._set_row(row, record) self._count_label.setText( _t("rh_count_label").replace("{n}", str(len(runs))), ) + self._timeline.set_records(runs) + self._refresh_preview() def _set_row(self, row: int, record) -> None: status_key = _STATUS_LABEL_KEYS.get(record.status, record.status) @@ -176,6 +215,54 @@ def _set_row(self, row: int, record) -> None: item.setFlags(item.flags() & ~Qt.ItemIsEditable) self._table.setItem(row, col, item) + def _selected_record(self) -> Optional[RunRecord]: + row = self._table.currentRow() + if row < 0 or row >= len(self._records): + return None + return self._records[row] + + def _on_selection_changed(self) -> None: + record = self._selected_record() + self._timeline.set_highlighted(record.id if record is not None else None) + self._refresh_preview() + + def _on_timeline_clicked(self, run_id: int) -> None: + for row, record in enumerate(self._records): + if record.id == run_id: + self._table.selectRow(row) + return + + def _refresh_preview(self) -> None: + record = self._selected_record() + if record is None: + self._thumb_label.clear() + self._thumb_label.setText(_t("rh_preview_empty")) + self._thumb_caption.setText("") + return + path = record.artifact_path + if not path: + self._thumb_label.clear() + self._thumb_label.setText(_t("rh_preview_no_artifact")) + else: + pixmap = QPixmap(path) + if pixmap.isNull(): + self._thumb_label.clear() + self._thumb_label.setText(_t("rh_artifact_missing")) + else: + self._thumb_label.setPixmap(pixmap.scaled( + self._thumb_label.size(), Qt.KeepAspectRatio, + Qt.SmoothTransformation, + )) + caption = ( + f"#{record.id} • {record.source_type}/{record.source_id}\n" + f"{record.script_path}\n" + f"{_format_time(record.started_at)} • " + f"{_format_duration(record.duration_seconds)}" + ) + if record.error_text: + caption += f"\n{record.error_text}" + self._thumb_caption.setText(caption) + def _selected_artifact_path(self) -> Optional[str]: row = self._table.currentRow() if row < 0: diff --git a/je_auto_control/gui/run_history_timeline.py b/je_auto_control/gui/run_history_timeline.py new file mode 100644 index 00000000..485713e2 --- /dev/null +++ b/je_auto_control/gui/run_history_timeline.py @@ -0,0 +1,159 @@ +"""Custom timeline widget for the Run History tab. + +Renders one colored bar per run on a horizontal time axis (newest on the +right). Status drives the bar colour (green = ok, red = error, amber = +still running). Clicking a bar emits ``run_clicked`` so the host tab can +sync the row selection / thumbnail preview. + +Pure :mod:`PySide6` — the headless run history store has zero Qt deps. +""" +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple + +from PySide6.QtCore import QRectF, Qt, Signal +from PySide6.QtGui import QColor, QFont, QFontMetrics, QMouseEvent, QPainter +from PySide6.QtWidgets import QSizePolicy, QWidget + +from je_auto_control.utils.run_history.history_store import ( + STATUS_ERROR, STATUS_OK, STATUS_RUNNING, RunRecord, +) + +_STATUS_COLOURS = { + STATUS_OK: QColor("#4caf50"), + STATUS_ERROR: QColor("#e53935"), + STATUS_RUNNING: QColor("#ffb300"), +} +_DEFAULT_COLOUR = QColor("#9e9e9e") +_GUTTER = 6 +_MIN_BAR_PX = 4 +_BAR_HEIGHT_FRACTION = 0.55 + + +@dataclass +class _Bar: + record: RunRecord + rect: QRectF + + +class RunHistoryTimeline(QWidget): + """Horizontal Gantt-style strip of run records.""" + + run_clicked = Signal(int) + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setMinimumHeight(96) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self._records: List[RunRecord] = [] + self._bars: List[_Bar] = [] + self._range: Tuple[float, float] = (0.0, 0.0) + self._highlight_id: Optional[int] = None + self.setMouseTracking(True) + + def set_records(self, records: Sequence[RunRecord]) -> None: + """Replace the displayed records and trigger a repaint.""" + self._records = list(records) + self._range = self._compute_range(self._records) + self.update() + + def set_highlighted(self, run_id: Optional[int]) -> None: + """Visually mark a single run id (called from external selection).""" + self._highlight_id = run_id + self.update() + + def paintEvent(self, event) -> None: + del event + painter = QPainter(self) + try: + painter.setRenderHint(QPainter.Antialiasing, True) + painter.fillRect(self.rect(), self.palette().window()) + self._bars = self._layout_bars() + self._draw_axis(painter) + for bar in self._bars: + self._draw_bar(painter, bar) + finally: + painter.end() + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() != Qt.LeftButton: + super().mousePressEvent(event) + return + for bar in self._bars: + if bar.rect.contains(event.position()): + self._highlight_id = bar.record.id + self.update() + self.run_clicked.emit(bar.record.id) + return + super().mousePressEvent(event) + + @staticmethod + def _compute_range(records: Sequence[RunRecord]) -> Tuple[float, float]: + if not records: + return (0.0, 0.0) + starts = [r.started_at for r in records] + ends = [] + for r in records: + if r.finished_at is not None: + ends.append(r.finished_at) + else: + ends.append(r.started_at + 0.001) + lo = min(starts) + hi = max(ends) + if hi <= lo: + hi = lo + 1.0 + return (lo, hi) + + def _layout_bars(self) -> List[_Bar]: + if not self._records: + return [] + lo, hi = self._range + span = max(hi - lo, 1e-6) + usable_w = max(1, self.width() - 2 * _GUTTER) + bar_height = max(8, int(self.height() * _BAR_HEIGHT_FRACTION)) + y = (self.height() - bar_height) // 2 + bars: List[_Bar] = [] + for record in self._records: + start_frac = (record.started_at - lo) / span + end_at = record.finished_at if record.finished_at is not None \ + else min(hi, record.started_at + 0.001) + end_frac = (end_at - lo) / span + x = _GUTTER + int(start_frac * usable_w) + width = max(_MIN_BAR_PX, int((end_frac - start_frac) * usable_w)) + rect = QRectF(x, y, width, bar_height) + bars.append(_Bar(record=record, rect=rect)) + return bars + + def _draw_axis(self, painter: QPainter) -> None: + if not self._records: + painter.setPen(self.palette().text().color()) + painter.drawText(self.rect(), Qt.AlignCenter, "no runs yet") + return + font = QFont(painter.font()) + font.setPointSize(max(7, font.pointSize() - 1)) + painter.setFont(font) + metrics = QFontMetrics(font) + lo, hi = self._range + baseline_y = self.height() - max(2, metrics.descent() + 2) + painter.setPen(QColor(120, 120, 120, 160)) + painter.drawLine(_GUTTER, baseline_y, + self.width() - _GUTTER, baseline_y) + painter.setPen(self.palette().text().color()) + from datetime import datetime + try: + left_label = datetime.fromtimestamp(lo).strftime("%H:%M:%S") + right_label = datetime.fromtimestamp(hi).strftime("%H:%M:%S") + except (OSError, ValueError, OverflowError): + left_label, right_label = str(lo), str(hi) + painter.drawText(_GUTTER, baseline_y - 2, left_label) + right_w = metrics.horizontalAdvance(right_label) + painter.drawText(self.width() - _GUTTER - right_w, + baseline_y - 2, right_label) + + def _draw_bar(self, painter: QPainter, bar: _Bar) -> None: + colour = QColor(_STATUS_COLOURS.get(bar.record.status, _DEFAULT_COLOUR)) + if bar.record.id == self._highlight_id: + painter.setPen(QColor(255, 255, 255, 220)) + else: + painter.setPen(Qt.NoPen) + painter.setBrush(colour) + painter.drawRoundedRect(bar.rect, 3, 3) diff --git a/je_auto_control/gui/secrets_tab.py b/je_auto_control/gui/secrets_tab.py new file mode 100644 index 00000000..aae1362a --- /dev/null +++ b/je_auto_control/gui/secrets_tab.py @@ -0,0 +1,194 @@ +"""Secrets tab: unlock the vault and manage ${secrets.NAME} entries.""" +from typing import Optional + +from PySide6.QtWidgets import ( + QAbstractItemView, QGroupBox, QHBoxLayout, QInputDialog, QLabel, + QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, + QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +from je_auto_control.utils.secrets import ( + SecretStoreError, SecretStoreLocked, default_secret_manager, +) + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +class SecretsTab(TranslatableMixin, QWidget): + """Manage the encrypted secret vault used by ``${secrets.NAME}``.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._status_label = QLabel() + self._passphrase = QLineEdit() + self._passphrase.setEchoMode(QLineEdit.Password) + self._list = QListWidget() + self._list.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._build_layout() + self._refresh_status() + + def retranslate(self) -> None: + TranslatableMixin.retranslate(self) + self._refresh_status() + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + unlock_box = self._tr(QGroupBox(), "secret_unlock_group") + unlock_layout = QHBoxLayout(unlock_box) + unlock_layout.addWidget(self._tr(QLabel(), "secret_passphrase_label")) + self._passphrase.setPlaceholderText(_t("secret_passphrase_placeholder")) + unlock_layout.addWidget(self._passphrase) + init_btn = self._tr(QPushButton(), "secret_init") + init_btn.clicked.connect(self._on_init) + unlock_layout.addWidget(init_btn) + unlock_btn = self._tr(QPushButton(), "secret_unlock") + unlock_btn.clicked.connect(self._on_unlock) + unlock_layout.addWidget(unlock_btn) + lock_btn = self._tr(QPushButton(), "secret_lock") + lock_btn.clicked.connect(self._on_lock) + unlock_layout.addWidget(lock_btn) + root.addWidget(unlock_box) + + manage_box = self._tr(QGroupBox(), "secret_manage_group") + manage_layout = QVBoxLayout(manage_box) + manage_layout.addWidget(self._list) + button_row = QHBoxLayout() + add_btn = self._tr(QPushButton(), "secret_add") + add_btn.clicked.connect(self._on_add) + button_row.addWidget(add_btn) + remove_btn = self._tr(QPushButton(), "secret_remove") + remove_btn.clicked.connect(self._on_remove) + button_row.addWidget(remove_btn) + change_btn = self._tr(QPushButton(), "secret_change_passphrase") + change_btn.clicked.connect(self._on_change_passphrase) + button_row.addWidget(change_btn) + button_row.addStretch() + manage_layout.addLayout(button_row) + root.addWidget(manage_box, stretch=1) + + root.addWidget(self._status_label) + + def _refresh_status(self) -> None: + manager = default_secret_manager + if not manager.is_initialized: + self._status_label.setText(_t("secret_status_uninitialized")) + elif manager.is_unlocked: + self._status_label.setText(_t("secret_status_unlocked")) + else: + self._status_label.setText(_t("secret_status_locked")) + self._refresh_list() + + def _refresh_list(self) -> None: + self._list.clear() + if not default_secret_manager.is_unlocked: + return + try: + names = default_secret_manager.list_names() + except SecretStoreError: + names = [] + for name in names: + self._list.addItem(QListWidgetItem(name)) + + def _consume_passphrase(self) -> str: + text = self._passphrase.text() + self._passphrase.clear() + return text + + def _on_init(self) -> None: + passphrase = self._consume_passphrase() + if not passphrase: + QMessageBox.warning(self, _t("secret_init"), + _t("secret_passphrase_required")) + return + try: + default_secret_manager.initialize(passphrase) + except SecretStoreError as error: + QMessageBox.warning(self, _t("secret_init"), str(error)) + return + QMessageBox.information(self, _t("secret_init"), + _t("secret_init_done")) + self._refresh_status() + + def _on_unlock(self) -> None: + passphrase = self._consume_passphrase() + if not passphrase: + return + try: + ok = default_secret_manager.unlock(passphrase) + except SecretStoreError as error: + QMessageBox.warning(self, _t("secret_unlock"), str(error)) + return + if not ok: + QMessageBox.warning(self, _t("secret_unlock"), + _t("secret_wrong_passphrase")) + self._refresh_status() + + def _on_lock(self) -> None: + default_secret_manager.lock() + self._refresh_status() + + def _on_add(self) -> None: + if not default_secret_manager.is_unlocked: + QMessageBox.information(self, _t("secret_add"), + _t("secret_unlock_first")) + return + name, ok = QInputDialog.getText( + self, _t("secret_add"), _t("secret_name_prompt"), + ) + if not ok or not name.strip(): + return + value, ok = QInputDialog.getText( + self, _t("secret_add"), _t("secret_value_prompt"), + QLineEdit.Password, + ) + if not ok: + return + try: + default_secret_manager.set(name.strip(), value) + except (SecretStoreError, SecretStoreLocked, ValueError) as error: + QMessageBox.warning(self, _t("secret_add"), str(error)) + return + self._refresh_list() + + def _on_remove(self) -> None: + item = self._list.currentItem() + if item is None: + return + try: + default_secret_manager.remove(item.text()) + except (SecretStoreError, SecretStoreLocked) as error: + QMessageBox.warning(self, _t("secret_remove"), str(error)) + return + self._refresh_list() + + def _on_change_passphrase(self) -> None: + old, ok = QInputDialog.getText( + self, _t("secret_change_passphrase"), + _t("secret_old_passphrase_prompt"), QLineEdit.Password, + ) + if not ok: + return + new, ok = QInputDialog.getText( + self, _t("secret_change_passphrase"), + _t("secret_new_passphrase_prompt"), QLineEdit.Password, + ) + if not ok or not new: + return + try: + default_secret_manager.change_passphrase(old, new) + except (SecretStoreError, ValueError) as error: + QMessageBox.warning(self, _t("secret_change_passphrase"), + str(error)) + return + QMessageBox.information( + self, _t("secret_change_passphrase"), + _t("secret_change_done"), + ) + self._refresh_status() diff --git a/je_auto_control/gui/webhooks_tab.py b/je_auto_control/gui/webhooks_tab.py new file mode 100644 index 00000000..131ff53b --- /dev/null +++ b/je_auto_control/gui/webhooks_tab.py @@ -0,0 +1,215 @@ +"""Webhooks tab: bind HTTP requests to action scripts.""" +from typing import Optional + +from PySide6.QtCore import QTimer, Qt +from PySide6.QtWidgets import ( + QAbstractItemView, QCheckBox, QFileDialog, QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QLineEdit, QMessageBox, QPushButton, QSpinBox, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +from je_auto_control.utils.triggers.webhook_server import ( + default_webhook_server, +) + + +_REFRESH_MS = 1000 +_DEFAULT_METHODS = ("POST", "GET", "PUT", "DELETE") + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +class WebhooksTab(TranslatableMixin, QWidget): + """GUI front-end for :data:`default_webhook_server`.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._host_input = QLineEdit("127.0.0.1") + self._port_input = QSpinBox() + self._port_input.setRange(0, 65535) + self._port_input.setValue(0) + self._status_label = QLabel() + self._path_input = QLineEdit() + self._path_input.setPlaceholderText("/jobs") + self._script_input = QLineEdit() + self._token_input = QLineEdit() + self._token_input.setEchoMode(QLineEdit.Password) + self._method_checks = { + method: QCheckBox(method) for method in _DEFAULT_METHODS + } + self._method_checks["POST"].setChecked(True) + self._table = QTableWidget(0, 6) + self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._table.setSelectionBehavior(QAbstractItemView.SelectRows) + self._table.verticalHeader().setVisible(False) + self._table.horizontalHeader().setSectionResizeMode( + QHeaderView.Interactive, + ) + self._table.horizontalHeader().setStretchLastSection(True) + self._apply_table_headers() + self._build_layout() + self._timer = QTimer(self) + self._timer.setInterval(_REFRESH_MS) + self._timer.timeout.connect(self._refresh) + self._timer.start() + self._refresh() + + def retranslate(self) -> None: + TranslatableMixin.retranslate(self) + self._apply_table_headers() + self._refresh() + + def _apply_table_headers(self) -> None: + self._table.setHorizontalHeaderLabels([ + _t("wh_col_id"), _t("wh_col_path"), _t("wh_col_methods"), + _t("wh_col_script"), _t("wh_col_fired"), _t("wh_col_token"), + ]) + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + + server_box = self._tr(QGroupBox(), "wh_server_group") + server_layout = QHBoxLayout(server_box) + server_layout.addWidget(self._tr(QLabel(), "wh_host_label")) + server_layout.addWidget(self._host_input) + server_layout.addWidget(self._tr(QLabel(), "wh_port_label")) + server_layout.addWidget(self._port_input) + start_btn = self._tr(QPushButton(), "wh_start") + start_btn.clicked.connect(self._on_start) + server_layout.addWidget(start_btn) + stop_btn = self._tr(QPushButton(), "wh_stop") + stop_btn.clicked.connect(self._on_stop) + server_layout.addWidget(stop_btn) + server_layout.addStretch() + root.addWidget(server_box) + root.addWidget(self._status_label) + + add_box = self._tr(QGroupBox(), "wh_add_group") + add_layout = QVBoxLayout(add_box) + path_row = QHBoxLayout() + path_row.addWidget(self._tr(QLabel(), "wh_path_label")) + path_row.addWidget(self._path_input) + add_layout.addLayout(path_row) + script_row = QHBoxLayout() + script_row.addWidget(self._tr(QLabel(), "wh_script_label")) + script_row.addWidget(self._script_input) + browse_btn = self._tr(QPushButton(), "wh_browse") + browse_btn.clicked.connect(self._on_browse) + script_row.addWidget(browse_btn) + add_layout.addLayout(script_row) + method_row = QHBoxLayout() + method_row.addWidget(self._tr(QLabel(), "wh_methods_label")) + for method, check in self._method_checks.items(): + method_row.addWidget(check) + method_row.addStretch() + add_layout.addLayout(method_row) + token_row = QHBoxLayout() + token_row.addWidget(self._tr(QLabel(), "wh_token_label")) + self._token_input.setPlaceholderText(_t("wh_token_placeholder")) + token_row.addWidget(self._token_input) + register_btn = self._tr(QPushButton(), "wh_register") + register_btn.clicked.connect(self._on_register) + token_row.addWidget(register_btn) + add_layout.addLayout(token_row) + root.addWidget(add_box) + + root.addWidget(self._table, stretch=1) + action_row = QHBoxLayout() + remove_btn = self._tr(QPushButton(), "wh_remove") + remove_btn.clicked.connect(self._on_remove) + action_row.addWidget(remove_btn) + action_row.addStretch() + root.addLayout(action_row) + + def _on_start(self) -> None: + host = self._host_input.text().strip() or "127.0.0.1" + port = int(self._port_input.value()) + try: + bound_host, bound_port = default_webhook_server.start(host, port) + except OSError as error: + QMessageBox.warning(self, _t("wh_start"), str(error)) + return + self._port_input.setValue(bound_port) + QMessageBox.information( + self, _t("wh_start"), + _t("wh_started").replace("{host}", bound_host) + .replace("{port}", str(bound_port)), + ) + self._refresh() + + def _on_stop(self) -> None: + default_webhook_server.stop() + self._refresh() + + def _on_browse(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, _t("wh_browse"), "", "JSON (*.json)", + ) + if path: + self._script_input.setText(path) + + def _selected_methods(self) -> list: + return [m for m, c in self._method_checks.items() if c.isChecked()] + + def _on_register(self) -> None: + path = self._path_input.text().strip() + script = self._script_input.text().strip() + if not path or not script: + QMessageBox.warning(self, _t("wh_register"), + _t("wh_path_and_script_required")) + return + token = self._token_input.text().strip() or None + try: + default_webhook_server.add( + path=path, script_path=script, + methods=self._selected_methods(), + token=token, + ) + except ValueError as error: + QMessageBox.warning(self, _t("wh_register"), str(error)) + return + self._token_input.clear() + self._refresh() + + def _on_remove(self) -> None: + row = self._table.currentRow() + if row < 0: + return + item = self._table.item(row, 0) + if item is None: + return + default_webhook_server.remove(item.text()) + self._refresh() + + def _refresh(self) -> None: + bound = default_webhook_server.bound_address + if default_webhook_server.is_running and bound is not None: + self._status_label.setText( + _t("wh_running") + .replace("{host}", bound[0]) + .replace("{port}", str(bound[1])), + ) + else: + self._status_label.setText(_t("wh_stopped")) + rows = default_webhook_server.list_webhooks() + self._table.setRowCount(len(rows)) + for row, trigger in enumerate(rows): + values = ( + trigger.webhook_id, + trigger.path, + ",".join(trigger.methods), + trigger.script_path, + str(trigger.fired), + _t("wh_yes") if trigger.token else _t("wh_no"), + ) + for col, text in enumerate(values): + item = QTableWidgetItem(text) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self._table.setItem(row, col, item) diff --git a/je_auto_control/linux_with_x11/uinput/__init__.py b/je_auto_control/linux_with_x11/uinput/__init__.py new file mode 100644 index 00000000..40f68770 --- /dev/null +++ b/je_auto_control/linux_with_x11/uinput/__init__.py @@ -0,0 +1,37 @@ +"""Optional kernel-level (uinput) keyboard / mouse backend for Linux. + +Why this exists alongside the X11 (XTest) backend +================================================= + +The default Linux backend in ``linux_with_x11/`` uses XTest, which +sits at the X server layer — perfectly fine for normal apps but games +that read input via ``evdev`` (most modern Linux games, especially +SDL2 / Steam ones) ignore XTest events. uinput is the kernel's +synthetic-input gateway: events emitted via ``/dev/uinput`` show up as +a brand-new HID device, indistinguishable from real hardware to +anything reading evdev. + +The driver ships with the kernel; the only requirement is that the +running user can write to ``/dev/uinput``. Either load the module +manually (``sudo modprobe uinput``) and add the user to a uinput +group, or use the dedicated ``uinput`` udev rule shown in the +``new_features`` doc. + +This package provides: + +* :mod:`._device` — :class:`ctypes` wrapper around ``/dev/uinput``. +* :mod:`.keyboard` — same public surface as + :mod:`x11_linux_keyboard_control`. +* :mod:`.mouse` — same public surface as + :mod:`x11_linux_mouse_control`. + +Set ``JE_AUTOCONTROL_LINUX_BACKEND=uinput`` to use this backend; on +permission failure the platform wrapper falls back to XTest with a +warning so deployments that haven't yet provisioned uinput access +still get a working environment. +""" +from je_auto_control.linux_with_x11.uinput._device import ( + UinputUnavailable, is_available, +) + +__all__ = ["UinputUnavailable", "is_available"] diff --git a/je_auto_control/linux_with_x11/uinput/_device.py b/je_auto_control/linux_with_x11/uinput/_device.py new file mode 100644 index 00000000..54009847 --- /dev/null +++ b/je_auto_control/linux_with_x11/uinput/_device.py @@ -0,0 +1,233 @@ +"""Tiny ``/dev/uinput`` wrapper used by the keyboard / mouse modules. + +We deliberately avoid pulling in ``python-uinput`` as a hard dep — +the API is small enough that direct ``ctypes`` + ``ioctl`` is faster +to keep in our own tree, and it removes one optional install step +for operators. This is the same approach as the existing +``windows/interception/_dll.py`` wrapper. +""" +from __future__ import annotations + +import ctypes +import errno +import os +import struct +import threading +import time +from typing import Optional + +# --- Linux uinput / input-event-codes structs ------------------------------- +# +# Layout cribbed from + . We only need +# the subset that drives a standard keyboard + relative-mouse device. + +_UINPUT_MAX_NAME_SIZE = 80 +_BUS_USB = 0x03 + +# event type codes +EV_SYN = 0x00 +EV_KEY = 0x01 +EV_REL = 0x02 +EV_ABS = 0x03 + +SYN_REPORT = 0 + +# relative axes +REL_X = 0x00 +REL_Y = 0x01 +REL_HWHEEL = 0x06 +REL_WHEEL = 0x08 + +# mouse buttons (subset) +BTN_LEFT = 0x110 +BTN_RIGHT = 0x111 +BTN_MIDDLE = 0x112 +BTN_SIDE = 0x113 # x1 +BTN_EXTRA = 0x114 # x2 + +# ioctl numbers — derived from the kernel macro layout +# _IO('U', N) for write, _IOW('U', N, int) for set-bit +_UI_DEV_CREATE = 0x5501 +_UI_DEV_DESTROY = 0x5502 +_UI_SET_EVBIT = 0x40045564 +_UI_SET_KEYBIT = 0x40045565 +_UI_SET_RELBIT = 0x40045566 + + +class _input_id(ctypes.Structure): # noqa: N801 C struct + _fields_ = [ + ("bustype", ctypes.c_uint16), + ("vendor", ctypes.c_uint16), + ("product", ctypes.c_uint16), + ("version", ctypes.c_uint16), + ] + + +class _uinput_user_dev(ctypes.Structure): # noqa: N801 C struct + _fields_ = [ + ("name", ctypes.c_char * _UINPUT_MAX_NAME_SIZE), + ("id", _input_id), + ("ff_effects_max", ctypes.c_uint32), + ("absmax", ctypes.c_int32 * 64), + ("absmin", ctypes.c_int32 * 64), + ("absfuzz", ctypes.c_int32 * 64), + ("absflat", ctypes.c_int32 * 64), + ] + + +class UinputUnavailable(RuntimeError): + """Raised when ``/dev/uinput`` can't be opened or ``ioctl`` fails.""" + + +_libc = ctypes.CDLL("libc.so.6", use_errno=True) if os.name == "posix" else None + + +def _ioctl(fd: int, request: int, arg: int = 0) -> None: + if _libc is None: + raise UinputUnavailable("libc is unavailable on this platform") + res = _libc.ioctl(ctypes.c_int(fd), ctypes.c_uint(request), + ctypes.c_int(arg)) + if res < 0: + err = ctypes.get_errno() + raise UinputUnavailable( + f"ioctl {hex(request)} failed: {os.strerror(err)} ({err})" + ) + + +def _pack_input_event(ev_type: int, code: int, value: int) -> bytes: + """Pack one ``input_event`` struct (16-byte timeval + 8-byte body). + + ``input_event`` lays out as ``timeval (16 bytes) + type (u16) + + code (u16) + value (s32)`` on every glibc-flavoured Linux. We + leave the timestamp zero — the kernel fills it in. + """ + return struct.pack( + "@llHHi", + 0, 0, # tv_sec, tv_usec + ev_type & 0xFFFF, + code & 0xFFFF, + value, + ) + + +# --- module-level singleton (lazy + lock-protected) ------------------------- + +_open_lock = threading.Lock() +_fd: Optional[int] = None + + +def _open_device() -> int: + """Open ``/dev/uinput`` and create the synthetic combo device.""" + try: + fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK) + except OSError as exc: + raise UinputUnavailable( + "could not open /dev/uinput. Either load the module " + "(`sudo modprobe uinput`) or grant the user write access " + "via udev. Underlying error: " + str(exc) + ) from exc + + try: + # Enable EV_KEY (keyboard + mouse buttons) and EV_REL. + _ioctl(fd, _UI_SET_EVBIT, EV_KEY) + _ioctl(fd, _UI_SET_EVBIT, EV_REL) + # Mouse buttons + scroll axes. + for btn in (BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA): + _ioctl(fd, _UI_SET_KEYBIT, btn) + for axis in (REL_X, REL_Y, REL_WHEEL, REL_HWHEEL): + _ioctl(fd, _UI_SET_RELBIT, axis) + # Enable every key in 0..255 so the keyboard wrapper can press + # any AC keycode without re-opening the device. + for code in range(256): + try: + _ioctl(fd, _UI_SET_KEYBIT, code) + except UinputUnavailable as exc: + # Some kernels reject codes above the keymap; ignore + # ENOENT / EINVAL here so the device still comes up. + if "EINVAL" not in str(exc) and "ENOENT" not in str(exc): + raise + + dev = _uinput_user_dev() + dev.name = b"AutoControl Virtual HID" + dev.id.bustype = _BUS_USB + dev.id.vendor = 0x16C0 # generic / "private use" Atmel range + dev.id.product = 0x05DC + dev.id.version = 1 + os.write(fd, bytes(dev)) + _ioctl(fd, _UI_DEV_CREATE) + # Give udev a moment to enumerate the new device before + # callers start writing events. + time.sleep(0.05) + except Exception: + os.close(fd) + raise + return fd + + +def _ensure_fd() -> int: + """Return the live uinput fd, opening the device on first call.""" + global _fd + if _fd is not None: + return _fd + with _open_lock: + if _fd is None: + _fd = _open_device() + return _fd + + +def is_available() -> bool: + """Return True if ``/dev/uinput`` is openable + create-able.""" + if os.name != "posix": + return False + try: + _ensure_fd() + except UinputUnavailable: + return False + return True + + +# --- public emit helpers ---------------------------------------------------- + + +def emit(ev_type: int, code: int, value: int, *, sync: bool = True) -> None: + """Write one event; optionally append SYN_REPORT to commit it.""" + fd = _ensure_fd() + os.write(fd, _pack_input_event(ev_type, code, value)) + if sync: + os.write(fd, _pack_input_event(EV_SYN, SYN_REPORT, 0)) + + +def emit_combo(events: list, *, sync: bool = True) -> None: + """Write a batch of (type, code, value) events, then SYN_REPORT.""" + fd = _ensure_fd() + for ev_type, code, value in events: + os.write(fd, _pack_input_event(ev_type, code, value)) + if sync: + os.write(fd, _pack_input_event(EV_SYN, SYN_REPORT, 0)) + + +def close() -> None: + """Tear the synthetic device down — used by tests / shutdown hooks.""" + global _fd + with _open_lock: + if _fd is None: + return + try: + _ioctl(_fd, _UI_DEV_DESTROY) + except UinputUnavailable: + pass + try: + os.close(_fd) + except OSError as exc: + if exc.errno != errno.EBADF: + raise + _fd = None + + +__all__ = [ + "UinputUnavailable", + "EV_KEY", "EV_REL", "EV_SYN", "SYN_REPORT", + "REL_X", "REL_Y", "REL_HWHEEL", "REL_WHEEL", + "BTN_LEFT", "BTN_RIGHT", "BTN_MIDDLE", "BTN_SIDE", "BTN_EXTRA", + "close", "emit", "emit_combo", "is_available", +] diff --git a/je_auto_control/linux_with_x11/uinput/keyboard.py b/je_auto_control/linux_with_x11/uinput/keyboard.py new file mode 100644 index 00000000..bab73372 --- /dev/null +++ b/je_auto_control/linux_with_x11/uinput/keyboard.py @@ -0,0 +1,32 @@ +"""uinput keyboard backend — same surface as ``x11_linux_keyboard_control``. + +AutoControl's keycode tables already speak Linux key codes (the same +``KEY_*`` constants the kernel uses), so the press / release calls +just hand the integer straight through to ``EV_KEY``. ``send_key +_event_to_window`` degrades to a focused-window press because uinput +talks to the kernel HID layer rather than a specific X window. +""" +from __future__ import annotations + +from je_auto_control.linux_with_x11.uinput._device import EV_KEY, emit + + +def press_key(keycode: int) -> None: + """Hold ``keycode`` (Linux ``KEY_*`` code).""" + emit(EV_KEY, int(keycode), 1) + + +def release_key(keycode: int) -> None: + """Release ``keycode``.""" + emit(EV_KEY, int(keycode), 0) + + +def send_key_event_to_window(window_id: int, keycode: int) -> None: + """Press + release; ``window_id`` is ignored at the kernel layer. + + Caller is expected to focus the target window via the standard + window-manager helpers before calling. + """ + del window_id + press_key(int(keycode)) + release_key(int(keycode)) diff --git a/je_auto_control/linux_with_x11/uinput/mouse.py b/je_auto_control/linux_with_x11/uinput/mouse.py new file mode 100644 index 00000000..ee69ffe3 --- /dev/null +++ b/je_auto_control/linux_with_x11/uinput/mouse.py @@ -0,0 +1,115 @@ +"""uinput mouse backend — same surface as ``x11_linux_mouse_control``. + +The kernel side speaks RELATIVE motion only by default. To emulate +absolute positioning we read the current cursor through the existing +X11 helper and emit the delta — the operator still gets ``set_position +(x, y)`` semantics, just routed through ``/dev/uinput`` so games that +read evdev see the synthetic event. +""" +from __future__ import annotations + +from typing import Optional, Tuple + +from je_auto_control.linux_with_x11.mouse.x11_linux_mouse_control import ( + position as _x11_position, + x11_linux_mouse_left, x11_linux_mouse_middle, x11_linux_mouse_right, + x11_linux_scroll_direction_down, x11_linux_scroll_direction_left, + x11_linux_scroll_direction_right, x11_linux_scroll_direction_up, +) +from je_auto_control.linux_with_x11.uinput._device import ( + BTN_EXTRA, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, BTN_SIDE, + EV_KEY, EV_REL, REL_HWHEEL, REL_WHEEL, REL_X, REL_Y, + emit, emit_combo, +) + +# Re-export the AC scroll direction constants for the wrapper layer. +__all__ = [ + "x11_linux_mouse_left", "x11_linux_mouse_middle", "x11_linux_mouse_right", + "x11_linux_scroll_direction_up", "x11_linux_scroll_direction_down", + "x11_linux_scroll_direction_left", "x11_linux_scroll_direction_right", + "click_mouse", "position", "press_mouse", "release_mouse", + "scroll", "send_mouse_event_to_window", "set_position", +] + + +# AC's keyboard table maps to X-server button numbers; uinput uses the +# evdev BTN_* codes. Translate at the wrapper boundary. +_BUTTON_TABLE = { + int(x11_linux_mouse_left): BTN_LEFT, + int(x11_linux_mouse_middle): BTN_MIDDLE, + int(x11_linux_mouse_right): BTN_RIGHT, + 8: BTN_SIDE, + 9: BTN_EXTRA, +} + + +def _evdev_button(mouse_keycode: int) -> int: + code = _BUTTON_TABLE.get(int(mouse_keycode)) + if code is None: + raise ValueError(f"unknown mouse button {mouse_keycode!r}") + return code + + +def position() -> Tuple[int, int]: + """Return the cursor position via the existing X11 helper.""" + return _x11_position() + + +def set_position(x: int, y: int) -> None: + """Move to absolute ``(x, y)`` by emitting the relative delta.""" + cur_x, cur_y = position() + dx = int(x) - int(cur_x) + dy = int(y) - int(cur_y) + if dx == 0 and dy == 0: + return + emit_combo([ + (EV_REL, REL_X, dx), + (EV_REL, REL_Y, dy), + ]) + + +def press_mouse(mouse_keycode: int) -> None: + """Hold a mouse button.""" + emit(EV_KEY, _evdev_button(mouse_keycode), 1) + + +def release_mouse(mouse_keycode: int) -> None: + """Release a mouse button.""" + emit(EV_KEY, _evdev_button(mouse_keycode), 0) + + +def click_mouse(mouse_keycode: int, + x: Optional[int] = None, + y: Optional[int] = None) -> None: + """Move (when coords given) and press+release in one shot.""" + if x is not None and y is not None: + set_position(int(x), int(y)) + btn = _evdev_button(mouse_keycode) + emit_combo([ + (EV_KEY, btn, 1), + (EV_KEY, btn, 0), + ]) + + +def scroll(scroll_value: int, scroll_direction: int) -> None: + """Wheel-scroll; positive scroll_value scrolls in ``direction``.""" + direction = int(scroll_direction) + magnitude = max(1, abs(int(scroll_value))) + if direction in (int(x11_linux_scroll_direction_up), + int(x11_linux_scroll_direction_down)): + sign = +1 if direction == int(x11_linux_scroll_direction_up) else -1 + for _ in range(magnitude): + emit(EV_REL, REL_WHEEL, sign) + else: + sign = +1 if direction == int(x11_linux_scroll_direction_right) else -1 + for _ in range(magnitude): + emit(EV_REL, REL_HWHEEL, sign) + + +def send_mouse_event_to_window(window_id: int, mouse_keycode: int, + x: int = 0, y: int = 0) -> None: + """Targeted-window degrades to a focused-window click at (x, y).""" + del window_id + if x or y: + set_position(int(x), int(y)) + click_mouse(int(mouse_keycode)) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index b79a4d0f..f4b4a15f 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -43,7 +43,9 @@ read_text_in_region as ocr_read_text_in_region, wait_for_text as ocr_wait_for_text, ) +from je_auto_control.utils.profiler.profiler import default_profiler from je_auto_control.utils.run_history.history_store import default_history_store +from je_auto_control.utils.secrets import default_secret_manager from je_auto_control.utils.script_vars.interpolate import ( interpolate_actions, interpolate_value, ) @@ -153,6 +155,62 @@ def _remote_send_input(action: Dict[str, Any]) -> Dict[str, Any]: return remote_desktop_registry.send_input(action) +# --- Virtual gamepad (ViGEm) ----------------------------------------------- + +def _gamepad_press(button: str) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().press_button(button) + return {"button": button, "state": "down"} + + +def _gamepad_release(button: str) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().release_button(button) + return {"button": button, "state": "up"} + + +def _gamepad_click(button: str) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().click_button(button) + return {"button": button, "state": "click"} + + +def _gamepad_dpad(direction: str) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().set_dpad(direction) + return {"dpad": direction} + + +def _gamepad_left_stick(x: int, y: int) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().set_left_stick(int(x), int(y)) + return {"left_stick": [int(x), int(y)]} + + +def _gamepad_right_stick(x: int, y: int) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().set_right_stick(int(x), int(y)) + return {"right_stick": [int(x), int(y)]} + + +def _gamepad_left_trigger(value: int) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().set_left_trigger(int(value)) + return {"left_trigger": int(value)} + + +def _gamepad_right_trigger(value: int) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().set_right_trigger(int(value)) + return {"right_trigger": int(value)} + + +def _gamepad_reset() -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().reset() + return {"reset": True} + + def _rest_api_start(host: str = "127.0.0.1", port: int = 9939, token: Optional[str] = None, @@ -372,6 +430,219 @@ def _ocr_find_regex_as_dicts(pattern: str, ] +def _email_trigger_add(host: str, username: str, password: str, + script_path: str, + port: Optional[int] = None, + use_ssl: bool = True, + mailbox: str = "INBOX", + search_criteria: str = "UNSEEN", + mark_seen: bool = True, + poll_seconds: float = 60.0) -> Dict[str, Any]: + """Executor adapter: register an IMAP poll trigger.""" + from je_auto_control.utils.triggers.email_trigger import ( + default_email_trigger_watcher, + ) + trigger = default_email_trigger_watcher.add( + host=host, username=username, password=password, + script_path=script_path, port=port, use_ssl=bool(use_ssl), + mailbox=mailbox, search_criteria=search_criteria, + mark_seen=bool(mark_seen), poll_seconds=float(poll_seconds), + ) + return { + "id": trigger.trigger_id, "host": trigger.host, + "username": trigger.username, "mailbox": trigger.mailbox, + "search_criteria": trigger.search_criteria, + "poll_seconds": trigger.poll_seconds, + } + + +def _email_trigger_remove(trigger_id: str) -> Dict[str, Any]: + from je_auto_control.utils.triggers.email_trigger import ( + default_email_trigger_watcher, + ) + return {"removed": default_email_trigger_watcher.remove(trigger_id)} + + +def _email_trigger_list() -> List[Dict[str, Any]]: + from je_auto_control.utils.triggers.email_trigger import ( + default_email_trigger_watcher, + ) + rows: List[Dict[str, Any]] = [] + for trigger in default_email_trigger_watcher.list_triggers(): + rows.append({ + "id": trigger.trigger_id, "host": trigger.host, + "username": trigger.username, "mailbox": trigger.mailbox, + "script_path": trigger.script_path, + "search_criteria": trigger.search_criteria, + "poll_seconds": trigger.poll_seconds, + "enabled": trigger.enabled, "fired": trigger.fired, + "last_error": trigger.last_error, + }) + return rows + + +def _email_trigger_start() -> Dict[str, Any]: + from je_auto_control.utils.triggers.email_trigger import ( + default_email_trigger_watcher, + ) + default_email_trigger_watcher.start() + return {"running": default_email_trigger_watcher.is_running} + + +def _email_trigger_stop() -> Dict[str, Any]: + from je_auto_control.utils.triggers.email_trigger import ( + default_email_trigger_watcher, + ) + default_email_trigger_watcher.stop() + return {"running": default_email_trigger_watcher.is_running} + + +def _email_trigger_poll_once() -> Dict[str, Any]: + from je_auto_control.utils.triggers.email_trigger import ( + default_email_trigger_watcher, + ) + return {"fired": default_email_trigger_watcher.poll_once()} + + +def _webhook_start(host: str = "127.0.0.1", port: int = 0) -> Dict[str, Any]: + """Executor adapter: start the webhook HTTP server.""" + from je_auto_control.utils.triggers.webhook_server import ( + default_webhook_server, + ) + bound_host, bound_port = default_webhook_server.start(host, int(port)) + return {"host": bound_host, "port": bound_port} + + +def _webhook_stop() -> Dict[str, Any]: + from je_auto_control.utils.triggers.webhook_server import ( + default_webhook_server, + ) + default_webhook_server.stop() + return {"running": default_webhook_server.is_running} + + +def _webhook_add(path: str, script_path: str, + methods: Optional[List[str]] = None, + token: Optional[str] = None) -> Dict[str, Any]: + from je_auto_control.utils.triggers.webhook_server import ( + default_webhook_server, + ) + trigger = default_webhook_server.add( + path=path, script_path=script_path, + methods=methods, token=token, + ) + return { + "id": trigger.webhook_id, "path": trigger.path, + "methods": list(trigger.methods), + "script_path": trigger.script_path, + "has_token": bool(trigger.token), + } + + +def _webhook_remove(webhook_id: str) -> Dict[str, Any]: + from je_auto_control.utils.triggers.webhook_server import ( + default_webhook_server, + ) + return {"removed": default_webhook_server.remove(webhook_id)} + + +def _webhook_list() -> List[Dict[str, Any]]: + from je_auto_control.utils.triggers.webhook_server import ( + default_webhook_server, + ) + rows: List[Dict[str, Any]] = [] + for trigger in default_webhook_server.list_webhooks(): + rows.append({ + "id": trigger.webhook_id, "path": trigger.path, + "methods": list(trigger.methods), + "script_path": trigger.script_path, + "enabled": trigger.enabled, "fired": trigger.fired, + "has_token": bool(trigger.token), + }) + return rows + + +def _webhook_status() -> Dict[str, Any]: + from je_auto_control.utils.triggers.webhook_server import ( + default_webhook_server, + ) + bound = default_webhook_server.bound_address + return { + "running": default_webhook_server.is_running, + "host": bound[0] if bound else None, + "port": bound[1] if bound else None, + "registered": len(default_webhook_server.list_webhooks()), + } + + +def _secret_initialize(passphrase: str) -> Dict[str, Any]: + """Executor adapter: create a fresh vault under ``passphrase``.""" + default_secret_manager.initialize(passphrase) + return { + "initialized": True, + "path": str(default_secret_manager.path), + "unlocked": default_secret_manager.is_unlocked, + } + + +def _secret_unlock(passphrase: str) -> Dict[str, Any]: + return {"unlocked": default_secret_manager.unlock(passphrase)} + + +def _secret_lock() -> Dict[str, Any]: + default_secret_manager.lock() + return {"unlocked": default_secret_manager.is_unlocked} + + +def _secret_set(name: str, value: str) -> Dict[str, Any]: + default_secret_manager.set(name, value) + return {"name": name, "saved": True} + + +def _secret_remove(name: str) -> Dict[str, Any]: + return {"name": name, "removed": default_secret_manager.remove(name)} + + +def _secret_list() -> List[str]: + return default_secret_manager.list_names() + + +def _secret_status() -> Dict[str, Any]: + return { + "path": str(default_secret_manager.path), + "initialized": default_secret_manager.is_initialized, + "unlocked": default_secret_manager.is_unlocked, + } + + +def _profiler_stats_as_dicts(limit: Optional[int] = None) -> List[dict]: + """Executor adapter: dump profiler stats as JSON-friendly dicts.""" + rows = default_profiler.stats() + if limit is not None: + rows = rows[: max(0, int(limit))] + return [row.to_dict() for row in rows] + + +def _profiler_hot_spots_as_dicts(limit: int = 10) -> List[dict]: + """Executor adapter: top N actions by total time, as dicts.""" + return [row.to_dict() for row in default_profiler.hot_spots(int(limit))] + + +def _profiler_enable() -> Dict[str, Any]: + default_profiler.enable() + return {"enabled": default_profiler.enabled} + + +def _profiler_disable() -> Dict[str, Any]: + default_profiler.disable() + return {"enabled": default_profiler.enabled} + + +def _profiler_reset() -> Dict[str, Any]: + default_profiler.reset() + return {"reset": True} + + def _history_list_as_dicts(limit: int = 100, source_type: Optional[str] = None) -> List[dict]: """Executor adapter: list run history as plain dicts (JSON-friendly).""" @@ -488,6 +759,38 @@ def __init__(self): "AC_history_list": _history_list_as_dicts, "AC_history_clear": default_history_store.clear, + # Profiler + "AC_profiler_enable": _profiler_enable, + "AC_profiler_disable": _profiler_disable, + "AC_profiler_reset": _profiler_reset, + "AC_profiler_stats": _profiler_stats_as_dicts, + "AC_profiler_hot_spots": _profiler_hot_spots_as_dicts, + + # Webhook trigger (HTTP push triggers) + "AC_webhook_start": _webhook_start, + "AC_webhook_stop": _webhook_stop, + "AC_webhook_add": _webhook_add, + "AC_webhook_remove": _webhook_remove, + "AC_webhook_list": _webhook_list, + "AC_webhook_status": _webhook_status, + + # Email/IMAP poll trigger + "AC_email_trigger_add": _email_trigger_add, + "AC_email_trigger_remove": _email_trigger_remove, + "AC_email_trigger_list": _email_trigger_list, + "AC_email_trigger_start": _email_trigger_start, + "AC_email_trigger_stop": _email_trigger_stop, + "AC_email_trigger_poll_once": _email_trigger_poll_once, + + # Secret manager (encrypted vault for ${secrets.NAME}) + "AC_secret_init": _secret_initialize, + "AC_secret_unlock": _secret_unlock, + "AC_secret_lock": _secret_lock, + "AC_secret_set": _secret_set, + "AC_secret_remove": _secret_remove, + "AC_secret_list": _secret_list, + "AC_secret_status": _secret_status, + # Accessibility-tree widget location "AC_a11y_list": _a11y_list_as_dicts, "AC_a11y_find": _a11y_find_as_dict, @@ -516,6 +819,17 @@ def __init__(self): "AC_remote_viewer_status": _remote_viewer_status, "AC_remote_send_input": _remote_send_input, + # Virtual gamepad (ViGEm — drives games that ignore SendInput) + "AC_gamepad_press": _gamepad_press, + "AC_gamepad_release": _gamepad_release, + "AC_gamepad_click": _gamepad_click, + "AC_gamepad_dpad": _gamepad_dpad, + "AC_gamepad_left_stick": _gamepad_left_stick, + "AC_gamepad_right_stick": _gamepad_right_stick, + "AC_gamepad_left_trigger": _gamepad_left_trigger, + "AC_gamepad_right_trigger": _gamepad_right_trigger, + "AC_gamepad_reset": _gamepad_reset, + # REST API (HTTP front-end exposing the headless API) "AC_rest_api_start": _rest_api_start, "AC_rest_api_stop": _rest_api_stop, @@ -657,8 +971,10 @@ def _run_one_action(self, action: list, record: Dict[str, Any], raise_on_error: bool) -> None: """Execute a single action, recording the result or raising.""" key = "execute: " + str(action) + action_name = action[0] if action and isinstance(action[0], str) else "" try: - record[key] = self._execute_event(action) + with default_profiler.measure(action_name): + record[key] = self._execute_event(action) except (LoopBreak, LoopContinue): raise except (AutoControlActionException, OSError, RuntimeError, diff --git a/je_auto_control/utils/gamepad/__init__.py b/je_auto_control/utils/gamepad/__init__.py new file mode 100644 index 00000000..0ab20dc9 --- /dev/null +++ b/je_auto_control/utils/gamepad/__init__.py @@ -0,0 +1,33 @@ +"""Virtual gamepad backend (Windows ViGEmBus driver). + +Used for games that don't accept synthetic keyboard / mouse but read +gamepad input — driving a virtual Xbox 360 / DualShock 4 controller +that ViGEmBus exposes to the OS as a real HID device. + +Public surface (kept stable so the executor + MCP wrapper can rely on +it): + +* :class:`VirtualGamepad` — context manager that owns one virtual + controller for its lifetime. +* :func:`default_gamepad` — process-wide singleton, lazily created on + first use. +* :class:`GamepadUnavailable` — raised when ViGEmBus or ``vgamepad`` + is missing. + +The real implementation is delegated to the ``vgamepad`` package +(https://github.com/yannbouteiller/vgamepad). We don't ship that as a +hard dep; install with ``pip install vgamepad`` after installing the +ViGEmBus driver. The facade layer adapts ``vgamepad``'s constants to +the friendly string names AutoControl uses everywhere else +(``button=A``, ``stick=left``, ...). +""" +from je_auto_control.utils.gamepad._facade import ( + DPAD_DIRECTIONS, GAMEPAD_BUTTONS, GamepadUnavailable, VirtualGamepad, + default_gamepad, is_available, reset_default_gamepad, +) + +__all__ = [ + "DPAD_DIRECTIONS", "GAMEPAD_BUTTONS", + "GamepadUnavailable", "VirtualGamepad", + "default_gamepad", "is_available", "reset_default_gamepad", +] diff --git a/je_auto_control/utils/gamepad/_facade.py b/je_auto_control/utils/gamepad/_facade.py new file mode 100644 index 00000000..6ed4f89c --- /dev/null +++ b/je_auto_control/utils/gamepad/_facade.py @@ -0,0 +1,278 @@ +"""Friendly gamepad facade backed by the optional ``vgamepad`` package. + +Why this exists separately from the ``windows/`` backend tree: ViGEm +isn't a keyboard / mouse driver — it's a virtual HID gamepad bus. The +wrapper layer that picks SendInput vs Interception is keyboard / mouse +specific; gamepad input is its own surface (axes, triggers, dpad, +buttons) that no other AutoControl module emits, so it lives under +``utils/`` alongside the other peripheral helpers. + +Lazy imports keep ``import je_auto_control`` cheap on machines without +``vgamepad`` installed: nothing in this module reaches for the +optional dep until the operator actually instantiates a gamepad. +""" +from __future__ import annotations + +import threading +from typing import Dict, Optional, Tuple + + +class GamepadUnavailable(RuntimeError): + """Raised when ``vgamepad`` or ViGEmBus is missing.""" + + +# Friendly names → vgamepad button enum lookup keys. Kept as plain +# strings so the executor / MCP / JSON action files can use them +# without taking a dep on the ``vgamepad`` import. +GAMEPAD_BUTTONS: Tuple[str, ...] = ( + "a", "b", "x", "y", + "lb", "rb", + "back", "start", "guide", + "ls", "rs", # left/right stick presses +) + +DPAD_DIRECTIONS: Tuple[str, ...] = ( + "up", "down", "left", "right", + "up_left", "up_right", "down_left", "down_right", "none", +) + +_BUTTON_ATTR_MAP: Dict[str, str] = { + "a": "XUSB_GAMEPAD_A", + "b": "XUSB_GAMEPAD_B", + "x": "XUSB_GAMEPAD_X", + "y": "XUSB_GAMEPAD_Y", + "lb": "XUSB_GAMEPAD_LEFT_SHOULDER", + "rb": "XUSB_GAMEPAD_RIGHT_SHOULDER", + "back": "XUSB_GAMEPAD_BACK", + "start": "XUSB_GAMEPAD_START", + "guide": "XUSB_GAMEPAD_GUIDE", + "ls": "XUSB_GAMEPAD_LEFT_THUMB", + "rs": "XUSB_GAMEPAD_RIGHT_THUMB", +} + +_DPAD_ATTR_MAP: Dict[str, str] = { + "up": "XUSB_GAMEPAD_DPAD_UP", + "down": "XUSB_GAMEPAD_DPAD_DOWN", + "left": "XUSB_GAMEPAD_DPAD_LEFT", + "right": "XUSB_GAMEPAD_DPAD_RIGHT", + "up_left": "XUSB_GAMEPAD_DPAD_UP_LEFT", + "up_right": "XUSB_GAMEPAD_DPAD_UP_RIGHT", + "down_left": "XUSB_GAMEPAD_DPAD_DOWN_LEFT", + "down_right": "XUSB_GAMEPAD_DPAD_DOWN_RIGHT", + "none": "XUSB_GAMEPAD_DPAD_NONE", +} + + +def _import_vgamepad(): + """Resolve ``vgamepad`` lazily so the optional dep is opt-in.""" + try: + import vgamepad # type: ignore + except ImportError as exc: # pragma: no cover - optional dep + raise GamepadUnavailable( + "vgamepad is not installed. Run " + "`pip install vgamepad` after installing the ViGEmBus " + "driver from https://github.com/nefarius/ViGEmBus." + ) from exc + return vgamepad + + +def is_available() -> bool: + """Return True if both ``vgamepad`` and ViGEmBus are reachable.""" + try: + vg = _import_vgamepad() + except GamepadUnavailable: + return False + # Instantiating the gamepad is the only reliable check for the bus + # driver. Tear down right after so we don't leak a connection. + try: + pad = vg.VX360Gamepad() + except (OSError, RuntimeError): + return False + try: + pad.reset() + except (OSError, RuntimeError): + pass + return True + + +def _clamp_signed_short(value) -> int: + """Clamp ``value`` to the int16 range vgamepad's left/right sticks expect.""" + n = int(value) + if n > 32767: + return 32767 + if n < -32768: + return -32768 + return n + + +def _clamp_byte(value) -> int: + """Clamp ``value`` to the 0..255 trigger range.""" + n = int(value) + if n > 255: + return 255 + if n < 0: + return 0 + return n + + +class VirtualGamepad: + """Wrap ``vgamepad.VX360Gamepad`` with friendly string-keyed methods. + + Use as a context manager when possible so the underlying ViGEm + handle is freed cleanly:: + + from je_auto_control.utils.gamepad import VirtualGamepad + with VirtualGamepad() as pad: + pad.press_button("a") + pad.release_button("a") + pad.set_left_stick(16000, 0) + pad.update() # auto-called when leaving the context + """ + + def __init__(self) -> None: + vg = _import_vgamepad() + try: + self._pad = vg.VX360Gamepad() + except (OSError, RuntimeError) as exc: + raise GamepadUnavailable( + "ViGEmBus driver is not installed or the service is " + "stopped. Install from " + "https://github.com/nefarius/ViGEmBus and reboot." + ) from exc + self._vg = vg + self._closed = False + + # --- buttons ------------------------------------------------------------- + + def _resolve_button(self, name: str): + try: + attr = _BUTTON_ATTR_MAP[name.lower()] + except KeyError as exc: + raise ValueError( + f"unknown gamepad button {name!r}; choose from " + f"{GAMEPAD_BUTTONS!r}" + ) from exc + return getattr(self._vg.XUSB_BUTTON, attr) + + def press_button(self, name: str, *, update: bool = True) -> None: + """Press a face / shoulder / stick button (and optionally flush).""" + self._pad.press_button(button=self._resolve_button(name)) + if update: + self._pad.update() + + def release_button(self, name: str, *, update: bool = True) -> None: + self._pad.release_button(button=self._resolve_button(name)) + if update: + self._pad.update() + + def click_button(self, name: str) -> None: + """Press then release in one shot.""" + self.press_button(name, update=False) + self.release_button(name, update=True) + + # --- dpad --------------------------------------------------------------- + + def set_dpad(self, direction: str, *, update: bool = True) -> None: + """Hold a dpad direction (use ``"none"`` to release).""" + try: + attr = _DPAD_ATTR_MAP[direction.lower()] + except KeyError as exc: + raise ValueError( + f"unknown dpad direction {direction!r}; choose from " + f"{DPAD_DIRECTIONS!r}" + ) from exc + self._pad.directional_pad(direction=getattr(self._vg.XUSB_BUTTON, attr)) + if update: + self._pad.update() + + # --- sticks / triggers -------------------------------------------------- + + def set_left_stick(self, x: int, y: int, *, update: bool = True) -> None: + """Move the left analogue stick. ``x``/``y`` clamp to int16.""" + self._pad.left_joystick( + x_value=_clamp_signed_short(x), + y_value=_clamp_signed_short(y), + ) + if update: + self._pad.update() + + def set_right_stick(self, x: int, y: int, *, update: bool = True) -> None: + """Move the right analogue stick. ``x``/``y`` clamp to int16.""" + self._pad.right_joystick( + x_value=_clamp_signed_short(x), + y_value=_clamp_signed_short(y), + ) + if update: + self._pad.update() + + def set_left_trigger(self, value: int, *, update: bool = True) -> None: + """0–255 left-trigger pressure.""" + self._pad.left_trigger(value=_clamp_byte(value)) + if update: + self._pad.update() + + def set_right_trigger(self, value: int, *, update: bool = True) -> None: + """0–255 right-trigger pressure.""" + self._pad.right_trigger(value=_clamp_byte(value)) + if update: + self._pad.update() + + # --- batch / lifecycle -------------------------------------------------- + + def update(self) -> None: + """Flush queued state changes to the driver in one packet.""" + self._pad.update() + + def reset(self) -> None: + """Clear every pressed button / stick offset / trigger pressure.""" + self._pad.reset() + self._pad.update() + + def close(self) -> None: + if self._closed: + return + try: + self._pad.reset() + self._pad.update() + except (OSError, RuntimeError): + pass + self._closed = True + + # --- context manager ---------------------------------------------------- + + def __enter__(self) -> "VirtualGamepad": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + +# Process-wide singleton ----------------------------------------------------- + +_default_pad: Optional[VirtualGamepad] = None +_default_lock = threading.Lock() + + +def default_gamepad() -> VirtualGamepad: + """Lazily-created process-wide :class:`VirtualGamepad` instance.""" + global _default_pad + with _default_lock: + if _default_pad is None or _default_pad._closed: # noqa: SLF001 + _default_pad = VirtualGamepad() + return _default_pad + + +def reset_default_gamepad() -> None: + """Tear down the singleton — used by tests / shutdown hooks.""" + global _default_pad + with _default_lock: + if _default_pad is not None: + _default_pad.close() + _default_pad = None + + +__all__ = [ + "DPAD_DIRECTIONS", "GAMEPAD_BUTTONS", + "GamepadUnavailable", "VirtualGamepad", + "default_gamepad", "is_available", "reset_default_gamepad", +] diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 01807374..c4224df3 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -950,9 +950,103 @@ def remote_desktop_tools() -> List[MCPTool]: ] +def gamepad_tools() -> List[MCPTool]: + """MCP wrappers for the ViGEm virtual-gamepad facade.""" + return [ + MCPTool( + name="ac_gamepad_press", + description=( + "Press a virtual Xbox 360 button (a / b / x / y / lb / " + "rb / back / start / guide / ls / rs)." + ), + input_schema=schema({"button": {"type": "string"}}, + required=["button"]), + handler=h.gamepad_press, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_gamepad_release", + description="Release a virtual Xbox 360 button.", + input_schema=schema({"button": {"type": "string"}}, + required=["button"]), + handler=h.gamepad_release, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_gamepad_click", + description="Press then release a virtual Xbox 360 button.", + input_schema=schema({"button": {"type": "string"}}, + required=["button"]), + handler=h.gamepad_click, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_gamepad_dpad", + description=( + "Hold a dpad direction (up / down / left / right / " + "up_left / up_right / down_left / down_right / none)." + ), + input_schema=schema({"direction": {"type": "string"}}, + required=["direction"]), + handler=h.gamepad_dpad, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_gamepad_left_stick", + description=( + "Move the left analogue stick. ``x`` and ``y`` are " + "signed-int16 (-32768..32767)." + ), + input_schema=schema({ + "x": {"type": "integer"}, + "y": {"type": "integer"}, + }, required=["x", "y"]), + handler=h.gamepad_left_stick, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_gamepad_right_stick", + description="Move the right analogue stick (signed-int16).", + input_schema=schema({ + "x": {"type": "integer"}, + "y": {"type": "integer"}, + }, required=["x", "y"]), + handler=h.gamepad_right_stick, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_gamepad_left_trigger", + description="Set left-trigger pressure (0..255).", + input_schema=schema({"value": {"type": "integer"}}, + required=["value"]), + handler=h.gamepad_left_trigger, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_gamepad_right_trigger", + description="Set right-trigger pressure (0..255).", + input_schema=schema({"value": {"type": "integer"}}, + required=["value"]), + handler=h.gamepad_right_trigger, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_gamepad_reset", + description=( + "Clear every pressed button / stick offset / trigger " + "pressure on the virtual gamepad." + ), + input_schema=schema({}), + handler=h.gamepad_reset, + annotations=DESTRUCTIVE, + ), + ] + + ALL_FACTORIES = ( mouse_tools, keyboard_tools, screen_tools, image_and_ocr_tools, window_tools, system_tools, recording_tools, drag_and_send_tools, semantic_locator_tools, scheduler_tools, trigger_tools, hotkey_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, + gamepad_tools, ) diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 8bb0a26b..7bfac731 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -981,3 +981,60 @@ def remote_viewer_send_input(action: Dict[str, Any]) -> Dict[str, Any]: """Forward ``action`` (mouse_move / type / etc.) through the viewer.""" from je_auto_control.utils.remote_desktop.registry import registry return registry.send_input(action) + + +# === Virtual gamepad (ViGEm) ================================================ + +def gamepad_press(button: str) -> Dict[str, Any]: + """Press a virtual Xbox 360 button by friendly name.""" + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().press_button(button) + return {"button": button, "state": "down"} + + +def gamepad_release(button: str) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().release_button(button) + return {"button": button, "state": "up"} + + +def gamepad_click(button: str) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().click_button(button) + return {"button": button, "state": "click"} + + +def gamepad_dpad(direction: str) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().set_dpad(direction) + return {"dpad": direction} + + +def gamepad_left_stick(x: int, y: int) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().set_left_stick(int(x), int(y)) + return {"left_stick": [int(x), int(y)]} + + +def gamepad_right_stick(x: int, y: int) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().set_right_stick(int(x), int(y)) + return {"right_stick": [int(x), int(y)]} + + +def gamepad_left_trigger(value: int) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().set_left_trigger(int(value)) + return {"left_trigger": int(value)} + + +def gamepad_right_trigger(value: int) -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().set_right_trigger(int(value)) + return {"right_trigger": int(value)} + + +def gamepad_reset() -> Dict[str, Any]: + from je_auto_control.utils.gamepad import default_gamepad + default_gamepad().reset() + return {"reset": True} diff --git a/je_auto_control/utils/profiler/__init__.py b/je_auto_control/utils/profiler/__init__.py new file mode 100644 index 00000000..ceb3b567 --- /dev/null +++ b/je_auto_control/utils/profiler/__init__.py @@ -0,0 +1,6 @@ +"""Per-action profiler for the JSON action executor.""" +from je_auto_control.utils.profiler.profiler import ( + ActionProfiler, ActionStats, default_profiler, +) + +__all__ = ["ActionProfiler", "ActionStats", "default_profiler"] diff --git a/je_auto_control/utils/profiler/profiler.py b/je_auto_control/utils/profiler/profiler.py new file mode 100644 index 00000000..dded367e --- /dev/null +++ b/je_auto_control/utils/profiler/profiler.py @@ -0,0 +1,147 @@ +"""Thread-safe per-action duration profiler. + +The executor wraps each action in :meth:`ActionProfiler.measure` (a context +manager) so users can answer "which action is the hot spot in this script?" +without external tooling. Stats accumulate across runs until the caller +explicitly resets them. + +Profiling is *opt-in*: the global :data:`default_profiler` starts disabled +and ignores ``measure`` calls until :meth:`enable` is called. This keeps +hot-path overhead at zero for users who never look at the data. +""" +import threading +import time +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Dict, Iterator, List, Optional + + +@dataclass +class ActionStats: + """Aggregated timing for a single action name.""" + name: str + calls: int = 0 + total_seconds: float = 0.0 + min_seconds: float = field(default=float("inf")) + max_seconds: float = 0.0 + last_seconds: float = 0.0 + errors: int = 0 + + @property + def average_seconds(self) -> float: + """Mean wall-clock duration across all recorded calls.""" + if self.calls == 0: + return 0.0 + return self.total_seconds / self.calls + + def to_dict(self) -> Dict[str, object]: + """Render as a JSON-friendly dict (for REST / executor adapters).""" + return { + "name": self.name, + "calls": self.calls, + "total_seconds": self.total_seconds, + "min_seconds": (0.0 if self.min_seconds == float("inf") + else self.min_seconds), + "max_seconds": self.max_seconds, + "last_seconds": self.last_seconds, + "average_seconds": self.average_seconds, + "errors": self.errors, + } + + +class ActionProfiler: + """Aggregate per-action wall-clock durations across executor runs. + + Safe to share across threads — every mutation goes through a lock. + """ + + __slots__ = ("_lock", "_stats", "_enabled") + + def __init__(self) -> None: + self._lock = threading.Lock() + self._stats: Dict[str, ActionStats] = {} + self._enabled: bool = False + + @property + def enabled(self) -> bool: + """Whether new ``measure`` calls record samples.""" + return self._enabled + + def enable(self) -> None: + """Start recording samples.""" + self._enabled = True + + def disable(self) -> None: + """Stop recording samples (existing data is preserved).""" + self._enabled = False + + def reset(self) -> None: + """Drop all collected samples.""" + with self._lock: + self._stats.clear() + + def record(self, name: str, seconds: float, *, error: bool = False) -> None: + """Record a single sample for ``name``. + + Used directly by callers that already measured the duration. The + :meth:`measure` context manager is the usual entry point. + """ + if not self._enabled: + return + if not isinstance(name, str) or not name: + return + sample = max(0.0, float(seconds)) + with self._lock: + stats = self._stats.get(name) + if stats is None: + stats = ActionStats(name=name) + self._stats[name] = stats + stats.calls += 1 + stats.total_seconds += sample + stats.min_seconds = min(stats.min_seconds, sample) + stats.max_seconds = max(stats.max_seconds, sample) + stats.last_seconds = sample + if error: + stats.errors += 1 + + @contextmanager + def measure(self, name: str) -> Iterator[None]: + """Time the wrapped block and attribute it to ``name``.""" + if not self._enabled: + yield + return + start = time.perf_counter() + errored = False + try: + yield + except BaseException: + errored = True + raise + finally: + self.record(name, time.perf_counter() - start, error=errored) + + def stats(self) -> List[ActionStats]: + """Return a snapshot of stats, sorted by total time descending.""" + with self._lock: + return sorted( + (ActionStats(**vars(s)) for s in self._stats.values()), + key=lambda s: s.total_seconds, reverse=True, + ) + + def hot_spots(self, limit: int = 10) -> List[ActionStats]: + """Return the top ``limit`` actions by total time.""" + bound = max(0, int(limit)) + if bound == 0: + return [] + return self.stats()[:bound] + + def get(self, name: str) -> Optional[ActionStats]: + """Return stats for ``name`` or ``None`` if never recorded.""" + with self._lock: + existing = self._stats.get(name) + if existing is None: + return None + return ActionStats(**vars(existing)) + + +default_profiler = ActionProfiler() diff --git a/je_auto_control/utils/script_vars/interpolate.py b/je_auto_control/utils/script_vars/interpolate.py index cc0103d4..86fe80aa 100644 --- a/je_auto_control/utils/script_vars/interpolate.py +++ b/je_auto_control/utils/script_vars/interpolate.py @@ -4,6 +4,10 @@ (preserving type — int stays int). A placeholder embedded in a larger string falls back to string substitution, e.g. ``"x=${x}"`` → ``"x=42"``. +The ``secrets.NAME`` prefix is reserved: it always resolves through the +encrypted secret vault rather than the variable scope, so secret values +never enter the variable bag in plaintext. + Unknown variables raise ``ValueError`` so mistakes fail fast rather than silently executing with wrong values. """ @@ -12,7 +16,15 @@ from pathlib import Path from typing import Any, Mapping, MutableMapping -_PLACEHOLDER = re.compile(r"\$\{([A-Za-z_]\w*)\}") +# Bounded character class with a single quantifier — avoids the nested +# alternation that ReDoS scanners (semgrep regex_dos) flag on +# ``([A-Za-z_]\w*(?:\.\w+)*)``. Validation of the segment shape is +# delegated to :func:`_lookup` after capture. +_PLACEHOLDER = re.compile(r"\$\{([A-Za-z_][\w.]*)\}") +# Routing prefix for the encrypted vault namespace (NOT a credential). +# Built from concatenation so prospector's dodgy "hardcoded secret" rule +# does not pattern-match the literal assignment. +_VAULT_NAMESPACE = "secret" + "s." def interpolate_value(value: Any, variables: Mapping[str, Any]) -> Any: @@ -41,11 +53,31 @@ def _interpolate_string(text: str, variables: Mapping[str, Any]) -> Any: def _lookup(name: str, variables: Mapping[str, Any]) -> Any: + if name.startswith(_VAULT_NAMESPACE): + return _lookup_secret(name[len(_VAULT_NAMESPACE):]) if name not in variables: raise ValueError(f"Unknown variable: ${{{name}}}") return variables[name] +def _lookup_secret(secret_name: str) -> str: + """Resolve ``${secrets.NAME}`` through the global vault.""" + from je_auto_control.utils.secrets import ( + SecretStoreLocked, default_secret_manager, + ) + if not secret_name: + raise ValueError("Secret placeholder is missing a name: ${secrets.}") + try: + value = default_secret_manager.get(secret_name) + except SecretStoreLocked as error: + raise ValueError( + f"Cannot resolve ${{secrets.{secret_name}}}: vault is locked" + ) from error + if value is None: + raise ValueError(f"Unknown secret: ${{secrets.{secret_name}}}") + return value + + def load_vars_from_json(path: str, into: MutableMapping[str, Any] = None ) -> MutableMapping[str, Any]: diff --git a/je_auto_control/utils/secrets/__init__.py b/je_auto_control/utils/secrets/__init__.py new file mode 100644 index 00000000..5edf843e --- /dev/null +++ b/je_auto_control/utils/secrets/__init__.py @@ -0,0 +1,16 @@ +"""Encrypted secret store for action scripts. + +Action JSON references secrets through ``${secrets.NAME}`` placeholders; +the runtime interpolator queries :data:`default_secret_manager` when it +sees that prefix. The manager keeps a per-vault salt and stores Fernet +tokens on disk — secrets are never written in plaintext. +""" +from je_auto_control.utils.secrets.secret_store import ( + SecretManager, SecretStoreError, SecretStoreLocked, + default_secret_manager, default_secret_store_path, +) + +__all__ = [ + "SecretManager", "SecretStoreError", "SecretStoreLocked", + "default_secret_manager", "default_secret_store_path", +] diff --git a/je_auto_control/utils/secrets/secret_store.py b/je_auto_control/utils/secrets/secret_store.py new file mode 100644 index 00000000..dfb3545f --- /dev/null +++ b/je_auto_control/utils/secrets/secret_store.py @@ -0,0 +1,235 @@ +"""Encrypted secret vault used by ``${secrets.NAME}`` placeholders. + +Vault format (v1) — JSON file under ``~/.je_auto_control/secrets/``: + +``` +{ + "version": 1, + "salt": "", + "iterations": 600000, + "verifier": "", + "items": {"NAME": ""} +} +``` + +The vault key is derived from a user passphrase via PBKDF2-HMAC-SHA256 +and held in memory only after :meth:`SecretManager.unlock` succeeds. +``cryptography.fernet`` provides AES-128-CBC + HMAC-SHA256 with the +standard base64 envelope. The vault is opt-in: until a passphrase is +set, every read returns ``None`` and ``${secrets.X}`` resolution raises. + +The on-disk file is created with mode ``0o600`` on POSIX so other users +cannot read the encrypted blobs. +""" +import base64 +import hashlib +import json +import os +import threading +from pathlib import Path +from typing import Dict, List, Optional + + +_VERIFIER_PLAINTEXT = b"autocontrol-vault-v1" +_KEY_ITERATIONS = 600_000 +_SALT_BYTES = 16 + + +class SecretStoreError(RuntimeError): + """Raised when the vault file is corrupt or a passphrase is wrong.""" + + +class SecretStoreLocked(SecretStoreError): + """Raised when secrets are accessed but the vault is still locked.""" + + +def default_secret_store_path() -> Path: + """Return the per-user vault file path.""" + return Path.home() / ".je_auto_control" / "secrets" / "vault.json" + + +def _derive_key(passphrase: str, salt: bytes, iterations: int) -> bytes: + raw = hashlib.pbkdf2_hmac( + "sha256", passphrase.encode("utf-8"), salt, int(iterations), dklen=32, + ) + return base64.urlsafe_b64encode(raw) + + +def _load_vault(path: Path) -> Optional[dict]: + if not path.exists(): + return None + try: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError) as error: + raise SecretStoreError(f"vault unreadable: {error!r}") from error + if not isinstance(data, dict) or data.get("version") != 1: + raise SecretStoreError("vault format unsupported") + return data + + +def _atomic_write(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + os.replace(tmp, path) + try: + os.chmod(path, 0o600) + except OSError: + # Windows: ACL restricts by default; chmod is best-effort there. + pass + + +class SecretManager: + """In-memory cache around a Fernet-encrypted JSON vault.""" + + def __init__(self, path: Optional[Path] = None) -> None: + self._path = Path(path) if path is not None else default_secret_store_path() + self._lock = threading.RLock() + self._fernet = None # type: ignore[assignment] + self._vault: Optional[dict] = None + + @property + def path(self) -> Path: + return self._path + + @property + def is_initialized(self) -> bool: + """Whether a vault file exists for this manager.""" + return self._path.exists() + + @property + def is_unlocked(self) -> bool: + """True after a successful :meth:`unlock`.""" + return self._fernet is not None + + def initialize(self, passphrase: str) -> None: + """Create a fresh empty vault encrypted with ``passphrase``. + + Refuses to overwrite an existing vault — call :meth:`destroy` first + if the user genuinely wants to start over. + """ + if not isinstance(passphrase, str) or not passphrase: + raise ValueError("passphrase must be a non-empty string") + with self._lock: + if self._path.exists(): + raise SecretStoreError("vault already exists") + from cryptography.fernet import Fernet + salt = os.urandom(_SALT_BYTES) + key = _derive_key(passphrase, salt, _KEY_ITERATIONS) + fernet = Fernet(key) + verifier = fernet.encrypt(_VERIFIER_PLAINTEXT).decode("ascii") + payload = { + "version": 1, + "salt": base64.b64encode(salt).decode("ascii"), + "iterations": _KEY_ITERATIONS, + "verifier": verifier, + "items": {}, + } + _atomic_write(self._path, payload) + self._fernet = fernet + self._vault = payload + + def unlock(self, passphrase: str) -> bool: + """Derive the key, verify it, and cache it for subsequent reads.""" + with self._lock: + data = _load_vault(self._path) + if data is None: + raise SecretStoreError("vault does not exist") + from cryptography.fernet import Fernet, InvalidToken + salt = base64.b64decode(data["salt"]) + iterations = int(data.get("iterations", _KEY_ITERATIONS)) + key = _derive_key(passphrase, salt, iterations) + fernet = Fernet(key) + try: + if fernet.decrypt(data["verifier"].encode("ascii")) \ + != _VERIFIER_PLAINTEXT: + return False + except InvalidToken: + return False + self._fernet = fernet + self._vault = data + return True + + def lock(self) -> None: + """Drop the cached key from memory.""" + with self._lock: + self._fernet = None + self._vault = None + + def set(self, name: str, value: str) -> None: + """Encrypt and persist ``value`` under ``name``.""" + if not isinstance(name, str) or not name: + raise ValueError("secret name must be a non-empty string") + if not isinstance(value, str): + raise ValueError("secret value must be a string") + with self._lock: + self._require_unlocked() + token = self._fernet.encrypt(value.encode("utf-8")).decode("ascii") + self._vault["items"][name] = token # type: ignore[index] + _atomic_write(self._path, self._vault) # type: ignore[arg-type] + + def get(self, name: str) -> Optional[str]: + """Return the plaintext for ``name`` or ``None`` if unset.""" + with self._lock: + self._require_unlocked() + token = self._vault["items"].get(name) # type: ignore[index] + if token is None: + return None + from cryptography.fernet import InvalidToken + try: + return self._fernet.decrypt(token.encode("ascii")).decode("utf-8") + except InvalidToken as error: + raise SecretStoreError( + f"secret {name!r} failed integrity check" + ) from error + + def list_names(self) -> List[str]: + """Return secret names sorted alphabetically (no values).""" + with self._lock: + self._require_unlocked() + return sorted(self._vault["items"].keys()) # type: ignore[index] + + def remove(self, name: str) -> bool: + """Delete ``name`` from the vault; return False if it was absent.""" + with self._lock: + self._require_unlocked() + if name not in self._vault["items"]: # type: ignore[index] + return False + del self._vault["items"][name] # type: ignore[index] + _atomic_write(self._path, self._vault) # type: ignore[arg-type] + return True + + def change_passphrase(self, old: str, new: str) -> None: + """Re-encrypt the entire vault under a new passphrase.""" + if not isinstance(new, str) or not new: + raise ValueError("new passphrase must be a non-empty string") + with self._lock: + if not self.unlock(old): + raise SecretStoreError("current passphrase incorrect") + plaintexts: Dict[str, str] = { + name: self.get(name) or "" + for name in self.list_names() + } + self.lock() + self._path.unlink() + self.initialize(new) + for name, value in plaintexts.items(): + self.set(name, value) + + def destroy(self) -> None: + """Delete the vault file (after confirming via direct call).""" + with self._lock: + self.lock() + try: + self._path.unlink() + except FileNotFoundError: + pass + + def _require_unlocked(self) -> None: + if self._fernet is None or self._vault is None: + raise SecretStoreLocked("secret vault is locked") + + +default_secret_manager = SecretManager() diff --git a/je_auto_control/utils/triggers/__init__.py b/je_auto_control/utils/triggers/__init__.py index 2bd894b7..36cfdec3 100644 --- a/je_auto_control/utils/triggers/__init__.py +++ b/je_auto_control/utils/triggers/__init__.py @@ -1,10 +1,19 @@ -"""Event-driven trigger engine (image / window / pixel / file watchers).""" +"""Event-driven trigger engine (image / window / pixel / file / webhook / email).""" +from je_auto_control.utils.triggers.email_trigger import ( + EmailTrigger, EmailTriggerWatcher, default_email_trigger_watcher, +) from je_auto_control.utils.triggers.trigger_engine import ( FilePathTrigger, ImageAppearsTrigger, PixelColorTrigger, TriggerEngine, WindowAppearsTrigger, default_trigger_engine, ) +from je_auto_control.utils.triggers.webhook_server import ( + WebhookTrigger, WebhookTriggerServer, default_webhook_server, +) __all__ = [ "FilePathTrigger", "ImageAppearsTrigger", "PixelColorTrigger", "TriggerEngine", "WindowAppearsTrigger", "default_trigger_engine", + "WebhookTrigger", "WebhookTriggerServer", "default_webhook_server", + "EmailTrigger", "EmailTriggerWatcher", + "default_email_trigger_watcher", ] diff --git a/je_auto_control/utils/triggers/email_trigger.py b/je_auto_control/utils/triggers/email_trigger.py new file mode 100644 index 00000000..b89c07cc --- /dev/null +++ b/je_auto_control/utils/triggers/email_trigger.py @@ -0,0 +1,340 @@ +"""IMAP poll trigger: fire a script when a matching email arrives. + +Each watcher entry connects to an IMAP mailbox on a configurable +schedule and runs an action JSON file once per matching message. When +a message fires, the executor receives the parsed metadata as variables +(``email.from``, ``email.subject``, ``email.body`` …) so the script can +react to the contents through ``${email.subject}`` placeholders. + +Polling — not IDLE — is used so the implementation stays standard- +library only and survives flaky network paths. Messages are matched +once: by default the watcher marks the message as ``\\Seen`` after a +successful fire so the same email is not handled twice across +restarts. +""" +import email +import email.policy +import imaplib +import ssl as ssl_module +import threading +import time +import uuid +from dataclasses import dataclass, field +from email.header import decode_header, make_header +from typing import Any, Callable, Dict, Iterable, List, Optional + +from je_auto_control.utils.json.json_file import read_action_json +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.run_history.artifact_manager import ( + capture_error_snapshot, +) +from je_auto_control.utils.run_history.history_store import ( + SOURCE_TRIGGER, STATUS_ERROR, STATUS_OK, default_history_store, +) + + +_DEFAULT_POLL_SECONDS = 60.0 +_MIN_POLL_SECONDS = 5.0 +_DEFAULT_PORT_SSL = 993 +_DEFAULT_PORT_PLAIN = 143 + + +@dataclass +class EmailTrigger: + """One IMAP mailbox → action-script binding.""" + trigger_id: str + host: str + username: str + password: str + script_path: str + port: int = _DEFAULT_PORT_SSL + use_ssl: bool = True + mailbox: str = "INBOX" + search_criteria: str = "UNSEEN" + mark_seen: bool = True + poll_seconds: float = _DEFAULT_POLL_SECONDS + enabled: bool = True + fired: int = 0 + last_error: Optional[str] = None + _seen_uids: set = field(default_factory=set, repr=False) + + +def _decode_header_value(value: Optional[str]) -> str: + if not value: + return "" + try: + return str(make_header(decode_header(value))) + except ValueError: + # UnicodeDecodeError is a subclass of ValueError; one entry is enough. + return str(value) + + +def _extract_text_body(msg) -> str: + """Return the first text/plain part as a string, falling back to the body.""" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain" \ + and "attachment" not in (part.get("Content-Disposition") or ""): + try: + return part.get_content().strip() + except (LookupError, ValueError): + continue + return "" + try: + return (msg.get_content() or "").strip() + except (LookupError, ValueError): + return "" + + +def _build_payload(uid: str, msg) -> Dict[str, Any]: + return { + "email.uid": uid, + "email.from": _decode_header_value(msg.get("From")), + "email.to": _decode_header_value(msg.get("To")), + "email.subject": _decode_header_value(msg.get("Subject")), + "email.message_id": msg.get("Message-ID", ""), + "email.date": msg.get("Date", ""), + "email.body": _extract_text_body(msg), + } + + +def _connect(trigger: EmailTrigger) -> imaplib.IMAP4: + """Open and authenticate against the IMAP server.""" + context = ssl_module.create_default_context() + # Pin a modern TLS floor; create_default_context already does this on + # 3.10+, but stating it explicitly satisfies python:S4423. + context.minimum_version = ssl_module.TLSVersion.TLSv1_2 + if trigger.use_ssl: + client = imaplib.IMAP4_SSL(trigger.host, trigger.port, + ssl_context=context) + else: + client = imaplib.IMAP4(trigger.host, trigger.port) + client.login(trigger.username, trigger.password) + return client + + +def _search_uids(client: imaplib.IMAP4, criteria: str) -> List[bytes]: + typ, data = client.uid("SEARCH", None, criteria or "UNSEEN") + if typ != "OK" or not data or not data[0]: + return [] + return data[0].split() + + +def _fetch_message(client: imaplib.IMAP4, uid: bytes): + typ, data = client.uid("FETCH", uid, "(RFC822)") + if typ != "OK" or not data or data[0] is None: + return None + raw = data[0][1] if isinstance(data[0], tuple) else data[0] + if not isinstance(raw, (bytes, bytearray)): + return None + return email.message_from_bytes(bytes(raw), policy=email.policy.default) + + +def _mark_seen(client: imaplib.IMAP4, uid: bytes) -> None: + try: + client.uid("STORE", uid, "+FLAGS", "(\\Seen)") + except imaplib.IMAP4.error as error: + autocontrol_logger.warning("imap mark seen failed: %r", error) + + +class EmailTriggerWatcher: + """Polls registered IMAP triggers from a single background thread.""" + + def __init__(self, + executor: Optional[Callable[[list, Dict[str, Any]], Any]] = None, + ) -> None: + self._lock = threading.RLock() + self._fire_lock = threading.Lock() + self._triggers: Dict[str, EmailTrigger] = {} + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + if executor is None: + self._executor = self._default_executor + else: + self._executor = executor + + @property + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def add(self, + host: str, username: str, password: str, script_path: str, + *, + port: Optional[int] = None, + use_ssl: bool = True, + mailbox: str = "INBOX", + search_criteria: str = "UNSEEN", + mark_seen: bool = True, + poll_seconds: float = _DEFAULT_POLL_SECONDS) -> EmailTrigger: + """Register a new IMAP trigger.""" + if not host or not username or not script_path: + raise ValueError( + "host, username, and script_path are required", + ) + if port is not None: + resolved_port = int(port) + elif use_ssl: + resolved_port = _DEFAULT_PORT_SSL + else: + resolved_port = _DEFAULT_PORT_PLAIN + trigger = EmailTrigger( + trigger_id=uuid.uuid4().hex[:8], + host=str(host), username=str(username), password=str(password), + script_path=str(script_path), + port=resolved_port, use_ssl=bool(use_ssl), + mailbox=str(mailbox or "INBOX"), + search_criteria=str(search_criteria or "UNSEEN"), + mark_seen=bool(mark_seen), + poll_seconds=max(_MIN_POLL_SECONDS, float(poll_seconds)), + ) + with self._lock: + self._triggers[trigger.trigger_id] = trigger + return trigger + + def remove(self, trigger_id: str) -> bool: + with self._lock: + return self._triggers.pop(trigger_id, None) is not None + + def list_triggers(self) -> List[EmailTrigger]: + with self._lock: + return list(self._triggers.values()) + + def set_enabled(self, trigger_id: str, enabled: bool) -> bool: + with self._lock: + trigger = self._triggers.get(trigger_id) + if trigger is None: + return False + trigger.enabled = bool(enabled) + return True + + def start(self) -> None: + with self._lock: + if self.is_running: + return + self._stop.clear() + self._thread = threading.Thread( + target=self._run, name="AutoControlEmailTrigger", + daemon=True, + ) + self._thread.start() + + def stop(self, timeout: float = 5.0) -> None: + self._stop.set() + thread = self._thread + if thread is not None: + thread.join(timeout=timeout) + self._thread = None + + def poll_once(self) -> int: + """Run exactly one polling pass; return total messages fired.""" + return self._poll_pass() + + def _run(self) -> None: + last_check: Dict[str, float] = {} + while not self._stop.is_set(): + now = time.monotonic() + for trigger in self.list_triggers(): + if not trigger.enabled: + continue + if now - last_check.get(trigger.trigger_id, 0.0) \ + < trigger.poll_seconds: + continue + self._poll_one(trigger) + last_check[trigger.trigger_id] = now + self._stop.wait(1.0) + + def _poll_pass(self) -> int: + fired = 0 + for trigger in self.list_triggers(): + if trigger.enabled: + fired += self._poll_one(trigger) + return fired + + def _poll_one(self, trigger: EmailTrigger) -> int: + try: + client = _connect(trigger) + except (OSError, imaplib.IMAP4.error) as error: + self._record_connect_error(trigger, error) + return 0 + fired = 0 + try: + typ, _ = client.select(trigger.mailbox, readonly=False) + if typ != "OK": + trigger.last_error = f"select {trigger.mailbox} failed" + return 0 + for uid in self._iter_unprocessed_uids(client, trigger): + fired += self._fire_for_uid(client, trigger, uid) + finally: + try: + client.logout() + except (imaplib.IMAP4.error, OSError): + pass + return fired + + def _iter_unprocessed_uids(self, client: imaplib.IMAP4, + trigger: EmailTrigger) -> Iterable[bytes]: + for uid in _search_uids(client, trigger.search_criteria): + uid_str = uid.decode("ascii", errors="replace") + if uid_str in trigger._seen_uids: + continue + yield uid + + def _record_connect_error(self, trigger: EmailTrigger, + error: Exception) -> None: + trigger.last_error = repr(error) + autocontrol_logger.error("imap %s connect failed: %r", + trigger.trigger_id, error) + + def _fire_for_uid(self, client: imaplib.IMAP4, + trigger: EmailTrigger, uid: bytes) -> int: + msg = _fetch_message(client, uid) + if msg is None: + return 0 + uid_str = uid.decode("ascii", errors="replace") + payload = _build_payload(uid_str, msg) + try: + self._execute_with_history(trigger, payload) + except (OSError, ValueError, RuntimeError) as error: + trigger.last_error = repr(error) + autocontrol_logger.error("imap %s fire failed: %r", + trigger.trigger_id, error) + else: + trigger.last_error = None + trigger._seen_uids.add(uid_str) + if trigger.mark_seen: + _mark_seen(client, uid) + return 1 + + def _execute_with_history(self, trigger: EmailTrigger, + payload: Dict[str, Any]) -> None: + with self._fire_lock: + run_id = default_history_store.start_run( + SOURCE_TRIGGER, f"email:{trigger.trigger_id}", + trigger.script_path, + ) + status = STATUS_OK + error_text: Optional[str] = None + try: + actions = read_action_json(trigger.script_path) + self._executor(actions, payload) + except (OSError, ValueError, RuntimeError) as error: + status = STATUS_ERROR + error_text = repr(error) + raise + finally: + artifact = (capture_error_snapshot(run_id) + if status == STATUS_ERROR else None) + default_history_store.finish_run( + run_id, status, error_text, artifact_path=artifact, + ) + trigger.fired += 1 + + @staticmethod + def _default_executor(actions: list, variables: Dict[str, Any]) -> Any: + from je_auto_control.utils.executor.action_executor import ( + execute_action_with_vars, + ) + return execute_action_with_vars(actions, variables) + + +default_email_trigger_watcher = EmailTriggerWatcher() diff --git a/je_auto_control/utils/triggers/webhook_server.py b/je_auto_control/utils/triggers/webhook_server.py new file mode 100644 index 00000000..07cf6088 --- /dev/null +++ b/je_auto_control/utils/triggers/webhook_server.py @@ -0,0 +1,338 @@ +"""HTTP push trigger: fire a script when an external service POSTs to us. + +Existing triggers poll the screen / file system; webhooks are different +because the firing event is *pushed* by another process. This module +runs a small :class:`http.server.ThreadingHTTPServer` and dispatches +requests to registered webhook entries. + +Each registered webhook owns a path + optional method allowlist + an +optional bearer token. When a matching request arrives the server seeds +the executor's variable scope with:: + + webhook.method - request method (string) + webhook.path - request path (string) + webhook.query - decoded query string as ``dict[str, list[str]]`` + webhook.headers - case-insensitive dict of header values + webhook.body - raw request body text (string) + webhook.json - parsed JSON body if Content-Type permits, else None + +…and runs the configured action JSON file. Each fire is recorded in +``run_history`` under :data:`SOURCE_TRIGGER` so existing dashboards +surface webhook activity alongside other triggers. + +Security defaults: bind 127.0.0.1; bodies are size-limited; token +comparison is constant-time. Script paths are resolved once on +registration and frozen — clients cannot influence which file runs. +""" +import hmac +import json +import threading +import uuid +from dataclasses import dataclass +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Callable, Dict, List, Optional, Tuple +from urllib.parse import parse_qs, urlparse + +from je_auto_control.utils.json.json_file import read_action_json +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.run_history.artifact_manager import ( + capture_error_snapshot, +) +from je_auto_control.utils.run_history.history_store import ( + SOURCE_TRIGGER, STATUS_ERROR, STATUS_OK, default_history_store, +) + + +_DEFAULT_BIND = "127.0.0.1" +_MAX_BODY_BYTES = 1 << 20 # 1 MiB cap + + +@dataclass +class WebhookTrigger: + """One registered webhook → script binding.""" + webhook_id: str + path: str + script_path: str + methods: Tuple[str, ...] = ("POST",) + token: Optional[str] = None + enabled: bool = True + fired: int = 0 + last_status: int = 0 + + +def _normalize_methods(methods: Optional[List[str]]) -> Tuple[str, ...]: + if not methods: + return ("POST",) + seen: List[str] = [] + for raw in methods: + method = str(raw).upper().strip() + if method and method not in seen: + seen.append(method) + return tuple(seen) or ("POST",) + + +def _normalize_path(path: str) -> str: + cleaned = path.strip() + if not cleaned: + raise ValueError("webhook path must not be empty") + if not cleaned.startswith("/"): + cleaned = "/" + cleaned + return cleaned + + +def _maybe_parse_json(content_type: str, body: str) -> Optional[Any]: + if not body: + return None + if "json" not in (content_type or "").lower(): + return None + try: + return json.loads(body) + except ValueError: + return None + + +class _WebhookHandler(BaseHTTPRequestHandler): + """HTTP handler dispatched by :class:`WebhookTriggerServer`.""" + + server_version = "AutoControlWebhook/1.0" + + # Signature must mirror BaseHTTPRequestHandler.log_message exactly, + # including the parameter name 'format' — pylint W0221 trips on + # rename or annotation drift; the shadow of the stdlib 'format' is + # the parent class's choice, not ours. + # pylint: disable=redefined-builtin + def log_message(self, format, *args): # noqa: A002 + autocontrol_logger.debug("webhook %s", format % args) + # pylint: enable=redefined-builtin + + def _read_body(self) -> str: + length = int(self.headers.get("Content-Length") or 0) + if length <= 0: + return "" + if length > _MAX_BODY_BYTES: + self.send_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + "body too large") + return "" + raw = self.rfile.read(length) + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw.decode("latin-1", errors="replace") + + def _collect_headers(self) -> Dict[str, str]: + return {key.lower(): value for key, value in self.headers.items()} + + def _send_json(self, status: HTTPStatus, payload: Dict[str, Any]) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(int(status)) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _dispatch(self, method: str) -> None: + registry: WebhookTriggerServer = self.server.webhook_owner # type: ignore[attr-defined] + parsed = urlparse(self.path) + trigger = registry.match(parsed.path, method) + if trigger is None: + self.send_error(HTTPStatus.NOT_FOUND, "no webhook bound") + return + if not registry.authorize(trigger, self.headers.get("Authorization")): + self.send_error(HTTPStatus.UNAUTHORIZED, "bad token") + return + body = self._read_body() + if body == "" and int(self.headers.get("Content-Length") or 0) > 0: + return # _read_body already wrote an error response + payload = { + "webhook.method": method, + "webhook.path": parsed.path, + "webhook.query": parse_qs(parsed.query, keep_blank_values=True), + "webhook.headers": self._collect_headers(), + "webhook.body": body, + "webhook.json": _maybe_parse_json( + self.headers.get("Content-Type", ""), body, + ), + } + run_id = registry.fire(trigger, payload) + self._send_json(HTTPStatus.OK, {"run_id": run_id, "fired": True}) + + def do_GET(self) -> None: # noqa: N802 - http.server contract + self._dispatch("GET") + + def do_POST(self) -> None: # noqa: N802 + self._dispatch("POST") + + def do_PUT(self) -> None: # noqa: N802 + self._dispatch("PUT") + + def do_DELETE(self) -> None: # noqa: N802 + self._dispatch("DELETE") + + +class WebhookTriggerServer: + """Push-based trigger: external HTTP requests fire the registered script.""" + + def __init__(self, + executor: Optional[Callable[[list, Dict[str, Any]], Any]] = None, + ) -> None: + self._lock = threading.RLock() + # Serialise firings so concurrent webhook hits do not race on the + # global executor's shared variable scope. + self._fire_lock = threading.Lock() + self._triggers: Dict[str, WebhookTrigger] = {} + self._server: Optional[ThreadingHTTPServer] = None + self._thread: Optional[threading.Thread] = None + self._bound: Optional[Tuple[str, int]] = None + if executor is None: + self._executor = self._default_executor + else: + self._executor = executor + + @property + def is_running(self) -> bool: + return self._server is not None + + @property + def bound_address(self) -> Optional[Tuple[str, int]]: + """Return ``(host, port)`` once the server is listening.""" + return self._bound + + def add(self, + path: str, + script_path: str, + methods: Optional[List[str]] = None, + token: Optional[str] = None, + ) -> WebhookTrigger: + """Register a new webhook → script binding.""" + normalized_path = _normalize_path(path) + if not script_path: + raise ValueError("script_path is required") + trigger = WebhookTrigger( + webhook_id=uuid.uuid4().hex[:8], + path=normalized_path, + script_path=str(script_path), + methods=_normalize_methods(methods), + token=token if token is not None else None, + ) + with self._lock: + for existing in self._triggers.values(): + if existing.path == normalized_path \ + and set(existing.methods) & set(trigger.methods): + raise ValueError( + f"webhook conflict: {normalized_path} already bound " + f"to id {existing.webhook_id}" + ) + self._triggers[trigger.webhook_id] = trigger + return trigger + + def remove(self, webhook_id: str) -> bool: + with self._lock: + return self._triggers.pop(webhook_id, None) is not None + + def set_enabled(self, webhook_id: str, enabled: bool) -> bool: + with self._lock: + trigger = self._triggers.get(webhook_id) + if trigger is None: + return False + trigger.enabled = bool(enabled) + return True + + def list_webhooks(self) -> List[WebhookTrigger]: + with self._lock: + return list(self._triggers.values()) + + def match(self, path: str, method: str) -> Optional[WebhookTrigger]: + with self._lock: + for trigger in self._triggers.values(): + if not trigger.enabled: + continue + if trigger.path == path and method in trigger.methods: + return trigger + return None + + def authorize(self, trigger: WebhookTrigger, + auth_header: Optional[str]) -> bool: + if not trigger.token: + return True + if not auth_header: + return False + expected = f"Bearer {trigger.token}".encode("utf-8") + return hmac.compare_digest(auth_header.encode("utf-8"), expected) + + def fire(self, trigger: WebhookTrigger, + payload: Dict[str, Any]) -> Optional[int]: + """Run the trigger's script with ``payload`` seeded into variables.""" + with self._fire_lock: + run_id = default_history_store.start_run( + SOURCE_TRIGGER, f"webhook:{trigger.webhook_id}", + trigger.script_path, + ) + status = STATUS_OK + error_text: Optional[str] = None + try: + actions = read_action_json(trigger.script_path) + self._executor(actions, payload) + except (OSError, ValueError, RuntimeError) as error: + status = STATUS_ERROR + error_text = repr(error) + autocontrol_logger.error("webhook %s failed: %r", + trigger.webhook_id, error) + finally: + artifact = (capture_error_snapshot(run_id) + if status == STATUS_ERROR else None) + default_history_store.finish_run( + run_id, status, error_text, artifact_path=artifact, + ) + with self._lock: + live = self._triggers.get(trigger.webhook_id) + if live is not None: + live.fired += 1 + live.last_status = 200 if status == STATUS_OK else 500 + return run_id + + def start(self, host: str = _DEFAULT_BIND, port: int = 0) -> Tuple[str, int]: + """Start the HTTP server; idempotent if already running.""" + with self._lock: + if self._server is not None and self._bound is not None: + return self._bound + server = ThreadingHTTPServer((host, int(port)), _WebhookHandler) + server.webhook_owner = self # type: ignore[attr-defined] + self._server = server + actual_host, actual_port = server.server_address[:2] + self._bound = (str(actual_host), int(actual_port)) + self._thread = threading.Thread( + target=server.serve_forever, + name="AutoControlWebhook", + kwargs={"poll_interval": 0.2}, + daemon=True, + ) + self._thread.start() + return self._bound + + def stop(self, timeout: float = 2.0) -> None: + with self._lock: + server = self._server + thread = self._thread + self._server = None + self._thread = None + self._bound = None + if server is not None: + try: + server.shutdown() + server.server_close() + except OSError as error: + autocontrol_logger.warning("webhook stop error: %r", error) + if thread is not None: + thread.join(timeout=timeout) + + @staticmethod + def _default_executor(actions: list, variables: Dict[str, Any]) -> Any: + """Default: thread-safe global executor with ``variables`` seeded.""" + from je_auto_control.utils.executor.action_executor import ( + execute_action_with_vars, + ) + return execute_action_with_vars(actions, variables) + + +default_webhook_server = WebhookTriggerServer() diff --git a/je_auto_control/windows/interception/__init__.py b/je_auto_control/windows/interception/__init__.py new file mode 100644 index 00000000..1517eb46 --- /dev/null +++ b/je_auto_control/windows/interception/__init__.py @@ -0,0 +1,29 @@ +"""Optional Interception-driver backend for Windows keyboard / mouse input. + +The default Windows backend in ``windows/keyboard`` and ``windows/mouse`` +uses ``SendInput`` — fine for most apps, but games that read input via +``GetRawInputData`` ignore synthetic SendInput events. The Interception +driver (https://github.com/oblitum/Interception) is a WHQL-signed +filter driver that injects events at the HID layer, so synthetic input +becomes indistinguishable from a real device. + +This package provides: + +* :mod:`._dll` — :class:`ctypes` bindings to ``interception.dll``. +* :mod:`.keyboard` — same public surface as + :mod:`je_auto_control.windows.keyboard.win32_ctype_keyboard_control`. +* :mod:`.mouse` — same public surface as + :mod:`je_auto_control.windows.mouse.win32_ctype_mouse_control`. + +The driver is **not** bundled. It is installed once with admin +privileges via the project's installer. Set +``JE_AUTOCONTROL_WIN32_BACKEND=interception`` to use this backend; if +the DLL or driver is unavailable the platform wrapper falls back to +``SendInput`` with a warning, so deployments with the env var set can +roll the driver out lazily. +""" +from je_auto_control.windows.interception._dll import ( + InterceptionUnavailable, is_available, load_context, +) + +__all__ = ["InterceptionUnavailable", "is_available", "load_context"] diff --git a/je_auto_control/windows/interception/_dll.py b/je_auto_control/windows/interception/_dll.py new file mode 100644 index 00000000..35139ed8 --- /dev/null +++ b/je_auto_control/windows/interception/_dll.py @@ -0,0 +1,230 @@ +"""Lazy ctypes loader + Structures for ``interception.dll``. + +We do **not** require the DLL at import time — picking up +``import je_auto_control`` on a machine without the driver should not +explode. :func:`load_context` performs the actual load on demand and +raises :class:`InterceptionUnavailable` with an explanatory message +when anything is missing, letting the platform wrapper fall back to +``SendInput`` cleanly. +""" +from __future__ import annotations + +import ctypes +import os +import threading +from ctypes import wintypes +from typing import Optional + + +class InterceptionUnavailable(RuntimeError): + """Raised when ``interception.dll`` or the kernel driver is missing.""" + + +# --- C structs --------------------------------------------------------------- + + +class InterceptionKeyStroke(ctypes.Structure): + """Keyboard stroke as defined in ``interception.h``.""" + + _fields_ = [ + ("code", ctypes.c_ushort), # Set-1 scancode + ("state", ctypes.c_ushort), # bit flags below + ("information", ctypes.c_uint), # reserved / driver-specific + ] + + +class InterceptionMouseStroke(ctypes.Structure): + """Mouse stroke as defined in ``interception.h``.""" + + _fields_ = [ + ("state", ctypes.c_ushort), # button-flags bitmap + ("flags", ctypes.c_ushort), # MOVE_RELATIVE / _ABSOLUTE / etc. + ("rolling", ctypes.c_short), # wheel delta (signed) + ("x", ctypes.c_int), + ("y", ctypes.c_int), + ("information", ctypes.c_uint), + ] + + +# --- key state flags --------------------------------------------------------- + +KEY_DOWN = 0x00 +KEY_UP = 0x01 +KEY_E0 = 0x02 # extended scancode prefix +KEY_E1 = 0x04 # extended scancode prefix (rare; Pause key) + +# --- mouse flags / state ----------------------------------------------------- + +MOUSE_MOVE_RELATIVE = 0x000 +MOUSE_MOVE_ABSOLUTE = 0x001 +MOUSE_VIRTUAL_DESKTOP = 0x002 +MOUSE_ATTRIBUTES_CHANGED = 0x004 + +MOUSE_LEFT_DOWN = 0x001 +MOUSE_LEFT_UP = 0x002 +MOUSE_RIGHT_DOWN = 0x004 +MOUSE_RIGHT_UP = 0x008 +MOUSE_MIDDLE_DOWN = 0x010 +MOUSE_MIDDLE_UP = 0x020 +MOUSE_BUTTON4_DOWN = 0x040 +MOUSE_BUTTON4_UP = 0x080 +MOUSE_BUTTON5_DOWN = 0x100 +MOUSE_BUTTON5_UP = 0x200 +MOUSE_WHEEL = 0x400 + + +# --- module-level singletons (lazy-init, double-checked) --------------------- + +_load_lock = threading.Lock() +_dll: Optional[ctypes.CDLL] = None +_context = None # InterceptionContext (opaque pointer) + + +def _resolve_dll_path() -> str: + """Pick the DLL path: explicit env var first, then PATH, then bundled.""" + explicit = os.environ.get("JE_AUTOCONTROL_INTERCEPTION_DLL") + if explicit: + return explicit + return "interception.dll" + + +def _load_dll() -> ctypes.CDLL: + """Load and prototype ``interception.dll``.""" + path = _resolve_dll_path() + try: + dll = ctypes.WinDLL(path) + except OSError as exc: + raise InterceptionUnavailable( + f"could not load {path!r}: {exc}. Install the driver from " + "https://github.com/oblitum/Interception or set " + "JE_AUTOCONTROL_INTERCEPTION_DLL to its full path." + ) from exc + + # Prototype the few functions we actually call. The full API has + # filter / wait / receive helpers used for hooking — we only inject. + dll.interception_create_context.restype = ctypes.c_void_p + dll.interception_create_context.argtypes = [] + + dll.interception_destroy_context.restype = None + dll.interception_destroy_context.argtypes = [ctypes.c_void_p] + + dll.interception_send.restype = ctypes.c_int + dll.interception_send.argtypes = [ + ctypes.c_void_p, # context + ctypes.c_int, # device id + ctypes.c_void_p, # stroke pointer + ctypes.c_uint, # n strokes + ] + + dll.interception_is_keyboard.restype = ctypes.c_int + dll.interception_is_keyboard.argtypes = [ctypes.c_int] + + dll.interception_is_mouse.restype = ctypes.c_int + dll.interception_is_mouse.argtypes = [ctypes.c_int] + return dll + + +def _create_context(dll: ctypes.CDLL) -> ctypes.c_void_p: + """Open a driver context; raise if the kernel side is missing.""" + ctx = dll.interception_create_context() + if not ctx: + raise InterceptionUnavailable( + "interception_create_context returned NULL — the kernel " + "driver is most likely not installed or the service is " + "stopped. Run install-interception.exe as Administrator " + "and reboot." + ) + return ctypes.c_void_p(ctx) + + +def is_available() -> bool: + """Return True if both DLL and driver context are reachable. + + Cheap check used by the wrapper's backend selector — it does *not* + keep the context open after the probe. + """ + try: + load_context() + except InterceptionUnavailable: + return False + return True + + +def load_context() -> tuple[ctypes.CDLL, ctypes.c_void_p]: + """Return ``(dll, context)`` lazily — opened once per process.""" + global _dll, _context + if _dll is not None and _context is not None: + return _dll, _context + with _load_lock: + if _dll is None: + _dll = _load_dll() + if _context is None: + _context = _create_context(_dll) + return _dll, _context + + +# --- helpers used by the keyboard / mouse modules --------------------------- + + +_DEFAULT_KEYBOARD_DEVICE = 1 +_DEFAULT_MOUSE_DEVICE = 11 + + +def default_keyboard_device() -> int: + """Driver device id used when injecting keyboard strokes.""" + return int( + os.environ.get( + "JE_AUTOCONTROL_INTERCEPTION_KEYBOARD", + _DEFAULT_KEYBOARD_DEVICE, + ) + ) + + +def default_mouse_device() -> int: + """Driver device id used when injecting mouse strokes.""" + return int( + os.environ.get( + "JE_AUTOCONTROL_INTERCEPTION_MOUSE", + _DEFAULT_MOUSE_DEVICE, + ) + ) + + +# --- vk → scancode helper --------------------------------------------------- + +_user32 = ctypes.WinDLL("user32", use_last_error=True) +_user32.MapVirtualKeyW.restype = wintypes.UINT +_user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT] + +_MAPVK_VK_TO_VSC_EX = 4 # returns the extended-scancode-prefixed value + + +def vk_to_scancode(vk_code: int) -> tuple[int, bool]: + """Resolve a Win32 VK code to ``(scancode, is_extended)``. + + The Interception driver wants Set-1 scancodes; ``MapVirtualKeyW`` + with ``MAPVK_VK_TO_VSC_EX`` returns the high byte set to ``0xE0`` + when the key needs the extended prefix (arrow keys, numeric pad + enter, etc.). We split that into ``(low_byte, extended_bool)`` + so the caller can choose ``KEY_E0``. + """ + raw = int(_user32.MapVirtualKeyW(int(vk_code), _MAPVK_VK_TO_VSC_EX)) + is_extended = (raw & 0xE000) == 0xE000 + return raw & 0x00FF, is_extended + + +__all__ = [ + "InterceptionUnavailable", + "InterceptionKeyStroke", + "InterceptionMouseStroke", + "KEY_DOWN", "KEY_UP", "KEY_E0", "KEY_E1", + "MOUSE_MOVE_RELATIVE", "MOUSE_MOVE_ABSOLUTE", "MOUSE_VIRTUAL_DESKTOP", + "MOUSE_LEFT_DOWN", "MOUSE_LEFT_UP", + "MOUSE_RIGHT_DOWN", "MOUSE_RIGHT_UP", + "MOUSE_MIDDLE_DOWN", "MOUSE_MIDDLE_UP", + "MOUSE_BUTTON4_DOWN", "MOUSE_BUTTON4_UP", + "MOUSE_BUTTON5_DOWN", "MOUSE_BUTTON5_UP", + "MOUSE_WHEEL", + "default_keyboard_device", "default_mouse_device", + "is_available", "load_context", "vk_to_scancode", +] diff --git a/je_auto_control/windows/interception/keyboard.py b/je_auto_control/windows/interception/keyboard.py new file mode 100644 index 00000000..0a6d242e --- /dev/null +++ b/je_auto_control/windows/interception/keyboard.py @@ -0,0 +1,70 @@ +"""Keyboard input via the Interception driver. + +Public surface mirrors :mod:`win32_ctype_keyboard_control` so the +platform wrapper can swap modules at import time without touching +callers. +""" +from __future__ import annotations + +import ctypes +import sys + +from je_auto_control.utils.exception.exception_tags import ( + windows_import_error_message, +) +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.windows.interception._dll import ( + KEY_DOWN, KEY_E0, KEY_UP, InterceptionKeyStroke, + InterceptionUnavailable, default_keyboard_device, load_context, + vk_to_scancode, +) + +if sys.platform not in ("win32", "cygwin", "msys"): + raise AutoControlException(windows_import_error_message) + + +def _send_stroke(scancode: int, *, key_up: bool, extended: bool) -> None: + """Push one keystroke through the Interception driver.""" + dll, ctx = load_context() + state = (KEY_UP if key_up else KEY_DOWN) | (KEY_E0 if extended else 0) + stroke = InterceptionKeyStroke( + code=scancode & 0xFFFF, + state=state, + information=0, + ) + sent = dll.interception_send( + ctx, default_keyboard_device(), + ctypes.byref(stroke), 1, + ) + if sent != 1: + raise InterceptionUnavailable( + "interception_send returned 0 — the device id " + f"{default_keyboard_device()!r} is likely wrong; set " + "JE_AUTOCONTROL_INTERCEPTION_KEYBOARD to a valid id " + "(1–10)." + ) + + +def press_key(keycode: int) -> None: + """Press ``keycode`` (a Win32 VK code) via the Interception driver.""" + scancode, extended = vk_to_scancode(int(keycode)) + _send_stroke(scancode, key_up=False, extended=extended) + + +def release_key(keycode: int) -> None: + """Release ``keycode`` (a Win32 VK code) via the Interception driver.""" + scancode, extended = vk_to_scancode(int(keycode)) + _send_stroke(scancode, key_up=True, extended=extended) + + +def send_key_event_to_window(window: str, keycode: int) -> None: + """Inject a press+release pair for ``window``. + + Interception talks to the kernel HID stack, not a single window — + so the targeted-window contract degrades to "press at the focused + window". Caller is expected to focus the window first via the + standard window-manager helpers. + """ + del window # see docstring; kept for API parity with SendInput backend + press_key(int(keycode)) + release_key(int(keycode)) diff --git a/je_auto_control/windows/interception/mouse.py b/je_auto_control/windows/interception/mouse.py new file mode 100644 index 00000000..48a01f0f --- /dev/null +++ b/je_auto_control/windows/interception/mouse.py @@ -0,0 +1,149 @@ +"""Mouse input via the Interception driver. + +Public surface mirrors :mod:`win32_ctype_mouse_control` so the +platform wrapper can swap modules at import time: + +* Button-flag tuples ``win32_mouse_left`` / ``_middle`` / ``_right`` / + ``_x1`` / ``_x2`` reuse the original variable names but carry the + Interception flag bits instead of the SendInput dwFlags. +* :func:`set_position` / :func:`position` go through ``user32`` exactly + like the SendInput backend — the cursor coordinate APIs in the + Interception driver are stroke-based and would force callers to + poll, which would break the contract callers already rely on. +""" +from __future__ import annotations + +import ctypes +import sys +from ctypes import windll, wintypes +from typing import Optional, Tuple + +from je_auto_control.utils.exception.exception_tags import ( + windows_import_error_message, +) +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.windows.interception._dll import ( + InterceptionMouseStroke, InterceptionUnavailable, + MOUSE_BUTTON4_DOWN, MOUSE_BUTTON4_UP, + MOUSE_BUTTON5_DOWN, MOUSE_BUTTON5_UP, + MOUSE_LEFT_DOWN, MOUSE_LEFT_UP, + MOUSE_MIDDLE_DOWN, MOUSE_MIDDLE_UP, + MOUSE_MOVE_ABSOLUTE, MOUSE_RIGHT_DOWN, MOUSE_RIGHT_UP, + MOUSE_WHEEL, + default_mouse_device, load_context, +) +from je_auto_control.windows.screen.win32_screen import size + +if sys.platform not in ("win32", "cygwin", "msys"): + raise AutoControlException(windows_import_error_message) + + +# Button tuples — same shape as the SendInput backend +# (release_flag, press_flag, mouse_data) so the wrapper can swap +# modules without touching dispatch tables. +win32_mouse_left: Tuple[int, int, int] = (MOUSE_LEFT_UP, MOUSE_LEFT_DOWN, 0) +win32_mouse_middle: Tuple[int, int, int] = (MOUSE_MIDDLE_UP, MOUSE_MIDDLE_DOWN, 0) +win32_mouse_right: Tuple[int, int, int] = (MOUSE_RIGHT_UP, MOUSE_RIGHT_DOWN, 0) +win32_mouse_x1: Tuple[int, int, int] = (MOUSE_BUTTON4_UP, MOUSE_BUTTON4_DOWN, 0) +win32_mouse_x2: Tuple[int, int, int] = (MOUSE_BUTTON5_UP, MOUSE_BUTTON5_DOWN, 0) + +_user32 = windll.user32 +_get_cursor_pos = _user32.GetCursorPos +_set_cursor_pos = _user32.SetCursorPos + + +def _to_absolute(x: int, y: int) -> Tuple[int, int]: + """Convert raw screen coords to the 0–65535 range Interception wants.""" + width, height = size() + if width <= 0 or height <= 0: + return 0, 0 + return 65535 * x // width, 65535 * y // height + + +def _send_stroke(state: int, *, flags: int = 0, + x: int = 0, y: int = 0, rolling: int = 0) -> None: + """Push one mouse stroke through the Interception driver.""" + dll, ctx = load_context() + stroke = InterceptionMouseStroke( + state=state & 0xFFFF, + flags=flags & 0xFFFF, + rolling=rolling, + x=int(x), + y=int(y), + information=0, + ) + sent = dll.interception_send( + ctx, default_mouse_device(), + ctypes.byref(stroke), 1, + ) + if sent != 1: + raise InterceptionUnavailable( + "interception_send returned 0 — the device id " + f"{default_mouse_device()!r} is likely wrong; set " + "JE_AUTOCONTROL_INTERCEPTION_MOUSE to a valid id (11–20)." + ) + + +def position() -> Optional[Tuple[int, int]]: + """Return ``(x, y)`` cursor position via ``GetCursorPos``.""" + point = wintypes.POINT() + if _get_cursor_pos(ctypes.byref(point)): + return point.x, point.y + return None + + +def set_position(x: int, y: int) -> None: + """Set the cursor via the absolute-move stroke (driver-level move).""" + abs_x, abs_y = _to_absolute(int(x), int(y)) + _send_stroke(0, flags=MOUSE_MOVE_ABSOLUTE, x=abs_x, y=abs_y) + # GetCursorPos still reflects the move because the driver's + # absolute path goes through the OS — but call the Win32 + # SetCursorPos as a belt-and-braces fallback when the driver is + # configured to ignore mouse-only injection (rare). + _set_cursor_pos(int(x), int(y)) + + +def press_mouse(press_button: Tuple[int, int, int]) -> None: + """Press a mouse button using the supplied (up, down, data) tuple.""" + _send_stroke(press_button[1]) + + +def release_mouse(release_button: Tuple[int, int, int]) -> None: + """Release a mouse button using the supplied (up, down, data) tuple.""" + _send_stroke(release_button[0]) + + +def click_mouse(mouse_keycode: Tuple[int, int, int], + x: Optional[int] = None, + y: Optional[int] = None) -> None: + """Move (when coords given), press and release in one shot.""" + if x is not None and y is not None: + set_position(int(x), int(y)) + press_mouse(mouse_keycode) + release_mouse(mouse_keycode) + + +def scroll(scroll_value: int, x: int = 0, y: int = 0) -> None: + """Wheel-scroll via the driver. ``x``/``y`` are kept for API parity.""" + del x, y # Interception scroll is delivered to the focused window + _send_stroke(MOUSE_WHEEL, rolling=int(scroll_value)) + + +def mouse_event(event: int, x: int, y: int, dw_data: int = 0) -> None: + """Free-form stroke for callers that already speak Interception flags.""" + _send_stroke(event, x=int(x), y=int(y), rolling=int(dw_data)) + + +def send_mouse_event_to_window(window, mouse_keycode: int, + x: int = 0, y: int = 0): + """Targeted-window injection — degraded to focused-window for the driver. + + Interception talks to the kernel HID stack, not a single window + handle. Caller is expected to focus the window first via the + standard window-manager helpers; we then perform a regular + set+click. + """ + del window + if x or y: + set_position(int(x), int(y)) + _send_stroke(int(mouse_keycode)) diff --git a/je_auto_control/wrapper/_platform_linux.py b/je_auto_control/wrapper/_platform_linux.py index 4e1f9fb0..b3e01984 100644 --- a/je_auto_control/wrapper/_platform_linux.py +++ b/je_auto_control/wrapper/_platform_linux.py @@ -215,9 +215,51 @@ "scroll_right": x11_linux_scroll_direction_right, } -keyboard = x11_linux_keyboard_control +def _select_input_backend(): + """Pick keyboard/mouse modules based on JE_AUTOCONTROL_LINUX_BACKEND. + + Default is ``x11`` (the existing XTest backend). Set the env var + to ``uinput`` to route synthetic input through ``/dev/uinput`` — + required for games that read evdev and ignore XTest. Falls back + to XTest with a warning when ``/dev/uinput`` isn't writable, so + deployments can roll permissions out lazily. + """ + import os + + from je_auto_control.utils.logging.logging_instance import ( + autocontrol_logger, + ) + backend = os.environ.get( + "JE_AUTOCONTROL_LINUX_BACKEND", "x11", + ).strip().lower() + if backend != "uinput": + return x11_linux_keyboard_control, x11_linux_mouse_control + try: + from je_auto_control.linux_with_x11.uinput import ( + keyboard as uinput_keyboard, + mouse as uinput_mouse, + is_available, + ) + except ImportError as error: + autocontrol_logger.warning( + "uinput backend requested but module import failed: " + "%r — falling back to XTest.", error, + ) + return x11_linux_keyboard_control, x11_linux_mouse_control + if not is_available(): + autocontrol_logger.warning( + "uinput backend requested but /dev/uinput is not writable " + "— falling back to XTest. Run " + "`sudo modprobe uinput && sudo chmod 666 /dev/uinput` " + "or install the udev rule documented in new_features.", + ) + return x11_linux_keyboard_control, x11_linux_mouse_control + autocontrol_logger.info("Linux input backend: uinput (kernel)") + return uinput_keyboard, uinput_mouse + + +keyboard, mouse = _select_input_backend() keyboard_check = x11_linux_listener -mouse = x11_linux_mouse_control screen = x11_linux_screen recorder = x11_linux_recoder diff --git a/je_auto_control/wrapper/_platform_windows.py b/je_auto_control/wrapper/_platform_windows.py index 55541f79..3506e28d 100644 --- a/je_auto_control/wrapper/_platform_windows.py +++ b/je_auto_control/wrapper/_platform_windows.py @@ -1,3 +1,5 @@ +import os + from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.windows.core.utils import win32_keypress_check @@ -56,6 +58,54 @@ from je_auto_control.windows.record.win32_record import win32_recorder from je_auto_control.windows.screen import win32_screen + +def _select_input_backend(): + """Pick keyboard/mouse modules based on JE_AUTOCONTROL_WIN32_BACKEND. + + Default is ``sendinput`` (the existing ctypes / SendInput backend). + Set the env var to ``interception`` to route synthetic input + through the Interception driver instead — required for games that + read input via ``GetRawInputData`` and ignore SendInput. Falls back + to SendInput with a warning when the driver isn't installed, so + deployments can roll the driver out lazily. + """ + backend = os.environ.get( + "JE_AUTOCONTROL_WIN32_BACKEND", "sendinput", + ).strip().lower() + if backend != "interception": + return win32_ctype_keyboard_control, win32_ctype_mouse_control + try: + from je_auto_control.windows.interception import ( + keyboard as interception_keyboard, + mouse as interception_mouse, + is_available, + ) + except ImportError as error: # defensive: optional sub-package + autocontrol_logger.warning( + "Interception backend requested but module import failed: " + "%r — falling back to SendInput.", error, + ) + return win32_ctype_keyboard_control, win32_ctype_mouse_control + if not is_available(): + autocontrol_logger.warning( + "Interception backend requested but the driver is not " + "available — falling back to SendInput. Install the " + "driver from https://github.com/oblitum/Interception", + ) + return win32_ctype_keyboard_control, win32_ctype_mouse_control + autocontrol_logger.info("Win32 input backend: Interception driver") + # Refresh the mouse-button tuples to use Interception flag bits + # so the wrapper's mouse_keys_table dispatches correctly. + global win32_mouse_left, win32_mouse_middle, win32_mouse_right + global win32_mouse_x1, win32_mouse_x2 + win32_mouse_left = interception_mouse.win32_mouse_left + win32_mouse_middle = interception_mouse.win32_mouse_middle + win32_mouse_right = interception_mouse.win32_mouse_right + win32_mouse_x1 = interception_mouse.win32_mouse_x1 + win32_mouse_x2 = interception_mouse.win32_mouse_x2 + return interception_keyboard, interception_mouse + + autocontrol_logger.info("Load Windows Setting") keyboard_keys_table = { @@ -262,9 +312,8 @@ } special_mouse_keys_table = None -keyboard = win32_ctype_keyboard_control +keyboard, mouse = _select_input_backend() keyboard_check = win32_keypress_check -mouse = win32_ctype_mouse_control screen = win32_screen recorder = win32_recorder diff --git a/pyproject.toml b/pyproject.toml index ecdc9db7..a08be4c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,8 @@ dependencies = [ "pyobjc==12.1;platform_system=='Darwin'", "python-Xlib==0.33;platform_system=='Linux'", "mss==10.2.0", - "defusedxml==0.7.1" + "defusedxml==0.7.1", + "cryptography>=42.0.0" ] classifiers = [ "Programming Language :: Python :: 3.10", diff --git a/test/unit_test/headless/test_email_trigger.py b/test/unit_test/headless/test_email_trigger.py new file mode 100644 index 00000000..4ef7a772 --- /dev/null +++ b/test/unit_test/headless/test_email_trigger.py @@ -0,0 +1,206 @@ +"""Tests for the IMAP poll email trigger.""" +from email.message import EmailMessage +from typing import List + +import pytest + +from je_auto_control.utils.triggers import email_trigger as et + + +# Sonar python:S2068 fires on the literal "password" anywhere it appears. +# These tests never connect to a real server; centralising the fake +# credential keeps the rule from flagging every fixture call. +_FAKE_HOST = "imap.example.com" +_FAKE_USER = "u" +_FAKE_PW = "p" + + +class _FakeIMAP: + """Minimal in-memory IMAP stub matching the subset our code uses.""" + + instances: List["_FakeIMAP"] = [] + + def __init__(self, host: str, port: int, ssl_context=None): + self.host = host + self.port = port + self.ssl_context = ssl_context + self.logged_in = False + self.selected = None + self.searches: List[str] = [] + self.fetched: List[bytes] = [] + self.flagged: List[bytes] = [] + self.logged_out = False + self._uids = list(self.next_uids) + self._messages = dict(self.next_messages) + _FakeIMAP.instances.append(self) + + next_uids: List[bytes] = [] + next_messages: dict = {} + + def login(self, user, password): + self.logged_in = True + return ("OK", [b"logged in"]) + + def select(self, mailbox, readonly=False): + self.selected = (mailbox, readonly) + return ("OK", [b"1"]) + + def uid(self, command, *args): + if command == "SEARCH": + criteria = args[1] + self.searches.append(criteria) + return ("OK", [b" ".join(self._uids)]) + if command == "FETCH": + uid = args[0] + self.fetched.append(uid) + payload = self._messages.get(uid) + if payload is None: + return ("NO", [None]) + return ("OK", [(b"1 (RFC822 {%d}" % len(payload), payload)]) + if command == "STORE": + self.flagged.append(args[0]) + return ("OK", [b"stored"]) + return ("NO", [None]) + + def logout(self): + self.logged_out = True + + +@pytest.fixture(autouse=True) +def imap_stub(monkeypatch): + _FakeIMAP.instances = [] + monkeypatch.setattr(et.imaplib, "IMAP4_SSL", _FakeIMAP) + monkeypatch.setattr(et.imaplib, "IMAP4", _FakeIMAP) + yield + + +def _build_message(subject: str, sender: str, body: str) -> bytes: + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = sender + msg["To"] = "user@example.com" + msg["Message-ID"] = "" + msg.set_content(body) + return msg.as_bytes() + + +@pytest.fixture +def watcher(): + captured = [] + + def fake_executor(actions, variables): + captured.append((actions, dict(variables))) + + w = et.EmailTriggerWatcher(executor=fake_executor) + w.captured = captured # type: ignore[attr-defined] + yield w + w.stop() + + +def test_add_validates_required_fields(watcher): + with pytest.raises(ValueError): + watcher.add(host="", username="u", password=_FAKE_PW, script_path="x") + + +def test_add_default_port_for_ssl(watcher): + trigger = watcher.add(host="imap.example.com", username="u", + password=_FAKE_PW, script_path="s.json") + assert trigger.port == 993 + assert trigger.use_ssl is True + + +def test_add_default_port_for_plain(watcher): + trigger = watcher.add(host="imap.example.com", username="u", + password=_FAKE_PW, script_path="s.json", use_ssl=False) + assert trigger.port == 143 + + +def test_poll_once_returns_zero_when_no_messages(watcher, tmp_path): + script = tmp_path / "s.json" + script.write_text('[["AC_screen_size"]]', encoding="utf-8") + watcher.add(host="imap.example.com", username="u", password=_FAKE_PW, + script_path=str(script)) + _FakeIMAP.next_uids = [] + _FakeIMAP.next_messages = {} + assert watcher.poll_once() == 0 + + +def test_poll_once_fires_on_matching_message(watcher, tmp_path): + script = tmp_path / "s.json" + script.write_text('[["AC_screen_size"]]', encoding="utf-8") + trigger = watcher.add( + host="imap.example.com", username="u", password=_FAKE_PW, + script_path=str(script), + ) + raw = _build_message("Build OK", "ci@example.com", "everything green") + _FakeIMAP.next_uids = [b"42"] + _FakeIMAP.next_messages = {b"42": raw} + + fired = watcher.poll_once() + + assert fired == 1 + assert len(watcher.captured) == 1 # type: ignore[attr-defined] + actions, variables = watcher.captured[0] # type: ignore[attr-defined] + assert actions == [["AC_screen_size"]] + assert variables["email.subject"] == "Build OK" + assert variables["email.from"] == "ci@example.com" + assert "everything green" in variables["email.body"] + assert variables["email.uid"] == "42" + assert trigger.fired == 1 + + +def test_mark_seen_flag_is_sent(watcher, tmp_path): + script = tmp_path / "s.json" + script.write_text('[["AC_screen_size"]]', encoding="utf-8") + watcher.add(host="imap.example.com", username="u", password=_FAKE_PW, + script_path=str(script)) + _FakeIMAP.next_uids = [b"7"] + _FakeIMAP.next_messages = {b"7": _build_message("hi", "a@b.c", "body")} + watcher.poll_once() + inst = _FakeIMAP.instances[-1] + assert inst.flagged == [b"7"] + + +def test_mark_seen_disabled_skips_flag(watcher, tmp_path): + script = tmp_path / "s.json" + script.write_text('[["AC_screen_size"]]', encoding="utf-8") + watcher.add(host="imap.example.com", username="u", password=_FAKE_PW, + script_path=str(script), mark_seen=False) + _FakeIMAP.next_uids = [b"7"] + _FakeIMAP.next_messages = {b"7": _build_message("hi", "a@b.c", "body")} + watcher.poll_once() + inst = _FakeIMAP.instances[-1] + assert inst.flagged == [] + + +def test_uid_not_double_fired(watcher, tmp_path): + script = tmp_path / "s.json" + script.write_text('[["AC_screen_size"]]', encoding="utf-8") + watcher.add(host="imap.example.com", username="u", password=_FAKE_PW, + script_path=str(script)) + raw = _build_message("once", "a@b.c", "hello") + _FakeIMAP.next_uids = [b"7"] + _FakeIMAP.next_messages = {b"7": raw} + assert watcher.poll_once() == 1 + assert watcher.poll_once() == 0 + + +def test_disabled_trigger_does_not_poll(watcher, tmp_path): + script = tmp_path / "s.json" + script.write_text('[["AC_screen_size"]]', encoding="utf-8") + trigger = watcher.add(host="imap.example.com", username="u", + password=_FAKE_PW, script_path=str(script)) + watcher.set_enabled(trigger.trigger_id, False) + _FakeIMAP.next_uids = [b"99"] + _FakeIMAP.next_messages = { + b"99": _build_message("ignored", "a@b.c", "x"), + } + assert watcher.poll_once() == 0 + + +def test_remove_returns_false_for_unknown(watcher): + assert watcher.remove("nope") is False + + +def test_decode_header_handles_encoded_words(): + assert "ñ" in et._decode_header_value("=?utf-8?b?w7E=?=") diff --git a/test/unit_test/headless/test_input_backends.py b/test/unit_test/headless/test_input_backends.py new file mode 100644 index 00000000..8a978337 --- /dev/null +++ b/test/unit_test/headless/test_input_backends.py @@ -0,0 +1,178 @@ +"""Headless tests for the optional driver-level input backends. + +These never touch the real driver / kernel device — they verify that + +1. the optional sub-packages import cleanly on a machine that has + *neither* Interception nor uinput nor ViGEm installed, +2. their ``is_available()`` probes reliably return ``False`` instead + of raising, +3. the platform wrappers fall back to the original XTest / SendInput + modules when the operator opts in but the driver / kernel device + isn't reachable, +4. the new MCP gamepad tools register with the right schema + + annotation flags. +""" +from __future__ import annotations + +import importlib +import sys + +import pytest + + +# --- Interception (Windows-only sub-package) ------------------------------- + + +@pytest.mark.skipif(sys.platform != "win32", + reason="Interception backend is Windows-only") +def test_interception_dll_module_imports_cleanly(): + """Importing the package on a machine without the driver must not raise.""" + mod = importlib.import_module( + "je_auto_control.windows.interception._dll", + ) + assert mod.is_available is not None + + +@pytest.mark.skipif(sys.platform != "win32", + reason="Interception backend is Windows-only") +def test_interception_is_available_returns_false_without_driver(): + """No driver installed → probe returns False, never raises.""" + from je_auto_control.windows.interception import is_available + # The CI runners do not have the driver, so this must come back + # False; if the driver IS there for some reason, we still tolerate + # True so the test stays portable. + result = is_available() + assert isinstance(result, bool) + + +# --- uinput (Linux-only sub-package) --------------------------------------- + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), + reason="uinput backend is Linux-only") +def test_uinput_module_imports_cleanly(): + """The uinput sub-package must import even without /dev/uinput.""" + mod = importlib.import_module( + "je_auto_control.linux_with_x11.uinput._device", + ) + assert hasattr(mod, "is_available") + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), + reason="uinput backend is Linux-only") +def test_uinput_is_available_does_not_raise(): + """Probe must report a bool whether or not /dev/uinput is writable.""" + from je_auto_control.linux_with_x11.uinput import is_available + assert isinstance(is_available(), bool) + + +# --- Virtual gamepad facade ------------------------------------------------- + + +def test_gamepad_module_imports_cleanly(): + """The facade is importable on every platform (vgamepad is opt-in).""" + mod = importlib.import_module("je_auto_control.utils.gamepad") + assert mod.GamepadUnavailable is not None + assert mod.GAMEPAD_BUTTONS # non-empty tuple + assert mod.DPAD_DIRECTIONS # non-empty tuple + + +def test_gamepad_is_available_does_not_raise(): + """Probe must report a bool whether or not ViGEm is installed.""" + from je_auto_control.utils.gamepad import is_available + assert isinstance(is_available(), bool) + + +def test_virtual_gamepad_raises_when_dependency_missing(monkeypatch): + """``VirtualGamepad()`` must surface a clear error when vgamepad is gone.""" + from je_auto_control.utils.gamepad import VirtualGamepad + from je_auto_control.utils.gamepad._facade import GamepadUnavailable + + # Block ``import vgamepad`` regardless of whether the dep is + # actually installed in the test environment. + real_import = __import__ + + def fake_import(name, *args, **kwargs): + if name == "vgamepad": + raise ImportError("simulated absence") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) + with pytest.raises(GamepadUnavailable): + VirtualGamepad() + + +# --- MCP registration check ------------------------------------------------- + + +def test_mcp_gamepad_tools_are_registered(): + """The seven gamepad tools must show up in build_default_tool_registry().""" + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry, + ) + by_name = {tool.name: tool for tool in build_default_tool_registry()} + expected = { + "ac_gamepad_press", "ac_gamepad_release", "ac_gamepad_click", + "ac_gamepad_dpad", + "ac_gamepad_left_stick", "ac_gamepad_right_stick", + "ac_gamepad_left_trigger", "ac_gamepad_right_trigger", + "ac_gamepad_reset", + } + assert expected.issubset(by_name.keys()) + # Every gamepad tool is destructive — it actively drives synthetic + # input — so it must NOT claim read-only. + for name in expected: + assert by_name[name].annotations.read_only is False, name + + +def test_mcp_gamepad_tools_dropped_under_read_only(): + """The destructive gamepad tools must not survive ``--readonly`` mode.""" + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry, + ) + safe_names = {tool.name + for tool in build_default_tool_registry(read_only=True)} + assert "ac_gamepad_press" not in safe_names + assert "ac_gamepad_left_stick" not in safe_names + + +# --- Win32 / Linux env-var selectors --------------------------------------- + + +def test_win32_backend_env_var_default_is_sendinput(monkeypatch): + """Without the env var, the wrapper still picks the SendInput backend.""" + if sys.platform != "win32": + pytest.skip("Win32 selector only matters on Windows") + monkeypatch.delenv("JE_AUTOCONTROL_WIN32_BACKEND", raising=False) + from je_auto_control.wrapper import _platform_windows + keyboard, mouse = _platform_windows._select_input_backend() + assert keyboard.__name__.endswith("win32_ctype_keyboard_control") + assert mouse.__name__.endswith("win32_ctype_mouse_control") + + +def test_win32_backend_env_var_falls_back_when_driver_missing(monkeypatch): + """``interception`` env value with no driver → SendInput + warning.""" + if sys.platform != "win32": + pytest.skip("Win32 selector only matters on Windows") + monkeypatch.setenv("JE_AUTOCONTROL_WIN32_BACKEND", "interception") + # Force the availability probe to report False so the test runs + # whether or not the host actually has the driver installed. + from je_auto_control.wrapper import _platform_windows + monkeypatch.setattr( + "je_auto_control.windows.interception.is_available", + lambda: False, + ) + keyboard, mouse = _platform_windows._select_input_backend() + assert keyboard.__name__.endswith("win32_ctype_keyboard_control") + assert mouse.__name__.endswith("win32_ctype_mouse_control") + + +def test_linux_backend_env_var_default_is_x11(monkeypatch): + """Without the env var, the wrapper still picks the XTest backend.""" + if not sys.platform.startswith("linux"): + pytest.skip("Linux selector only matters on Linux") + monkeypatch.delenv("JE_AUTOCONTROL_LINUX_BACKEND", raising=False) + from je_auto_control.wrapper import _platform_linux + keyboard, mouse = _platform_linux._select_input_backend() + assert keyboard.__name__.endswith("x11_linux_keyboard_control") + assert mouse.__name__.endswith("x11_linux_mouse_control") diff --git a/test/unit_test/headless/test_profiler.py b/test/unit_test/headless/test_profiler.py new file mode 100644 index 00000000..ae38547d --- /dev/null +++ b/test/unit_test/headless/test_profiler.py @@ -0,0 +1,89 @@ +"""Tests for the per-action profiler.""" +import time + +import pytest + +from je_auto_control.utils.profiler.profiler import ActionProfiler + + +@pytest.fixture +def profiler(): + return ActionProfiler() + + +def test_profiler_disabled_by_default_records_nothing(profiler): + with profiler.measure("AC_click_mouse"): + time.sleep(0.001) + assert profiler.stats() == [] + + +def test_profiler_records_after_enable(profiler): + profiler.enable() + with profiler.measure("AC_click_mouse"): + time.sleep(0.001) + rows = profiler.stats() + assert len(rows) == 1 + row = rows[0] + assert row.name == "AC_click_mouse" + assert row.calls == 1 + assert row.total_seconds >= 0.0 + assert row.last_seconds >= 0.0 + + +def test_profiler_aggregates_multiple_calls(profiler): + profiler.enable() + for _ in range(3): + profiler.record("AC_screenshot", 0.05) + profiler.record("AC_screenshot", 0.10) + row = profiler.get("AC_screenshot") + assert row.calls == 4 + assert row.total_seconds == pytest.approx(0.25) + assert row.min_seconds == pytest.approx(0.05) + assert row.max_seconds == pytest.approx(0.10) + assert row.average_seconds == pytest.approx(0.0625) + + +def test_hot_spots_sorted_by_total_time(profiler): + profiler.enable() + profiler.record("slow", 1.0) + profiler.record("fast", 0.01) + profiler.record("slow", 0.5) + top = profiler.hot_spots(limit=2) + assert [r.name for r in top] == ["slow", "fast"] + + +def test_reset_clears_samples(profiler): + profiler.enable() + profiler.record("AC_loop", 0.1) + profiler.reset() + assert profiler.stats() == [] + + +def test_measure_records_error_and_reraises(profiler): + profiler.enable() + with pytest.raises(RuntimeError): + with profiler.measure("AC_press_keyboard_key"): + raise RuntimeError("boom") + row = profiler.get("AC_press_keyboard_key") + assert row.calls == 1 + assert row.errors == 1 + + +def test_disable_keeps_existing_data(profiler): + profiler.enable() + profiler.record("kept", 0.2) + profiler.disable() + profiler.record("ignored", 0.1) + rows = {r.name for r in profiler.stats()} + assert rows == {"kept"} + + +def test_to_dict_includes_average_and_share(profiler): + profiler.enable() + profiler.record("a", 0.4) + row = profiler.get("a") + payload = row.to_dict() + assert payload["name"] == "a" + assert payload["calls"] == 1 + assert payload["average_seconds"] == pytest.approx(0.4) + assert "min_seconds" in payload and "max_seconds" in payload diff --git a/test/unit_test/headless/test_secret_store.py b/test/unit_test/headless/test_secret_store.py new file mode 100644 index 00000000..fde4664c --- /dev/null +++ b/test/unit_test/headless/test_secret_store.py @@ -0,0 +1,126 @@ +"""Tests for the encrypted secret vault and ${secrets.NAME} interpolation.""" +from pathlib import Path + +import pytest + +# The vault depends on ``cryptography``; declared in pyproject.toml but skip +# cleanly when an older environment hasn't refreshed the lockfile yet. +pytest.importorskip("cryptography") + +from je_auto_control.utils.script_vars.interpolate import interpolate_value # noqa: E402 +from je_auto_control.utils.secrets.secret_store import ( # noqa: E402 + SecretManager, SecretStoreError, SecretStoreLocked, +) + + +@pytest.fixture +def vault_path(tmp_path: Path) -> Path: + return tmp_path / "vault.json" + + +@pytest.fixture +def manager(vault_path: Path) -> SecretManager: + return SecretManager(path=vault_path) + + +def test_initialize_creates_unlocked_vault(manager, vault_path): + manager.initialize("hunter2") + assert vault_path.exists() + assert manager.is_unlocked + assert manager.list_names() == [] + + +def test_initialize_refuses_overwrite(manager): + manager.initialize("first") + manager.lock() + with pytest.raises(SecretStoreError): + manager.initialize("second") + + +def test_set_and_get_round_trips(manager): + manager.initialize("pw") + manager.set("api_token", "shhh") + assert manager.get("api_token") == "shhh" + + +def test_locked_manager_rejects_reads(manager): + manager.initialize("pw") + manager.set("k", "v") + manager.lock() + with pytest.raises(SecretStoreLocked): + manager.get("k") + + +def test_unlock_with_wrong_passphrase_returns_false(manager): + manager.initialize("good") + manager.lock() + assert manager.unlock("bad") is False + assert manager.unlock("good") is True + + +def test_unlock_persists_across_manager_instances(vault_path): + first = SecretManager(path=vault_path) + first.initialize("pw") + first.set("name", "value") + first.lock() + + second = SecretManager(path=vault_path) + assert second.is_initialized + assert second.unlock("pw") is True + assert second.get("name") == "value" + + +def test_remove_returns_false_for_missing(manager): + manager.initialize("pw") + assert manager.remove("missing") is False + manager.set("present", "x") + assert manager.remove("present") is True + assert manager.list_names() == [] + + +def test_change_passphrase_re_encrypts_items(vault_path): + mgr = SecretManager(path=vault_path) + mgr.initialize("old") + mgr.set("token", "abc") + mgr.change_passphrase("old", "new") + assert mgr.unlock("new") is True + assert mgr.get("token") == "abc" + + +def test_secret_value_is_not_plaintext_on_disk(vault_path): + mgr = SecretManager(path=vault_path) + mgr.initialize("pw") + mgr.set("token", "supersecret-12345") + text = vault_path.read_text(encoding="utf-8") + assert "supersecret-12345" not in text + + +def test_interpolate_uses_default_secret_manager(monkeypatch, tmp_path): + from je_auto_control.utils import secrets as secrets_pkg + fresh = SecretManager(path=tmp_path / "vault.json") + monkeypatch.setattr(secrets_pkg, "default_secret_manager", fresh) + fresh.initialize("pw") + fresh.set("api_key", "tok-42") + assert interpolate_value("${secrets.api_key}", {}) == "tok-42" + assert interpolate_value("Bearer ${secrets.api_key}", {}) \ + == "Bearer tok-42" + + +def test_interpolate_locked_secret_raises(monkeypatch, tmp_path): + from je_auto_control.utils import secrets as secrets_pkg + fresh = SecretManager(path=tmp_path / "vault.json") + monkeypatch.setattr(secrets_pkg, "default_secret_manager", fresh) + fresh.initialize("pw") + fresh.set("api_key", "tok") + fresh.lock() + with pytest.raises(ValueError, match="locked"): + interpolate_value("${secrets.api_key}", {}) + + +def test_interpolate_unknown_secret_raises(monkeypatch, tmp_path): + from je_auto_control.utils import secrets as secrets_pkg + fresh = SecretManager(path=tmp_path / "vault.json") + monkeypatch.setattr(secrets_pkg, "default_secret_manager", fresh) + fresh.initialize("pw") + with pytest.raises(ValueError, match="Unknown secret"): + interpolate_value("${secrets.missing}", {}) diff --git a/test/unit_test/headless/test_webhook_trigger.py b/test/unit_test/headless/test_webhook_trigger.py new file mode 100644 index 00000000..8ba03c68 --- /dev/null +++ b/test/unit_test/headless/test_webhook_trigger.py @@ -0,0 +1,161 @@ +"""Tests for the webhook (HTTP push) trigger server.""" +import json +import threading +import urllib.error +import urllib.request + +import pytest + +from je_auto_control.utils.triggers.webhook_server import WebhookTriggerServer + + +def _local_url(host: str, port: int, path: str) -> str: + """Build a loopback URL for an in-process test server. + + The webhook trigger's HTTPS variant lives on the application; the test + fixture deliberately drives the server over plain HTTP because the + listener only ever binds to 127.0.0.1 inside the test process. + """ + return f"http://{host}:{port}{path}" # NOSONAR python:S5332 - loopback test fixture + + +def _post(url, body=b"", headers=None, method="POST", timeout=2.0): + request = urllib.request.Request(url, data=body, method=method, + headers=headers or {}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, response.read() + + +@pytest.fixture +def server(): + captured = [] + fired_event = threading.Event() + + def fake_executor(actions, variables): + captured.append((actions, dict(variables))) + fired_event.set() + + srv = WebhookTriggerServer(executor=fake_executor) + srv.captured = captured # type: ignore[attr-defined] + srv.fired_event = fired_event # type: ignore[attr-defined] + yield srv + srv.stop() + + +def _write_dummy_script(path): + path.write_text('[["AC_screen_size"]]', encoding="utf-8") + + +def test_add_assigns_id_and_normalises_path(server): + trigger = server.add(path="hooks/build", script_path="x.json") + assert trigger.path == "/hooks/build" + assert trigger.methods == ("POST",) + assert len(trigger.webhook_id) == 8 + + +def test_add_rejects_conflicting_path_and_method(server): + server.add(path="/dup", script_path="a.json", methods=["POST"]) + with pytest.raises(ValueError): + server.add(path="/dup", script_path="b.json", methods=["POST"]) + + +def test_remove_returns_false_for_unknown(server): + assert server.remove("nope") is False + + +def test_match_skips_disabled(server): + trig = server.add(path="/p", script_path="s.json") + assert server.match("/p", "POST") is trig + server.set_enabled(trig.webhook_id, False) + assert server.match("/p", "POST") is None + + +def test_authorize_uses_constant_time_compare(server): + trig = server.add(path="/p", script_path="s.json", token="abc123") + assert server.authorize(trig, "Bearer abc123") is True + assert server.authorize(trig, "Bearer wrong") is False + assert server.authorize(trig, None) is False + + +def test_authorize_no_token_allows_all(server): + trig = server.add(path="/p", script_path="s.json", token=None) + assert server.authorize(trig, None) is True + + +def test_post_fires_trigger_with_payload(server, tmp_path): + script = tmp_path / "hook.json" + _write_dummy_script(script) + server.add(path="/jobs", script_path=str(script), methods=["POST"]) + host, port = server.start("127.0.0.1", 0) + body = json.dumps({"hello": "world"}).encode("utf-8") + status, _ = _post( + _local_url(host, port, "/jobs?ref=main"), + body=body, + headers={"Content-Type": "application/json", + "X-Custom": "value"}, + ) + assert status == 200 + assert server.fired_event.wait(timeout=2.0) # type: ignore[attr-defined] + actions, variables = server.captured[0] # type: ignore[attr-defined] + assert actions == [["AC_screen_size"]] + assert variables["webhook.method"] == "POST" + assert variables["webhook.path"] == "/jobs" + assert variables["webhook.query"] == {"ref": ["main"]} + assert variables["webhook.body"] == body.decode() + assert variables["webhook.json"] == {"hello": "world"} + headers = variables["webhook.headers"] + assert headers["x-custom"] == "value" + + +def test_unknown_path_returns_404(server): + server.add(path="/known", script_path="x.json") + host, port = server.start("127.0.0.1", 0) + with pytest.raises(urllib.error.HTTPError) as excinfo: + _post(_local_url(host, port, "/unknown")) + assert excinfo.value.code == 404 + + +def test_token_mismatch_returns_401(server, tmp_path): + script = tmp_path / "hook.json" + _write_dummy_script(script) + server.add(path="/p", script_path=str(script), token="topsecret") + host, port = server.start("127.0.0.1", 0) + with pytest.raises(urllib.error.HTTPError) as excinfo: + _post( + _local_url(host, port, "/p"), + headers={"Authorization": "Bearer wrong"}, + ) + assert excinfo.value.code == 401 + + +def test_oversize_body_rejected(server, tmp_path): + script = tmp_path / "hook.json" + _write_dummy_script(script) + server.add(path="/p", script_path=str(script)) + host, port = server.start("127.0.0.1", 0) + payload = b"x" * (2 << 20) # 2 MiB > 1 MiB cap + with pytest.raises(urllib.error.HTTPError) as excinfo: + _post(_local_url(host, port, "/p"), body=payload, + headers={"Content-Type": "application/octet-stream"}) + assert excinfo.value.code in (413, 400) + + +def test_method_filter_rejects_other_verbs(server): + server.add(path="/only-post", script_path="x.json", methods=["POST"]) + host, port = server.start("127.0.0.1", 0) + with pytest.raises(urllib.error.HTTPError) as excinfo: + _post(_local_url(host, port, "/only-post"), method="GET") + assert excinfo.value.code == 404 + + +def test_stop_is_idempotent(server): + server.start("127.0.0.1", 0) + server.stop() + server.stop() + assert server.is_running is False + + +def test_start_is_idempotent(server): + first = server.start("127.0.0.1", 0) + second = server.start("127.0.0.1", 0) + assert first == second