diff --git a/WHATS_NEW.md b/WHATS_NEW.md index c595e324..d9277189 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-25) — Open Files / URLs with the Default App + +Hand a file to its default app, print it, or open a URL in the browser. Full reference: [`docs/source/Eng/doc/new_features/v203_features_doc.rst`](docs/source/Eng/doc/new_features/v203_features_doc.rst). + +- **`open_path` / `plan_open`** (`AC_open_path`, `AC_plan_open`): the framework could launch a literal `.exe`, but not the most common "hand off to another app" step — open `report.pdf` with its registered app, `print` a document, or open a URL in the default browser. This routes per-OS to `os.startfile` / `open` / `xdg-open` / `webbrowser`. `plan_open` is a pure planner that classifies the target (URL vs file path), validates it (URL scheme allow-list; `realpath` for files — a Windows drive `C:\` is correctly a path, not a scheme) and returns the dispatch descriptor; `open_path` runs it through an injectable `opener` (the real OS call by default), so the logic is unit-testable without launching anything. First feature of the ROUND-15 cross-app OS lane. No `PySide6`. + ## What's new (2026-06-25) — Reactive UIA Event Wait (focus change) Wait until focus lands on the dialog — a real, zero-latency UIA event, not polling. Full reference: [`docs/source/Eng/doc/new_features/v202_features_doc.rst`](docs/source/Eng/doc/new_features/v202_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v203_features_doc.rst b/docs/source/Eng/doc/new_features/v203_features_doc.rst new file mode 100644 index 00000000..d83263a0 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v203_features_doc.rst @@ -0,0 +1,49 @@ +Open Files / URLs with the Default App +====================================== + +The framework could launch a literal executable (``start_exe`` / ``shell_process``), +but not the single most common "hand off to another app" RPA step: open +``report.pdf`` with whatever app is registered for it, ``print`` a document, or +open a URL in the default browser. ``shell_open`` adds that, routed per-OS to +``os.startfile`` / ``open`` / ``xdg-open`` / ``webbrowser``. + +* :func:`plan_open` — pure planner: classify the target (URL vs file path), + validate it (URL scheme allow-list; ``realpath`` for files) and return the + dispatch descriptor, +* :func:`open_path` — run the plan through an injectable ``opener`` sink (the real + OS call by default). + +Pure stdlib; the dispatch logic is unit-testable without launching anything via +the injectable ``opener``. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import open_path, plan_open + + open_path("report.pdf") # default PDF viewer + open_path("invoice.pdf", verb="print") # print it + open_path("https://example.com") # default browser + + plan_open("https://example.com") + # {"kind": "url", "scheme": "https", "target": "...", "backend": "webbrowser", + # "verb": "open"} + plan_open("report.pdf") + # {"kind": "file", "target": "", "backend": "startfile", ...} + +A ``scheme://`` target (or ``mailto:`` / ``tel:``) is opened as a URL — only the +allow-listed schemes (``http`` / ``https`` / ``ftp`` / ``file`` / ``mailto`` / +``tel``) are accepted, anything else raises ``ValueError``. Everything else is a +file path (a Windows drive like ``C:\\…`` is correctly treated as a path, not a +scheme) and is ``realpath``-resolved. ``verb`` (``open`` / ``print`` / ``edit``) +applies to files on Windows. + +Executor commands +----------------- + +``AC_open_path`` (``target`` / ``verb`` → ``{opened}``) and ``AC_plan_open`` +(``target`` / ``verb`` → the plan). They are exposed as the matching ``ac_*`` MCP +tools (``open_path`` side-effect-only, ``plan_open`` read-only) and as Script +Builder commands under **Shell**. diff --git a/docs/source/Zh/doc/new_features/v203_features_doc.rst b/docs/source/Zh/doc/new_features/v203_features_doc.rst new file mode 100644 index 00000000..6978a170 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v203_features_doc.rst @@ -0,0 +1,43 @@ +以預設程式開啟檔案 / URL +======================== + +框架原本能啟動字面執行檔(``start_exe`` / ``shell_process``),卻無法做最常見的「交接給另一個應用程式」 +RPA 步驟:用註冊的應用程式開啟 ``report.pdf``、``print`` 一份文件,或在預設瀏覽器開啟 URL。 +``shell_open`` 補上這點,依作業系統路由到 ``os.startfile`` / ``open`` / ``xdg-open`` / +``webbrowser``。 + +* :func:`plan_open` ——純 planner:分類目標(URL 或檔案路徑)、驗證(URL scheme 白名單;檔案用 + ``realpath``)並回傳分派描述子, +* :func:`open_path` ——透過可注入的 ``opener`` 接縫執行計畫(預設為真正的 OS 呼叫)。 + +純標準庫;透過可注入的 ``opener``,分派邏輯可在不真正開啟任何東西的情況下單元測試。不匯入 +``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import open_path, plan_open + + open_path("report.pdf") # 預設 PDF 檢視器 + open_path("invoice.pdf", verb="print") # 列印 + open_path("https://example.com") # 預設瀏覽器 + + plan_open("https://example.com") + # {"kind": "url", "scheme": "https", "target": "...", "backend": "webbrowser", + # "verb": "open"} + plan_open("report.pdf") + # {"kind": "file", "target": "", "backend": "startfile", ...} + +``scheme://`` 目標(或 ``mailto:`` / ``tel:``)會以 URL 開啟——只接受白名單 scheme +(``http`` / ``https`` / ``ftp`` / ``file`` / ``mailto`` / ``tel``),其他則拋出 ``ValueError``。 +其餘皆視為檔案路徑(Windows 磁碟代號如 ``C:\\…`` 會正確視為路徑而非 scheme)並以 ``realpath`` +解析。``verb``(``open`` / ``print`` / ``edit``)在 Windows 上套用於檔案。 + +執行器指令 +---------- + +``AC_open_path``(``target`` / ``verb`` → ``{opened}``)與 ``AC_plan_open``(``target`` / +``verb`` → 計畫)。皆以對應的 ``ac_*`` MCP 工具(``open_path`` 為僅副作用、``plan_open`` 為唯讀) +及 Script Builder 指令(位於 **Shell** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 6b8fcbc8..3cc59027 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -91,6 +91,8 @@ ) # Reactive UIA event waits (focus-changed) from je_auto_control.utils.ax_events import wait_for_focus_change +# Open a file with its default app / a URL in the default browser +from je_auto_control.utils.shell_open import open_path, plan_open # Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set from je_auto_control.utils.clipboard_rich_formats import ( build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, @@ -1700,6 +1702,7 @@ def start_autocontrol_gui(*args, **kwargs): "legacy_info", "legacy_default_action", "get_selection", "list_views", "set_view", "wait_for_focus_change", + "plan_open", "open_path", "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index f5a8811a..2be6e99d 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -4250,6 +4250,24 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: "AC_shell_command", "Shell", "Shell Command", fields=(FieldSpec("shell_command", FieldType.STRING),), )) + specs.append(CommandSpec( + "AC_open_path", "Shell", "Open File / URL (default app)", + fields=( + FieldSpec("target", FieldType.STRING, + placeholder="report.pdf or https://example.com"), + FieldSpec("verb", FieldType.STRING, optional=True, default="open", + placeholder="open / print / edit"), + ), + description="Open a file with its default app, or a URL in the browser.", + )) + specs.append(CommandSpec( + "AC_plan_open", "Shell", "Plan Open (classify)", + fields=( + FieldSpec("target", FieldType.STRING), + FieldSpec("verb", FieldType.STRING, optional=True, default="open"), + ), + description="Classify how a file/URL would be opened (pure, no launch).", + )) specs.append(CommandSpec( "AC_take_golden", "Report", "Capture Golden Image", fields=(FieldSpec("path", FieldType.FILE_PATH),), diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 59dd56f7..4d808f57 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2609,6 +2609,18 @@ def _wait_for_focus_change(timeout: Any = 5.0) -> Dict[str, Any]: return {"changed": element is not None, "element": element} +def _plan_open(target: str, verb: str = "open") -> Dict[str, Any]: + """Adapter: classify how a file path / URL would be opened (pure).""" + from je_auto_control.utils.shell_open import plan_open + return plan_open(str(target), verb=str(verb)) + + +def _open_path(target: str, verb: str = "open") -> Dict[str, Any]: + """Adapter: open a file with its default app / a URL in the browser.""" + from je_auto_control.utils.shell_open import open_path + return {"opened": bool(open_path(str(target), verb=str(verb)))} + + def _get_control_text(name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Dict[str, Any]: @@ -6599,6 +6611,8 @@ def __init__(self): "AC_list_views": _list_views, "AC_set_view": _set_view, "AC_wait_for_focus_change": _wait_for_focus_change, + "AC_plan_open": _plan_open, + "AC_open_path": _open_path, "AC_get_control_text": _get_control_text, "AC_find_control_text": _find_control_text, "AC_select_control_text": _select_control_text, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index de0c8f7c..1b647680 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2084,6 +2084,28 @@ def process_and_shell_tools() -> List[MCPTool]: handler=h.shell_command, annotations=DESTRUCTIVE, ), + MCPTool( + name="ac_open_path", + description=("Open a file with its OS-registered default app (or a " + "'verb' like print), or a URL in the default browser. " + "'target' is a path or URL. Returns {opened}."), + input_schema=schema({"target": {"type": "string"}, + "verb": {"type": "string"}}, + required=["target"]), + handler=h.open_path, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_plan_open", + description=("Classify how a file path / URL would be opened without " + "opening it (pure): {kind, target, backend, verb} " + "(+scheme for URLs). Rejects non-allow-listed schemes."), + input_schema=schema({"target": {"type": "string"}, + "verb": {"type": "string"}}, + required=["target"]), + handler=h.plan_open, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 26f98959..b130cda5 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -558,6 +558,16 @@ def shell_command(command: str, timeout: float = 30.0 } +def open_path(target, verb="open"): + from je_auto_control.utils.executor.action_executor import _open_path + return _open_path(target, verb) + + +def plan_open(target, verb="open"): + from je_auto_control.utils.executor.action_executor import _plan_open + return _plan_open(target, verb) + + def get_clipboard() -> str: from je_auto_control.utils.clipboard.clipboard import get_clipboard as _get return _get() diff --git a/je_auto_control/utils/shell_open/__init__.py b/je_auto_control/utils/shell_open/__init__.py new file mode 100644 index 00000000..d6c58fc6 --- /dev/null +++ b/je_auto_control/utils/shell_open/__init__.py @@ -0,0 +1,4 @@ +"""Open a file with its default app, or a URL in the default browser (per-OS).""" +from je_auto_control.utils.shell_open.shell_open import open_path, plan_open + +__all__ = ["plan_open", "open_path"] diff --git a/je_auto_control/utils/shell_open/shell_open.py b/je_auto_control/utils/shell_open/shell_open.py new file mode 100644 index 00000000..cbaf6697 --- /dev/null +++ b/je_auto_control/utils/shell_open/shell_open.py @@ -0,0 +1,93 @@ +"""Open a file with its default app, or a URL in the default browser. + +The framework can launch a literal executable (``start_exe`` / ``shell_process``), +but not the single most common "hand off to another app" RPA step: open ``report.pdf`` +with whatever app is registered for it, ``print`` a document, or open a URL in the +default browser. ``shell_open`` adds that, routed per-OS to ``os.startfile`` / +``open`` / ``xdg-open`` / ``webbrowser``. + +:func:`plan_open` is a pure planner — it classifies the target (URL vs file path), +validates it (URL scheme allowlist; ``realpath`` for files) and returns the +dispatch descriptor, fully unit-testable. :func:`open_path` runs the plan through +an injectable ``opener`` sink (the real OS call by default), so the dispatch logic +is testable without launching anything. Imports no ``PySide6``. +""" +import os +import re +import sys +from typing import Any, Callable, Dict, Optional + +# URL schemes we will hand to the browser / OS — the safety allowlist. +_URL_SCHEMES = frozenset({"http", "https", "ftp", "file", "mailto", "tel"}) +_SCHEME_AUTHORITY = re.compile(r"([a-zA-Z][a-zA-Z0-9+.\-]+)://") +_SCHEME_OPAQUE = re.compile(r"(mailto|tel):", re.IGNORECASE) + +# A plan dispatcher: maps a plan dict to the real open call → bool. +Opener = Callable[[Dict[str, Any]], bool] + + +def _scheme(target: str) -> Optional[str]: + """Return the URL scheme of ``target`` (e.g. ``https``), or None for a path. + + Requires ``scheme://`` (so a Windows drive ``C:\\`` is not a scheme) or a known + opaque scheme (``mailto:`` / ``tel:``). + """ + match = _SCHEME_AUTHORITY.match(target) or _SCHEME_OPAQUE.match(target) + return match.group(1).lower() if match else None + + +def _file_backend() -> str: + if sys.platform.startswith("win"): + return "startfile" + if sys.platform == "darwin": + return "open" + return "xdg-open" + + +def plan_open(target: str, *, verb: str = "open") -> Dict[str, Any]: + """Classify ``target`` and return how to open it (pure, no side effect). + + Returns ``{kind, target, backend, verb}`` (plus ``scheme`` for URLs). Raises + ``ValueError`` for an empty target or a URL whose scheme isn't allow-listed. + """ + text = str(target).strip() + if not text: + raise ValueError("target is empty") + scheme = _scheme(text) + if scheme is not None: + if scheme not in _URL_SCHEMES: + raise ValueError(f"unsupported URL scheme: {scheme!r}") + return {"kind": "url", "scheme": scheme, "target": text, + "backend": "webbrowser", "verb": verb} + path = os.path.realpath(os.path.expanduser(text)) + return {"kind": "file", "target": path, "backend": _file_backend(), + "verb": verb} + + +def _default_opener(plan: Dict[str, Any]) -> bool: + backend, target = plan["backend"], plan["target"] + if backend == "webbrowser": + import webbrowser + return bool(webbrowser.open(target)) + if backend == "startfile": + if not sys.platform.startswith("win"): + raise RuntimeError("startfile is only supported on Windows") + # file path is from the allow-listed plan; os.startfile is not a shell + os.startfile(target, plan.get("verb") or "open") # noqa: S606 # nosec B606 # nosemgrep + return True + import subprocess # nosec B404 # reason: argv list, no shell + # fixed backend + argv list, no shell — injection-safe + subprocess.Popen([backend, target]) # nosec B603 # nosemgrep + return True + + +def open_path(target: str, *, verb: str = "open", + opener: Optional[Opener] = None) -> bool: + """Open ``target`` (file → default app / verb; URL → default browser). + + Pass an ``opener`` ``plan -> bool`` to intercept the dispatch (e.g. in tests); + the default runs the real OS call. Returns True on success. + """ + plan = plan_open(target, verb=verb) + dispatch = opener if opener is not None else _default_opener + return bool(dispatch(plan)) diff --git a/test/unit_test/headless/test_shell_open_batch.py b/test/unit_test/headless/test_shell_open_batch.py new file mode 100644 index 00000000..2f3ad14c --- /dev/null +++ b/test/unit_test/headless/test_shell_open_batch.py @@ -0,0 +1,81 @@ +"""Headless tests for default-app / URL opening (pure planner + injected opener).""" +import os + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.shell_open import open_path, plan_open + + +def test_plan_url_goes_to_browser(): + plan = plan_open("https://example.com/x") + assert plan["kind"] == "url" + assert plan["scheme"] == "https" + assert plan["backend"] == "webbrowser" + + +def test_plan_mailto_and_tel(): + assert plan_open("mailto:a@b.com")["scheme"] == "mailto" + assert plan_open("tel:+123")["scheme"] == "tel" + + +def test_plan_file_path_is_realpathed(): + plan = plan_open("report.pdf") + assert plan["kind"] == "file" + assert plan["target"] == os.path.realpath("report.pdf") + assert plan["backend"] in ("startfile", "open", "xdg-open") + + +def test_windows_drive_is_a_file_not_a_scheme(): + assert plan_open(r"C:\Users\me\report.txt")["kind"] == "file" + + +def test_verb_is_carried(): + assert plan_open("report.pdf", verb="print")["verb"] == "print" + + +def test_rejects_bad_scheme_and_empty(): + with pytest.raises(ValueError): + plan_open("javascript://alert(1)") + with pytest.raises(ValueError): + plan_open(" ") + + +def test_open_path_dispatches_plan_to_injected_opener(): + captured = {} + + def fake_opener(plan): + captured.update(plan) + return True + + assert open_path("https://x.io", opener=fake_opener) is True + assert captured["backend"] == "webbrowser" + assert captured["target"] == "https://x.io" + + +def test_open_path_returns_opener_result(): + assert open_path("report.pdf", opener=lambda plan: False) is False + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_plan_path(): + from je_auto_control.utils.executor.action_executor import _plan_open + plan = _plan_open("https://example.com") + assert plan["backend"] == "webbrowser" and plan["kind"] == "url" + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_open_path", "AC_plan_open"} <= known + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_open_path", "ac_plan_open"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_open_path", "AC_plan_open"} <= specs + + +def test_facade_exports(): + for name in ("plan_open", "open_path"): + assert hasattr(ac, name) and name in ac.__all__