Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
49 changes: 49 additions & 0 deletions docs/source/Eng/doc/new_features/v203_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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": "<realpath>", "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**.
43 changes: 43 additions & 0 deletions docs/source/Zh/doc/new_features/v203_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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": "<realpath>", "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** 分類下)形式提供。
3 changes: 3 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),),
Expand Down
14 changes: 14 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
]


Expand Down
10 changes: 10 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions je_auto_control/utils/shell_open/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
93 changes: 93 additions & 0 deletions je_auto_control/utils/shell_open/shell_open.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading