diff --git a/.gitignore b/.gitignore index a7449546..ddbf93f5 100644 --- a/.gitignore +++ b/.gitignore @@ -57,63 +57,9 @@ uv.lock /skills/ .ace-tool/ -# Plugin workspace (only auto_mas_core is tracked) +# Plugin workspace (external plugin sources stay ignored) /plugins/* !/plugins/auto_mas_core/ !/plugins/auto_mas_core/** -!/plugins/automas_maafw_interface/ -!/plugins/automas_maafw_interface/** -plugins/automas_maafw_interface/build/ -plugins/automas_maafw_interface/dist/ -plugins/automas_maafw_interface/**/*.egg-info/ -plugins/automas_maafw_interface/**/__pycache__/ -!/plugins/automas_maafw_agent_env/ -!/plugins/automas_maafw_agent_env/** -plugins/automas_maafw_agent_env/build/ -plugins/automas_maafw_agent_env/dist/ -plugins/automas_maafw_agent_env/**/*.egg-info/ -plugins/automas_maafw_agent_env/**/__pycache__/ -!/plugins/automas_maafw_controller_adb/ -!/plugins/automas_maafw_controller_adb/** -plugins/automas_maafw_controller_adb/build/ -plugins/automas_maafw_controller_adb/dist/ -plugins/automas_maafw_controller_adb/**/*.egg-info/ -plugins/automas_maafw_controller_adb/**/__pycache__/ -!/plugins/automas_maafw_controller_win32/ -!/plugins/automas_maafw_controller_win32/** -plugins/automas_maafw_controller_win32/build/ -plugins/automas_maafw_controller_win32/dist/ -plugins/automas_maafw_controller_win32/**/*.egg-info/ -plugins/automas_maafw_controller_win32/**/__pycache__/ -!/plugins/automas_maafw_project_update/ -!/plugins/automas_maafw_project_update/** -plugins/automas_maafw_project_update/build/ -plugins/automas_maafw_project_update/dist/ -plugins/automas_maafw_project_update/**/*.egg-info/ -plugins/automas_maafw_project_update/**/__pycache__/ -!/plugins/automas_maafw_runner/ -!/plugins/automas_maafw_runner/** -plugins/automas_maafw_runner/build/ -plugins/automas_maafw_runner/dist/ -plugins/automas_maafw_runner/**/*.egg-info/ -plugins/automas_maafw_runner/**/__pycache__/ -!/plugins/automas_script_maafw/ -!/plugins/automas_script_maafw/** -plugins/automas_script_maafw/build/ -plugins/automas_script_maafw/dist/ -plugins/automas_script_maafw/**/*.egg-info/ -plugins/automas_script_maafw/**/__pycache__/ -!/plugins/automas_script_maafw_pack_m9a/ -!/plugins/automas_script_maafw_pack_m9a/** -plugins/automas_script_maafw_pack_m9a/build/ -plugins/automas_script_maafw_pack_m9a/dist/ -plugins/automas_script_maafw_pack_m9a/**/*.egg-info/ -plugins/automas_script_maafw_pack_m9a/**/__pycache__/ -!/plugins/automas_m9a/ -!/plugins/automas_m9a/** -plugins/automas_m9a/build/ -plugins/automas_m9a/dist/ -plugins/automas_m9a/**/*.egg-info/ -plugins/automas_m9a/**/__pycache__/ /app/core/plugins/_generated/ res/docs/ok-series-common-script-report.md diff --git a/app/api/scripts.py b/app/api/scripts.py index 408ade19..30db062e 100644 --- a/app/api/scripts.py +++ b/app/api/scripts.py @@ -1231,7 +1231,11 @@ async def preview_maafw_windows( win32_windows = win32_service.list_windows() windows: list[MaaFWDesktopWindowInfo] = [] for controller in controllers: - for window in win32_service.match_controller_windows(controller, win32_windows): + controller_payload = controller.model_dump(mode="json", by_alias=True) + for window in win32_service.match_controller_windows( + controller_payload, + win32_windows, + ): windows.append( MaaFWDesktopWindowInfo( hWnd=window.hWnd, diff --git a/plugins/automas_m9a/README.md b/plugins/automas_m9a/README.md deleted file mode 100644 index dd6f6585..00000000 --- a/plugins/automas_m9a/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# automas-m9a - -M9A 专项插件组合包(meta-package)。安装此包会自动拉入 M9A 全部运行所需的 8 个插件包。 - -## 安装 - -```bash -pip install automas-m9a -``` - -## 包含的插件包 - -| 包名 | 职责 | -| --- | --- | -| automas-maafw-interface | MaaFW ProjectInterface 解析 | -| automas-maafw-project-update | MaaFW 项目资源升级 | -| automas-maafw-agent-env | MaaFW agent 命令计划与环境准备 | -| automas-maafw-runner | MaaFW 隔离运行服务(含 maafw 运行依赖) | -| automas-maafw-controller-adb | ADB controller | -| automas-maafw-controller-win32 | Win32 controller | -| automas-script-maafw | MaaFW 脚本适配插件 | -| automas-script-maafw-pack-m9a | M9A project pack 声明 | - -## 说明 - -此包是纯依赖聚合包,不包含任何 Python 代码或插件入口点。安装后,8 个插件包会被自动安装到 `plugins/pypi/site-packages`,插件加载器会自动发现并注册它们。 diff --git a/plugins/automas_m9a/pyproject.toml b/plugins/automas_m9a/pyproject.toml deleted file mode 100644 index 406e3944..00000000 --- a/plugins/automas_m9a/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "automas-m9a" -version = "0.1.0" -description = "M9A 专项插件组合包 — 一键安装 M9A 全部运行所需插件" -readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" -dependencies = [ - "automas-maafw-interface>=0.1.0", - "automas-maafw-project-update>=0.1.0", - "automas-maafw-agent-env>=0.1.0", - "automas-maafw-runner>=0.1.0", - "automas-maafw-controller-adb>=0.1.0", - "automas-maafw-controller-win32>=0.1.0", - "automas-script-maafw>=0.1.0", - "automas-script-maafw-pack-m9a>=0.1.0", -] - -[tool.setuptools] -packages = [] diff --git a/plugins/automas_maafw_agent_env/README.md b/plugins/automas_maafw_agent_env/README.md deleted file mode 100644 index 83f87abd..00000000 --- a/plugins/automas_maafw_agent_env/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# automas-maafw-agent-env - -MaaFW agent command planning and Python environment preparation service. - -It provides `maafw.agent_env.v1`. The package does not import `maa`, does not -start agents, and can be installed without the MaaFW runner package. diff --git a/plugins/automas_maafw_agent_env/pyproject.toml b/plugins/automas_maafw_agent_env/pyproject.toml deleted file mode 100644 index c0bfc962..00000000 --- a/plugins/automas_maafw_agent_env/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "automas-maafw-agent-env" -version = "0.1.0" -description = "MaaFW agent command plan and environment preparation plugin for AUTO-MAS" -readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" -dependencies = [ - "automas-maafw-interface>=0.1.0", - "pydantic>=2", -] - -[project.entry-points."auto_mas.plugins"] -automas_maafw_agent_env = "automas_maafw_agent_env.plugin:Plugin" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -automas_maafw_agent_env = ["py.typed"] diff --git a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/__init__.py b/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/__init__.py deleted file mode 100644 index b29188de..00000000 --- a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from .env import ( - build_agent_env_manifest, - prepare_agent_envs, - write_agent_compat_shims, -) -from .models import ( - MaaFWAgentCommandPlan, - MaaFWAgentEnvPrepareResult, -) -from .planner import ( - MaaFWAgentEnvError, - build_maafw_agent_command_plans, - compute_isolated_venv_path, - venv_python_exe, -) -from .service import MaaFWAgentEnvService - -__all__ = [ - "MaaFWAgentCommandPlan", - "MaaFWAgentEnvError", - "MaaFWAgentEnvPrepareResult", - "MaaFWAgentEnvService", - "build_agent_env_manifest", - "build_maafw_agent_command_plans", - "compute_isolated_venv_path", - "prepare_agent_envs", - "venv_python_exe", - "write_agent_compat_shims", -] diff --git a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/env.py b/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/env.py deleted file mode 100644 index 2248b672..00000000 --- a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/env.py +++ /dev/null @@ -1,468 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Callable - -from .models import MaaFWAgentCommandPlan, MaaFWAgentEnvPrepareResult -from .planner import MaaFWAgentEnvError, venv_python_exe - - -AGENT_BOOTSTRAP_PACKAGE = "json-with-comments" -AGENT_ENV_MANIFEST_NAME = ".auto_mas_agent_env.json" -AGENT_COMPAT_SHIM_DIR_NAME = ".auto_mas_shims" -PIP_HEALTH_CHECK_TIMEOUT = 15 -PIP_INSTALL_TIMEOUT = 120 - - -def prepare_agent_envs( - project_path: str | Path, - plans: list[MaaFWAgentCommandPlan], - *, - send_log: Callable[[str], None] | None = None, - bootstrap_python: str | None = None, - install_dependencies: bool = True, -) -> MaaFWAgentEnvPrepareResult: - resolved_project_path = Path(project_path).resolve() - messages: list[str] = [] - prepared_venvs: list[str] = [] - skipped: list[str] = [] - - def log(message: str) -> None: - messages.append(message) - if send_log is not None: - send_log(message) - - for path_name in ("debug", "logs", "temp"): - (resolved_project_path / path_name).mkdir(exist_ok=True) - - checked_python: set[str] = set() - for plan in plans: - python_exe = plan.command[0] if plan.command else plan.executable - resolved_python = _safe_resolve_python(python_exe) - if resolved_python in checked_python: - log(f"[Python环境] 已检查过该 Python,跳过重复检查: {python_exe}") - continue - - runtime_kind = plan.runtimeKind or "external" - log(f"[Python环境] Agent {plan.childExec} 使用 {runtime_kind}: {python_exe}") - if runtime_kind == "isolated_venv": - prepared_path = _prepare_isolated_venv_env( - plan, - resolved_project_path, - log, - bootstrap_python=bootstrap_python, - install_dependencies=install_dependencies, - ) - prepared_venvs.append(str(prepared_path)) - checked_python.add(resolved_python) - continue - - if runtime_kind == "project_python": - _prepare_project_python_env(python_exe, resolved_project_path, log) - checked_python.add(resolved_python) - continue - - skipped.append(plan.childExec) - log(f"[Python环境] 跳过外部或非 Python 环境检测: {python_exe}") - - return MaaFWAgentEnvPrepareResult( - projectPath=str(resolved_project_path), - plans=plans, - preparedVenvs=prepared_venvs, - skipped=skipped, - messages=messages, - ) - - -def build_agent_env_manifest(project_path: str | Path) -> dict[str, object]: - resolved_project_path = Path(project_path).resolve() - return { - "schemaVersion": 1, - "projectPath": str(resolved_project_path), - "interfaceHash": _project_interface_hash(resolved_project_path), - "requirementsHash": _project_agent_requirements_hash(resolved_project_path), - "requirements": _load_project_agent_requirements(resolved_project_path), - } - - -def write_agent_compat_shims(venv_path: str | Path) -> Path: - shim_dir = Path(venv_path) / AGENT_COMPAT_SHIM_DIR_NAME - shim_dir.mkdir(parents=True, exist_ok=True) - (shim_dir / "sitecustomize.py").write_text( - "\n".join( - [ - "def _patch_legacy_maafw_resource():", - " try:", - " import maa.resource as maa_resource_module", - " if hasattr(maa_resource_module, 'resource'):", - " return", - " from maa.agent.agent_server import AgentServer", - " maa_resource_module.resource = AgentServer", - " except Exception:", - " pass", - "", - "_patch_legacy_maafw_resource()", - "", - ] - ), - encoding="utf-8", - ) - return shim_dir - - -def _prepare_project_python_env( - python_exe: str, - project_path: Path, - log: Callable[[str], None], -) -> None: - log(f"[Python环境] 检测项目 Python: {python_exe}") - test_env = _build_agent_env_for_pip(project_path) - if _check_pip_health(python_exe, cwd=str(project_path), env=test_env, log=log): - return - - raise MaaFWAgentEnvError( - "项目 Python 环境 pip 不可用,请手动修复后重试:\n" - f" Python 路径: {python_exe}\n" - " 处理建议:\n" - " 方法1: 重新下载并解压完整 MaaFW 项目包\n" - " 方法2: 在项目目录中手动修复该项目自带 Python 的 pip 环境\n" - " AUTO-MAS 不会自动修改项目 release 目录。" - ) - - -def _prepare_isolated_venv_env( - agent_plan: MaaFWAgentCommandPlan, - project_path: Path, - log: Callable[[str], None], - *, - bootstrap_python: str | None, - install_dependencies: bool, -) -> Path: - if not agent_plan.isolatedVenvPath: - raise MaaFWAgentEnvError("隔离 venv 路径未提供,无法创建隔离环境") - - venv_path = Path(agent_plan.isolatedVenvPath).resolve() - python_exe = agent_plan.command[0] if agent_plan.command else str(venv_python_exe(venv_path)) - - log(f"[Python环境] 准备隔离 venv: {venv_path}") - had_valid_venv = _is_valid_venv_path(venv_path) - if _should_rebuild_isolated_venv(venv_path, project_path, log): - _reset_isolated_venv(venv_path, log) - had_valid_venv = False - _ensure_isolated_venv(venv_path, log, bootstrap_python=bootstrap_python) - write_agent_compat_shims(venv_path) - - test_env = _build_agent_env_for_pip(project_path) - test_env["PYTHONPATH"] = str(project_path) - - if not _check_pip_health(python_exe, cwd=str(project_path), env=test_env, log=log): - log("[Python环境] 隔离 venv pip 异常,尝试 ensurepip 修复...") - if not _try_ensurepip(python_exe, cwd=str(project_path), env=test_env, log=log): - raise MaaFWAgentEnvError(f"隔离 venv pip 无法自动修复: {python_exe}") - - if had_valid_venv and _is_isolated_venv_manifest_current(venv_path, project_path): - log("[Python环境] 隔离 venv 依赖清单未变化,跳过 pip install") - return venv_path - - if install_dependencies: - packages = _load_project_agent_requirements(project_path) - log(f"[Python环境] 隔离 venv 安装项目依赖: {', '.join(packages)}") - if not _pip_install(python_exe, packages, cwd=str(project_path), env=test_env, log=log): - raise MaaFWAgentEnvError(f"隔离 venv 依赖安装失败: {python_exe}") - else: - log("[Python环境] 当前调用禁用依赖安装,仅写入隔离 venv manifest") - - _write_isolated_venv_manifest(venv_path, project_path) - return venv_path - - -def _is_valid_venv_path(venv_path: Path) -> bool: - return venv_python_exe(venv_path).is_file() and (venv_path / "pyvenv.cfg").is_file() - - -def _ensure_isolated_venv( - venv_path: Path, - log: Callable[[str], None], - *, - bootstrap_python: str | None, -) -> None: - if _is_valid_venv_path(venv_path): - log(f"[Python环境] 隔离 venv 已存在: {venv_path}") - return - - if venv_path.exists(): - _reset_isolated_venv(venv_path, log) - - venv_path.parent.mkdir(parents=True, exist_ok=True) - python = bootstrap_python or _venv_bootstrap_python() - log(f"[Python环境] 创建隔离 venv: {venv_path} (引导 Python: {python})") - try: - result = subprocess.run( - [python, "-m", "venv", str(venv_path)], - capture_output=True, - timeout=PIP_INSTALL_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - ) - except subprocess.TimeoutExpired as exc: - raise MaaFWAgentEnvError( - f"创建隔离 venv 超时 ({PIP_INSTALL_TIMEOUT}s): {venv_path}" - ) from exc - - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() - raise MaaFWAgentEnvError( - f"创建隔离 venv 失败 (exit={result.returncode}): {detail[:500]}" - ) - if not _is_valid_venv_path(venv_path): - raise MaaFWAgentEnvError(f"创建隔离 venv 后结构不完整: {venv_path}") - log(f"[Python环境] 隔离 venv 创建成功: {venv_path}") - - -def _should_rebuild_isolated_venv( - venv_path: Path, - project_path: Path, - log: Callable[[str], None], -) -> bool: - if venv_path.exists() and not _is_valid_venv_path(venv_path): - log("[Python环境] 隔离 venv 不完整,将重建") - return True - if not _is_valid_venv_path(venv_path): - return False - - manifest_path = venv_path / AGENT_ENV_MANIFEST_NAME - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except FileNotFoundError: - log("[Python环境] 隔离 venv 缺少依赖清单,将重建") - return True - except Exception as exc: - log(f"[Python环境] 隔离 venv 依赖清单异常,将重建: {exc}") - return True - - expected = build_agent_env_manifest(project_path) - if manifest.get("projectPath") != expected["projectPath"]: - log("[Python环境] 隔离 venv 项目路径已变化,将重建") - return True - if manifest.get("interfaceHash") != expected["interfaceHash"]: - log("[Python环境] MaaFW 项目 interface 已变化,将重建隔离 venv") - return True - if manifest.get("requirementsHash") != expected["requirementsHash"]: - log("[Python环境] MaaFW 项目 requirements 已变化,将重建隔离 venv") - return True - return False - - -def _reset_isolated_venv(venv_path: Path, log: Callable[[str], None]) -> None: - if venv_path.parent.name != "maafw_agent_venvs" or not venv_path.name.startswith("maafw_venv_"): - raise MaaFWAgentEnvError(f"拒绝重建非托管隔离 venv: {venv_path}") - shutil.rmtree(venv_path, ignore_errors=True) - log(f"[Python环境] 已清理旧隔离 venv: {venv_path}") - - -def _is_isolated_venv_manifest_current(venv_path: Path, project_path: Path) -> bool: - manifest_path = venv_path / AGENT_ENV_MANIFEST_NAME - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except Exception: - return False - expected = build_agent_env_manifest(project_path) - return ( - manifest.get("projectPath") == expected["projectPath"] - and manifest.get("interfaceHash") == expected["interfaceHash"] - and manifest.get("requirementsHash") == expected["requirementsHash"] - ) - - -def _write_isolated_venv_manifest(venv_path: Path, project_path: Path) -> None: - manifest_path = venv_path / AGENT_ENV_MANIFEST_NAME - manifest_path.write_text( - json.dumps(build_agent_env_manifest(project_path), ensure_ascii=False, indent=2), - encoding="utf-8", - ) - - -def _load_project_agent_requirements(project_path: Path) -> list[str]: - requirements_path = project_path / "requirements.txt" - packages: list[str] = [] - try: - with requirements_path.open("r", encoding="utf-8") as file: - for raw_line in file: - line = raw_line.strip() - if not line or line.startswith("#"): - continue - packages.append(line) - except FileNotFoundError: - pass - - normalized = {item.split(";", 1)[0].strip().lower() for item in packages} - if not any(item.startswith(AGENT_BOOTSTRAP_PACKAGE) for item in normalized): - packages.append(AGENT_BOOTSTRAP_PACKAGE) - return packages - - -def _project_agent_requirements_hash(project_path: Path) -> str: - payload = json.dumps( - _load_project_agent_requirements(project_path), - ensure_ascii=False, - separators=(",", ":"), - ) - return hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def _project_interface_hash(project_path: Path) -> str: - for name in ("interface.json", "interface.jsonc"): - path = project_path / name - if path.is_file(): - return hashlib.sha256(path.read_bytes()).hexdigest() - return "" - - -def _build_agent_env_for_pip(project_path: Path) -> dict[str, str]: - env = os.environ.copy() - env.pop("VIRTUAL_ENV", None) - env.pop("PYTHONHOME", None) - env.pop("PYTHONUSERBASE", None) - env.pop("PIP_TARGET", None) - env.pop("PIP_PREFIX", None) - env.pop("PIP_USER", None) - env["PYTHONPATH"] = str(project_path) - return env - - -def _check_pip_health( - python_exe: str, - *, - cwd: str | None, - env: dict[str, str], - log: Callable[[str], None], -) -> bool: - try: - result = subprocess.run( - [python_exe, "-m", "pip", "--version"], - capture_output=True, - timeout=PIP_HEALTH_CHECK_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - cwd=cwd, - env=env, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() - log(f"[Python环境] pip --version 失败 (exit={result.returncode}): {detail[:500]}") - return False - - install_check = subprocess.run( - [ - python_exe, - "-c", - "from pip._internal.commands.install import InstallCommand; print('install command OK')", - ], - capture_output=True, - timeout=PIP_HEALTH_CHECK_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - cwd=cwd, - env=env, - ) - if install_check.returncode == 0: - log(f"[Python环境] pip 健康: {result.stdout.strip()}") - return True - - detail = (install_check.stderr or install_check.stdout or "").strip() - if "backports.zstd" in detail or "ZstdError" in detail: - log("[Python环境] pip install 子命令加载失败(backports.zstd 冲突)") - else: - log(f"[Python环境] pip install 检测失败 (exit={install_check.returncode}): {detail[:500]}") - return False - except subprocess.TimeoutExpired: - log(f"[Python环境] pip 检测超时 ({PIP_HEALTH_CHECK_TIMEOUT}s)") - return False - except Exception as exc: - log(f"[Python环境] pip 检测异常: {exc}") - return False - - -def _try_ensurepip( - python_exe: str, - *, - cwd: str | None, - env: dict[str, str], - log: Callable[[str], None], -) -> bool: - log("[Python环境] 修复策略 A (ensurepip)...") - try: - result = subprocess.run( - [python_exe, "-m", "ensurepip", "--upgrade"], - capture_output=True, - timeout=PIP_INSTALL_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - cwd=cwd, - env=env, - ) - if result.returncode == 0 and _check_pip_health(python_exe, cwd=cwd, env=env, log=log): - log("[Python环境] ensurepip 修复成功") - return True - detail = (result.stderr or result.stdout or "").strip() - log(f"[Python环境] ensurepip 未成功: {detail[:300]}") - except subprocess.TimeoutExpired: - log(f"[Python环境] ensurepip 超时 ({PIP_INSTALL_TIMEOUT}s)") - except Exception as exc: - log(f"[Python环境] ensurepip 执行异常: {exc}") - return False - - -def _pip_install( - python_exe: str, - packages: list[str], - *, - cwd: str | None, - env: dict[str, str], - log: Callable[[str], None], -) -> bool: - try: - result = subprocess.run( - [python_exe, "-m", "pip", "install", "--quiet", *packages], - capture_output=True, - timeout=PIP_INSTALL_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - cwd=cwd, - env=env, - ) - if result.returncode == 0: - log(f"[Python环境] pip install 完成: {', '.join(packages)}") - return True - detail = (result.stderr or result.stdout or "").strip() - log(f"[Python环境] pip install 未成功: {detail[:300]}") - except subprocess.TimeoutExpired: - log(f"[Python环境] pip install 超时 ({PIP_INSTALL_TIMEOUT}s)") - except Exception as exc: - log(f"[Python环境] pip install 异常: {exc}") - return False - - -def _venv_bootstrap_python() -> str: - portable_python = Path.cwd() / "environment" / "python" / "python.exe" - if portable_python.is_file(): - return str(portable_python) - return sys.executable - - -def _safe_resolve_python(path: str) -> str: - try: - return str(Path(path).resolve()) - except Exception: - return path diff --git a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/models.py b/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/models.py deleted file mode 100644 index bb52163d..00000000 --- a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/models.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from pydantic import BaseModel, Field - - -MaaFWAgentRuntimeKind = Literal[ - "embedded", - "project_python", - "project_binary", - "isolated_venv", - "external", -] - - -class MaaFWAgentCommandPlan(BaseModel): - childExec: str - executable: str - executableExists: bool | None = None - fallbackReason: str | None = None - runtimeKind: MaaFWAgentRuntimeKind | str | None = None - isolatedVenvPath: str | None = None - childArgs: list[str] = Field(default_factory=list) - command: list[str] = Field(default_factory=list) - cwd: str - identifier: str | None = None - embedded: bool = False - - -class MaaFWAgentEnvPrepareResult(BaseModel): - projectPath: str - plans: list[MaaFWAgentCommandPlan] = Field(default_factory=list) - preparedVenvs: list[str] = Field(default_factory=list) - skipped: list[str] = Field(default_factory=list) - messages: list[str] = Field(default_factory=list) diff --git a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/planner.py b/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/planner.py deleted file mode 100644 index ec2484e7..00000000 --- a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/planner.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -import hashlib -import os -from pathlib import Path -from typing import Any - -from automas_maafw_interface.models import MaaFWAgent - -from .models import MaaFWAgentCommandPlan - - -class MaaFWAgentEnvError(RuntimeError): - """Raised when MaaFW agent command plans or environments are invalid.""" - - -def build_maafw_agent_command_plans( - base_dir: str | Path, - raw_agent: MaaFWAgent | list[MaaFWAgent] | dict[str, Any] | list[dict[str, Any]] | None, - *, - managed_env_root: str | Path | None = None, -) -> list[MaaFWAgentCommandPlan]: - resolved_base_dir = Path(base_dir).resolve() - agent_configs = _coerce_agent_configs(raw_agent) - return [ - _build_agent_command_plan( - resolved_base_dir, - agent_config, - managed_env_root=managed_env_root, - ) - for agent_config in agent_configs - ] - - -def compute_isolated_venv_path( - base_dir: str | Path, - *, - managed_env_root: str | Path | None = None, -) -> Path: - resolved_base_dir = Path(base_dir).resolve() - key = str(resolved_base_dir) - if os.name == "nt": - key = key.lower() - digest = hashlib.sha1(key.encode("utf-8")).hexdigest()[:12] - root = Path(managed_env_root) if managed_env_root is not None else Path.cwd() / "config" / "maafw_agent_venvs" - return root.resolve() / f"maafw_venv_{digest}" - - -def venv_python_exe(venv_path: str | Path) -> Path: - path = Path(venv_path) - if os.name == "nt": - return path / "Scripts" / "python.exe" - return path / "bin" / "python" - - -def _coerce_agent_configs( - raw_agent: MaaFWAgent | list[MaaFWAgent] | dict[str, Any] | list[dict[str, Any]] | None, -) -> list[MaaFWAgent]: - if raw_agent is None: - return [] - if isinstance(raw_agent, (list, tuple)): - return [_coerce_single_agent(item) for item in raw_agent] - return [_coerce_single_agent(raw_agent)] - - -def _coerce_single_agent(item: Any) -> MaaFWAgent: - # 不用 isinstance(item, MaaFWAgent) 直接判定:dev HMR 会重载 - # automas_maafw_interface.models,使得缓存 interface 携带的旧 MaaFWAgent - # 类与本模块 import 的新类实例判定失败。改为按 dict / 任意 pydantic 模型 - # 归一化(与 runner._coerce_interface 的 model_dump 兜底同源)。 - if isinstance(item, MaaFWAgent): - return item - if isinstance(item, dict): - return MaaFWAgent.model_validate(item) - if hasattr(item, "model_dump"): - return MaaFWAgent.model_validate(item.model_dump()) - raise MaaFWAgentEnvError("agent declaration must be an object or list") - - -def _build_agent_command_plan( - base_dir: Path, - agent_config: MaaFWAgent, - *, - managed_env_root: str | Path | None, -) -> MaaFWAgentCommandPlan: - child_args = [ - _replace_project_dir(arg, base_dir) - for arg in agent_config.child_args or [] - ] - embedded_requested = agent_config.embedded is True - executable = _resolve_executable( - base_dir, - agent_config.child_exec, - managed_env_root=managed_env_root, - ) - if ( - embedded_requested - and executable["runtime_kind"] != "project_binary" - and not _has_python_entry_arg(child_args) - ): - child_args = ["-u", str((base_dir / "agent" / "main.py").resolve())] - - fallback_reason = executable["fallback_reason"] - if embedded_requested: - isolation_reason = ( - "embedded agent 已切换为隔离子进程,避免污染 AUTO-MAS 主进程 Python 环境" - ) - fallback_reason = ( - f"{isolation_reason}; {fallback_reason}" - if fallback_reason - else isolation_reason - ) - - command = [executable["command"], *child_args, ""] - return MaaFWAgentCommandPlan( - childExec=agent_config.child_exec, - executable=executable["command"], - executableExists=executable["exists"], - fallbackReason=fallback_reason, - runtimeKind=executable["runtime_kind"], - isolatedVenvPath=executable["isolated_venv_path"], - childArgs=child_args, - command=command, - cwd=str(base_dir), - identifier=agent_config.identifier, - embedded=False, - ) - - -def _resolve_executable( - base_dir: Path, - child_exec: str, - *, - managed_env_root: str | Path | None, -) -> dict[str, Any]: - replaced = _replace_project_dir(child_exec, base_dir) - if not _looks_like_project_path(replaced): - return { - "command": replaced, - "exists": None, - "fallback_reason": None, - "runtime_kind": "external", - "isolated_venv_path": None, - } - - resolved_path = _resolve_project_executable_path(base_dir, replaced) - if resolved_path.is_file(): - return { - "command": str(resolved_path), - "exists": True, - "fallback_reason": None, - "runtime_kind": _classify_existing_project_executable(resolved_path), - "isolated_venv_path": None, - } - - if _is_bundled_python_pattern(child_exec, base_dir): - venv_path = compute_isolated_venv_path( - base_dir, - managed_env_root=managed_env_root, - ) - venv_python = venv_python_exe(venv_path) - return { - "command": str(venv_python), - "exists": venv_python.exists(), - "fallback_reason": ( - f"项目声明的解释器不存在: {resolved_path}," - f"将使用该项目专属隔离 venv: {venv_path}" - ), - "runtime_kind": "isolated_venv", - "isolated_venv_path": str(venv_path), - } - - return { - "command": str(resolved_path), - "exists": False, - "fallback_reason": f"声明的可执行文件不存在: {resolved_path}", - "runtime_kind": "external", - "isolated_venv_path": None, - } - - -def _resolve_project_executable_path(base_dir: Path, raw_path: str) -> Path: - resolved_path = _resolve_project_path(base_dir, raw_path) - if resolved_path.exists() or os.name != "nt" or resolved_path.suffix: - return resolved_path - - exe_candidate = resolved_path.with_suffix(".exe") - if _is_within_base_dir(exe_candidate.resolve(), base_dir) and exe_candidate.is_file(): - return exe_candidate.resolve() - return resolved_path - - -def _classify_existing_project_executable(resolved_path: Path) -> str: - if resolved_path.name.lower() in {"python", "python.exe", "pythonw", "pythonw.exe"}: - return "project_python" - return "project_binary" - - -def _is_bundled_python_pattern(child_exec: str, base_dir: Path) -> bool: - normalized = child_exec.replace("\\", "/").lower() - if not normalized.endswith("python/python.exe"): - return False - return (base_dir / "agent" / "main.py").exists() - - -def _replace_project_dir(value: str, base_dir: Path) -> str: - return value.replace("{PROJECT_DIR}", str(base_dir)) - - -def _has_python_entry_arg(child_args: list[str]) -> bool: - return any(str(arg).lower().endswith(".py") for arg in child_args) - - -def _looks_like_project_path(value: str) -> bool: - if value.startswith("."): - return True - if "/" in value or "\\" in value: - return True - return Path(value).is_absolute() - - -def _resolve_project_path(base_dir: Path, raw_path: str) -> Path: - replaced = _replace_project_dir(raw_path, base_dir) - candidate = Path(replaced) - if not candidate.is_absolute(): - candidate = base_dir / candidate - resolved_path = candidate.resolve() - if not _is_within_base_dir(resolved_path, base_dir): - raise MaaFWAgentEnvError(f"路径越界,禁止访问项目目录之外的 agent 文件: {raw_path}") - return resolved_path - - -def _is_within_base_dir(path: Path, base_dir: Path) -> bool: - try: - path.relative_to(base_dir) - return True - except ValueError: - return False diff --git a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/plugin.py b/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/plugin.py deleted file mode 100644 index 9b64c136..00000000 --- a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/plugin.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from .service import MaaFWAgentEnvService - -if TYPE_CHECKING: - from auto_mas_core import PluginContext - - -DEFAULT_INSTANCE = { - "name": "MaaFW Agent Env", - "enabled": True, - "config": {}, -} - -schema = { - "__no_plugin_config__": { - "type": "boolean", - "default": True, - "hidden": True, - "configurable": False, - "title": "No plugin-level configuration", - }, -} - - -class Plugin: - provides = ["maafw.agent_env.v1"] - - def __init__(self, ctx: "PluginContext") -> None: - self.ctx = ctx - self.service = MaaFWAgentEnvService() - - async def on_start(self) -> None: - self.ctx.set("maafw.agent_env.v1", self.service) - self.ctx.logger.info("maafw.agent_env.v1 ready") - - async def on_stop(self, reason: str) -> None: - self.ctx.logger.info(f"maafw.agent_env.v1 stopped, reason={reason}") diff --git a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/py.typed b/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/py.typed deleted file mode 100644 index 8b137891..00000000 --- a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/service.py b/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/service.py deleted file mode 100644 index 062c1b88..00000000 --- a/plugins/automas_maafw_agent_env/src/automas_maafw_agent_env/service.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from automas_maafw_interface.models import MaaFWAgent, MaaFWInterface - -from .env import prepare_agent_envs -from .models import MaaFWAgentCommandPlan, MaaFWAgentEnvPrepareResult -from .planner import build_maafw_agent_command_plans - - -class MaaFWAgentEnvService: - """maafw.agent_env.v1 service.""" - - def classify(self, agent: MaaFWAgent | dict[str, Any]) -> str: - plans = self.build_command_plans(".", agent) - return plans[0].runtimeKind or "external" if plans else "external" - - def build_command_plans( - self, - project_path: str | Path, - interface_or_agent: MaaFWInterface | MaaFWAgent | dict[str, Any] | list[dict[str, Any]] | None, - *, - managed_env_root: str | Path | None = None, - ) -> list[MaaFWAgentCommandPlan]: - raw_agent = self._extract_agent(interface_or_agent) - return build_maafw_agent_command_plans( - project_path, - raw_agent, - managed_env_root=managed_env_root, - ) - - def prepare_env( - self, - project_path: str | Path, - interface_or_agent: MaaFWInterface | MaaFWAgent | dict[str, Any] | list[dict[str, Any]] | None, - *, - managed_env_root: str | Path | None = None, - send_log: Any = None, - bootstrap_python: str | None = None, - install_dependencies: bool = True, - ) -> MaaFWAgentEnvPrepareResult: - plans = self.build_command_plans( - project_path, - interface_or_agent, - managed_env_root=managed_env_root, - ) - return prepare_agent_envs( - project_path, - plans, - send_log=send_log, - bootstrap_python=bootstrap_python, - install_dependencies=install_dependencies, - ) - - @staticmethod - def _extract_agent( - value: MaaFWInterface | MaaFWAgent | dict[str, Any] | list[dict[str, Any]] | None, - ) -> Any: - if isinstance(value, MaaFWInterface): - return value.agent - if isinstance(value, MaaFWAgent): - return value - if isinstance(value, dict): - if "interface_version" in value or "interfaceVersion" in value: - return MaaFWInterface.model_validate(value).agent - if "agent" in value: - return value.get("agent") - return value - if hasattr(value, "model_dump"): - return MaaFWInterface.model_validate( - value.model_dump(mode="json", by_alias=True) - ).agent - if hasattr(value, "agent"): - return getattr(value, "agent") - return value diff --git a/plugins/automas_maafw_controller_adb/README.md b/plugins/automas_maafw_controller_adb/README.md deleted file mode 100644 index 5d5ef04b..00000000 --- a/plugins/automas_maafw_controller_adb/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# automas-maafw-controller-adb - -ADB controller provider for the AUTO-MAS MaaFW plugin group. - -This package declares the ADB controller capability without importing `maa` at -plugin startup. Runtime device resolution remains owned by the MaaFW script -adapter until the host controller path is fully split. diff --git a/plugins/automas_maafw_controller_adb/pyproject.toml b/plugins/automas_maafw_controller_adb/pyproject.toml deleted file mode 100644 index 9532405c..00000000 --- a/plugins/automas_maafw_controller_adb/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "automas-maafw-controller-adb" -version = "0.1.0" -description = "ADB controller provider for AUTO-MAS MaaFW plugins" -readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" -dependencies = [ - "pydantic>=2", -] - -[project.entry-points."auto_mas.plugins"] -automas_maafw_controller_adb = "automas_maafw_controller_adb.plugin:Plugin" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -automas_maafw_controller_adb = ["py.typed"] diff --git a/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/__init__.py b/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/__init__.py deleted file mode 100644 index bdec2fc8..00000000 --- a/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -__all__: list[str] = [] diff --git a/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/plugin.py b/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/plugin.py deleted file mode 100644 index 1c4ed44c..00000000 --- a/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/plugin.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from .service import MaaFWAdbControllerService - -if TYPE_CHECKING: - from auto_mas_core import PluginContext - - -DEFAULT_INSTANCE = { - "name": "MaaFW ADB Controller", - "enabled": True, - "config": {}, -} - -schema = { - "__no_plugin_config__": { - "type": "boolean", - "default": True, - "hidden": True, - "configurable": False, - "title": "No plugin-level configuration", - }, -} - - -class Plugin: - provides = ["maafw.controller.adb"] - wants = ["maafw.registry.v1"] - - def __init__(self, ctx: "PluginContext") -> None: - self.ctx = ctx - self.service = MaaFWAdbControllerService() - - async def on_start(self) -> None: - self.ctx.set("maafw.controller.adb", self.service) - registry = self.ctx.get("maafw.registry.v1") - if registry is not None and hasattr(registry, "register_controller_provider"): - registry.register_controller_provider(self.service.get_provider_definition()) - self.ctx.logger.info("maafw.controller.adb ready") - - async def on_stop(self, reason: str) -> None: - registry = self.ctx.get("maafw.registry.v1") - if registry is not None and hasattr(registry, "unregister_controller_provider"): - registry.unregister_controller_provider("adb") - self.ctx.logger.info(f"maafw.controller.adb stopped, reason={reason}") diff --git a/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/py.typed b/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/py.typed deleted file mode 100644 index 8b137891..00000000 --- a/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/service.py b/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/service.py deleted file mode 100644 index f08a2e8a..00000000 --- a/plugins/automas_maafw_controller_adb/src/automas_maafw_controller_adb/service.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from typing import Any - - -class MaaFWAdbControllerService: - """maafw.controller.adb service.""" - - key = "adb" - display_name = "ADB" - controller_types = ["Adb"] - - def get_provider_definition(self) -> dict[str, Any]: - return { - "key": self.key, - "displayName": self.display_name, - "controllerTypes": list(self.controller_types), - "capabilities": [ - "device_spec", - "emulator_service_consumption", - ], - } - - def build_device_spec( - self, - *, - adb_path: str | None = None, - address: str | None = None, - screencap_methods: int = 0, - input_methods: int = 0, - config: dict[str, Any] | None = None, - ) -> dict[str, Any]: - return { - "type": "Adb", - "adbPath": adb_path, - "address": address, - "screencapMethods": screencap_methods, - "inputMethods": input_methods, - "config": dict(config or {}), - } diff --git a/plugins/automas_maafw_controller_win32/README.md b/plugins/automas_maafw_controller_win32/README.md deleted file mode 100644 index 1fd2609c..00000000 --- a/plugins/automas_maafw_controller_win32/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# automas-maafw-controller-win32 - -Win32 controller provider for the AUTO-MAS MaaFW plugin group. - -The service exposes window scanning helpers. `maa.toolkit` is imported lazily -only when Win32 windows are scanned. diff --git a/plugins/automas_maafw_controller_win32/pyproject.toml b/plugins/automas_maafw_controller_win32/pyproject.toml deleted file mode 100644 index e7134f8e..00000000 --- a/plugins/automas_maafw_controller_win32/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "automas-maafw-controller-win32" -version = "0.1.0" -description = "Win32 controller provider for AUTO-MAS MaaFW plugins" -readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" -dependencies = [ - "automas-maafw-interface>=0.1.0", - "pydantic>=2", -] - -[project.entry-points."auto_mas.plugins"] -automas_maafw_controller_win32 = "automas_maafw_controller_win32.plugin:Plugin" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -automas_maafw_controller_win32 = ["py.typed"] diff --git a/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/__init__.py b/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/__init__.py deleted file mode 100644 index bdec2fc8..00000000 --- a/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -__all__: list[str] = [] diff --git a/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/plugin.py b/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/plugin.py deleted file mode 100644 index 2fcdc2b4..00000000 --- a/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/plugin.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from .service import MaaFWWin32ControllerService - -if TYPE_CHECKING: - from auto_mas_core import PluginContext - - -DEFAULT_INSTANCE = { - "name": "MaaFW Win32 Controller", - "enabled": True, - "config": {}, -} - -schema = { - "__no_plugin_config__": { - "type": "boolean", - "default": True, - "hidden": True, - "configurable": False, - "title": "No plugin-level configuration", - }, -} - - -class Plugin: - provides = ["maafw.controller.win32"] - wants = ["maafw.registry.v1"] - - def __init__(self, ctx: "PluginContext") -> None: - self.ctx = ctx - self.service = MaaFWWin32ControllerService() - - async def on_start(self) -> None: - self.ctx.set("maafw.controller.win32", self.service) - registry = self.ctx.get("maafw.registry.v1") - if registry is not None and hasattr(registry, "register_controller_provider"): - registry.register_controller_provider(self.service.get_provider_definition()) - self.ctx.logger.info("maafw.controller.win32 ready") - - async def on_stop(self, reason: str) -> None: - registry = self.ctx.get("maafw.registry.v1") - if registry is not None and hasattr(registry, "unregister_controller_provider"): - registry.unregister_controller_provider("win32") - self.ctx.logger.info(f"maafw.controller.win32 stopped, reason={reason}") diff --git a/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/py.typed b/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/py.typed deleted file mode 100644 index 8b137891..00000000 --- a/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/service.py b/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/service.py deleted file mode 100644 index b1cfbe17..00000000 --- a/plugins/automas_maafw_controller_win32/src/automas_maafw_controller_win32/service.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -import re -import sys -from dataclasses import dataclass -from typing import Any, Iterable - -from automas_maafw_interface.models import MaaFWController - -MAX_REGEX_PATTERN_LENGTH = 256 -MAX_REGEX_VALUE_LENGTH = 4096 -DANGEROUS_NESTED_QUANTIFIER_RE = re.compile( - r"\([^)]*(?:[*+]|\{\d+(?:,\d*)?\})[^)]*\)(?:[*+]|\{\d+(?:,\d*)?\})" -) - - -@dataclass(frozen=True) -class MaaFWWin32Window: - hWnd: int - className: str - windowName: str - - -@dataclass(frozen=True) -class MaaFWWindowMatch(MaaFWWin32Window): - controllerName: str - controllerType: str - - -class MaaFWWin32ControllerService: - """maafw.controller.win32 service.""" - - key = "win32" - display_name = "Win32" - controller_types = ["Win32"] - - def get_provider_definition(self) -> dict[str, Any]: - return { - "key": self.key, - "displayName": self.display_name, - "controllerTypes": list(self.controller_types), - "capabilities": ["window_scan", "device_spec"], - } - - def list_windows(self) -> list[MaaFWWin32Window]: - if sys.platform != "win32": - raise RuntimeError("Win32 窗口扫描仅支持 Windows") - - from maa.toolkit import Toolkit - - return [ - MaaFWWin32Window( - hWnd=_normalize_hwnd(window.hwnd), - className=str(window.class_name or ""), - windowName=str(window.window_name or ""), - ) - for window in Toolkit.find_desktop_windows() - ] - - def match_controller_windows( - self, - controller: MaaFWController | dict[str, Any], - windows: Iterable[MaaFWWin32Window] | None = None, - ) -> list[MaaFWWindowMatch]: - controller_model = ( - controller - if isinstance(controller, MaaFWController) - else MaaFWController.model_validate(controller) - ) - if controller_model.type != "Win32": - return [] - - class_regex, window_regex = _controller_window_regex(controller_model) - source_windows = list(windows) if windows is not None else self.list_windows() - matched: list[MaaFWWindowMatch] = [] - seen_hwnds: set[int] = set() - - for window in source_windows: - if window.hWnd in seen_hwnds: - continue - if not _matches_regex(class_regex, window.className): - continue - if not _matches_regex(window_regex, window.windowName): - continue - - seen_hwnds.add(window.hWnd) - matched.append( - MaaFWWindowMatch( - hWnd=window.hWnd, - className=window.className, - windowName=window.windowName, - controllerName=controller_model.name, - controllerType=controller_model.type, - ) - ) - - return matched - - def build_device_spec( - self, - *, - h_wnd: int | None = None, - screencap_method: int = 0, - mouse_method: int = 0, - keyboard_method: int = 0, - ) -> dict[str, Any]: - return { - "type": "Win32", - "hWnd": h_wnd, - "screencapMethod": screencap_method, - "mouseMethod": mouse_method, - "keyboardMethod": keyboard_method, - } - - -def _controller_window_regex(controller: MaaFWController) -> tuple[str | None, str | None]: - if controller.type == "Win32" and controller.win32 is not None: - return controller.win32.class_regex, controller.win32.window_regex - return None, None - - -def _matches_regex(pattern: str | None, value: str) -> bool: - if not pattern: - return True - try: - compiled_pattern = _compile_safe_regex(pattern) - return compiled_pattern.search(value[:MAX_REGEX_VALUE_LENGTH]) is not None - except re.error as exc: - raise RuntimeError(f"interface 窗口匹配正则无效: {pattern}") from exc - - -def _compile_safe_regex(pattern: str) -> re.Pattern[str]: - if len(pattern) > MAX_REGEX_PATTERN_LENGTH: - raise RuntimeError("interface window regex is too long") - if DANGEROUS_NESTED_QUANTIFIER_RE.search(pattern): - raise RuntimeError("interface window regex contains nested quantifiers") - return re.compile(pattern) - - -def _normalize_hwnd(value: Any) -> int: - if hasattr(value, "value"): - value = value.value - if value is None: - return 0 - return int(value) diff --git a/plugins/automas_maafw_interface/README.md b/plugins/automas_maafw_interface/README.md deleted file mode 100644 index 2af9fb0a..00000000 --- a/plugins/automas_maafw_interface/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# automas-maafw-interface - -MaaFW ProjectInterface parser and AUTO-MAS plugin service. - -It provides `maafw.interface.v1` and can also be imported directly by other -Python packages. diff --git a/plugins/automas_maafw_interface/pyproject.toml b/plugins/automas_maafw_interface/pyproject.toml deleted file mode 100644 index fab011a0..00000000 --- a/plugins/automas_maafw_interface/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "automas-maafw-interface" -version = "0.1.1" -description = "MaaFW ProjectInterface parser plugin for AUTO-MAS" -readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" -dependencies = [ - "json5>=0.9", - "pydantic>=2", -] - -[project.entry-points."auto_mas.plugins"] -automas_maafw_interface = "automas_maafw_interface.plugin:Plugin" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -automas_maafw_interface = ["py.typed"] diff --git a/plugins/automas_maafw_interface/src/automas_maafw_interface/__init__.py b/plugins/automas_maafw_interface/src/automas_maafw_interface/__init__.py deleted file mode 100644 index 6bb98be1..00000000 --- a/plugins/automas_maafw_interface/src/automas_maafw_interface/__init__.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from .loader import ( - MaaFWInterfaceLoadError, - load_interface_model, - load_interface_model_cached, - rescan_scan_select_option, -) -from .models import ( - MaaFWController, - MaaFWInterface, - MaaFWOption, - MaaFWPreset, - MaaFWResource, - MaaFWTask, -) -from .service import MaaFWInterfaceService -from .task_config import ( - MaaFWTaskConfig, - MaaFWTaskPresetSnapshot, - build_interface_preset_snapshot, - normalize_snapshot, - normalize_task_config, - normalize_task_execution_payload, - normalize_task_options_by_task, -) - -__all__ = [ - "MaaFWController", - "MaaFWInterface", - "MaaFWInterfaceLoadError", - "MaaFWInterfaceService", - "MaaFWOption", - "MaaFWPreset", - "MaaFWResource", - "MaaFWTask", - "MaaFWTaskConfig", - "MaaFWTaskPresetSnapshot", - "build_interface_preset_snapshot", - "load_interface_model", - "load_interface_model_cached", - "normalize_snapshot", - "normalize_task_config", - "normalize_task_execution_payload", - "normalize_task_options_by_task", - "rescan_scan_select_option", -] diff --git a/plugins/automas_maafw_interface/src/automas_maafw_interface/loader.py b/plugins/automas_maafw_interface/src/automas_maafw_interface/loader.py deleted file mode 100644 index 0b88d5af..00000000 --- a/plugins/automas_maafw_interface/src/automas_maafw_interface/loader.py +++ /dev/null @@ -1,1075 +0,0 @@ -# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software -# Copyright © 2025-2026 AUTO-MAS Team - -# This file is part of AUTO-MAS. - -# AUTO-MAS is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of -# the License, or (at your option) any later version. - -# AUTO-MAS is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty -# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See -# the GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with AUTO-MAS. If not, see . - - -import copy -import hashlib -import json -import logging -import time -from pathlib import Path -from threading import RLock -from typing import Any - -import json5 - -from .models import ( - SUPPORTED_OPTION_TYPES, - MaaFWInterface, - MaaFWOption, - MaaFWOptionCase, - MaaFWPretask, - build_pretask_task_name, - iter_pretasks, -) - - -IMPORTABLE_KEYS = ( - "task", - "option", - "global_option", - "preset", - "group", - "pretask", - "import", -) -logger = logging.getLogger("automas.maafw.interface.loader") - -DISK_CACHE_VERSION = 2 -DISK_CACHE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 -DISK_CACHE_CLEANUP_INTERVAL_SECONDS = 24 * 60 * 60 -_interface_cache: dict[Path, tuple[tuple, MaaFWInterface, set[Path], set[tuple[Path, str]]]] = {} -_cache_lock = RLock() -_last_disk_cache_cleanup_at = 0.0 - - -class MaaFWInterfaceLoadError(ValueError): - """Raised when a MaaFW ProjectInterface cannot be loaded safely.""" - - -class _MergeState: - def __init__(self) -> None: - self.group_names: set[str] = set() - self.global_option_names: set[str] = set() - self.pretask_names: set[str] = set() - - -class _LoadContext: - def __init__(self) -> None: - self.dependency_paths: set[Path] = set() - self.scan_select_specs: set[tuple[Path, str]] = set() - - -def _read_json_dict(path: Path, context: _LoadContext | None = None) -> dict[str, Any]: - if context is not None: - context.dependency_paths.add(path.resolve()) - - try: - with path.open("r", encoding="utf-8") as file: - data = json5.load(file) - except FileNotFoundError as exc: - raise MaaFWInterfaceLoadError(f"找不到 interface 配置文件: {path}") from exc - except Exception as exc: - raise MaaFWInterfaceLoadError(f"解析 interface 配置失败: {path}: {exc}") from exc - - if not isinstance(data, dict): - raise MaaFWInterfaceLoadError(f"interface 配置文件必须是 JSON 对象: {path}") - return data - - -def _normalize_import_list(raw_value: Any, source_path: Path) -> list[str]: - if raw_value is None: - return [] - if not isinstance(raw_value, list): - raise MaaFWInterfaceLoadError(f"import 字段必须是字符串数组: {source_path}") - if not all(isinstance(item, str) and item.strip() for item in raw_value): - raise MaaFWInterfaceLoadError(f"import 字段必须是非空字符串数组: {source_path}") - return raw_value - - -def _normalize_project_relative_path(raw_path: str, *, field_name: str) -> str: - normalized_path = raw_path.strip().replace("\\", "/") - if not normalized_path: - raise MaaFWInterfaceLoadError(f"{field_name} 不能为空") - - candidate = Path(normalized_path) - if candidate.is_absolute() or candidate.drive or candidate.root: - raise MaaFWInterfaceLoadError(f"{field_name} 不允许使用绝对路径: {raw_path}") - - parts: list[str] = [] - for part in normalized_path.split("/"): - if part in {"", "."}: - continue - if part == "..": - raise MaaFWInterfaceLoadError(f"{field_name} 不允许包含 .. 路径段: {raw_path}") - parts.append(part) - - if not parts: - raise MaaFWInterfaceLoadError(f"{field_name} 不能为空") - return "/".join(parts) - - -def _is_within_base_dir(path: Path, base_dir: Path) -> bool: - try: - path.relative_to(base_dir) - return True - except ValueError: - return False - - -def _resolve_project_relative_path( - base_dir: Path, - raw_path: str, - *, - field_name: str, -) -> Path: - normalized_path = _normalize_project_relative_path(raw_path, field_name=field_name) - resolved_path = (base_dir / normalized_path).resolve() - if not _is_within_base_dir(resolved_path, base_dir): - raise MaaFWInterfaceLoadError( - f"{field_name} 越界,禁止访问 MaaFW 项目目录之外的路径: {raw_path}" - ) - return resolved_path - - -def _resolve_interface_path(base_dir: Path) -> Path: - for file_name in ("interface.json", "interface.jsonc"): - interface_path = (base_dir / file_name).resolve() - if interface_path.exists(): - if not _is_within_base_dir(interface_path, base_dir): - raise MaaFWInterfaceLoadError(f"{file_name} 不在 MaaFW 项目目录内") - return interface_path - raise MaaFWInterfaceLoadError("请设置包含 interface.json 的 MaaFW 项目目录") - - -def _resolve_import_path(import_path: str, base_dir: Path) -> Path: - resolved_path = _resolve_project_relative_path( - base_dir, - import_path, - field_name="import", - ) - if not resolved_path.exists() or not resolved_path.is_file(): - raise MaaFWInterfaceLoadError(f"import 文件不存在: {import_path}") - return resolved_path - - -def _validate_importable_fragment(data: dict[str, Any], source_path: Path) -> None: - invalid_keys = sorted(set(data) - set(IMPORTABLE_KEYS)) - if invalid_keys: - logger.warning( - "MaaFW ProjectInterface 导入文件包含暂不支持的字段,已忽略:%s;文件:%s", - ", ".join(invalid_keys), - source_path, - ) - - -def _warn_unsupported_root_fields(data: dict[str, Any], source_path: Path) -> None: - supported_keys = { - field.alias or field_name - for field_name, field in MaaFWInterface.model_fields.items() - } - supported_keys.add("setting") - unsupported_keys = sorted(set(data) - supported_keys) - if unsupported_keys: - logger.warning( - "MaaFW ProjectInterface 包含暂不支持的顶层字段,已忽略:%s;文件:%s", - ", ".join(unsupported_keys), - source_path, - ) - - -def _validate_tasks(tasks: Any, source_path: Path) -> None: - if tasks is None: - return - if not isinstance(tasks, list): - raise MaaFWInterfaceLoadError(f"task 字段必须是数组: {source_path}") - - for index, task in enumerate(tasks): - if not isinstance(task, dict): - raise MaaFWInterfaceLoadError(f"task[{index}] 必须是对象: {source_path}") - - task_name = task.get("name") - task_entry = task.get("entry") - if not isinstance(task_name, str) or not task_name: - raise MaaFWInterfaceLoadError( - f"task[{index}].name 必须是非空字符串: {source_path}" - ) - if not isinstance(task_entry, str) or not task_entry: - raise MaaFWInterfaceLoadError( - f"task[{index}].entry 必须是非空字符串: {source_path}" - ) - - -def _validate_options(options: Any, source_path: Path) -> None: - if options is None: - return - if not isinstance(options, dict): - raise MaaFWInterfaceLoadError(f"option 字段必须是对象: {source_path}") - - for option_key in options: - if not isinstance(option_key, str) or not option_key: - raise MaaFWInterfaceLoadError(f"option 键必须是非空字符串: {source_path}") - - -def _validate_preset_section(presets: Any, source_path: Path) -> None: - if presets is None: - return - if not isinstance(presets, list): - raise MaaFWInterfaceLoadError(f"preset 字段必须是数组: {source_path}") - - for index, preset in enumerate(presets): - if not isinstance(preset, dict): - raise MaaFWInterfaceLoadError(f"preset[{index}] 必须是对象: {source_path}") - - preset_name = preset.get("name") - if not isinstance(preset_name, str) or not preset_name: - raise MaaFWInterfaceLoadError( - f"preset[{index}].name 必须是非空字符串: {source_path}" - ) - - -def _normalize_object_list( - raw_value: Any, - source_path: Path, - field_name: str, - *, - allow_single: bool = False, -) -> list[dict[str, Any]]: - if raw_value is None: - return [] - if isinstance(raw_value, list): - values = raw_value - elif allow_single and isinstance(raw_value, dict): - values = [raw_value] - else: - expected_type = "对象或对象数组" if allow_single else "对象数组" - raise MaaFWInterfaceLoadError( - f"{field_name} 字段必须是{expected_type}: {source_path}" - ) - if not all(isinstance(item, dict) for item in values): - expected_type = "对象或对象数组" if allow_single else "对象数组" - raise MaaFWInterfaceLoadError( - f"{field_name} 字段必须是{expected_type}: {source_path}" - ) - return values - - -def _validate_string_list( - raw_value: Any, - source_path: Path, - field_name: str, -) -> list[str]: - if raw_value is None: - return [] - if not isinstance(raw_value, list) or not all( - isinstance(item, str) and item for item in raw_value - ): - raise MaaFWInterfaceLoadError( - f"{field_name} 字段必须是非空字符串数组: {source_path}" - ) - return raw_value - - -def _merge_groups( - target: dict[str, Any], - groups: Any, - source_path: Path, - state: _MergeState, -) -> None: - group_items = _normalize_object_list(groups, source_path, "group") - if not group_items: - return - - target.setdefault("group", []) - for index, group in enumerate(group_items): - group_name = group.get("name") - if not isinstance(group_name, str) or not group_name: - raise MaaFWInterfaceLoadError( - f"group[{index}].name 必须是非空字符串: {source_path}" - ) - if group_name in state.group_names: - continue - state.group_names.add(group_name) - target["group"].append(copy.deepcopy(group)) - - -def _merge_global_options( - target: dict[str, Any], - global_options: Any, - source_path: Path, - state: _MergeState, -) -> None: - option_names = _validate_string_list(global_options, source_path, "global_option") - if not option_names: - return - - target.setdefault("global_option", []) - for option_name in option_names: - if option_name in state.global_option_names: - continue - state.global_option_names.add(option_name) - target["global_option"].append(option_name) - - -def _normalize_pretask_items( - raw_value: Any, - source_path: Path, -) -> list[dict[str, Any]]: - if raw_value is None: - return [] - values = raw_value if isinstance(raw_value, list) else [raw_value] - normalized: list[dict[str, Any]] = [] - for index, value in enumerate(values): - if not isinstance(value, dict): - logger.warning( - "MaaFW ProjectInterface pretask[%s] 不是对象,已忽略;文件:%s", - index, - source_path, - ) - continue - try: - pretask = MaaFWPretask.model_validate(value) - except Exception as exc: - logger.warning( - "MaaFW ProjectInterface pretask[%s] 无效,已忽略:%s;文件:%s", - index, - exc, - source_path, - ) - continue - normalized.append(pretask.model_dump(mode="json", exclude_none=True)) - return normalized - - -def _merge_pretasks( - target: dict[str, Any], - pretasks: Any, - source_path: Path, - state: _MergeState, -) -> None: - pretask_items = _normalize_pretask_items(pretasks, source_path) - if not pretask_items: - return - - target.setdefault("pretask", []) - for pretask_data in pretask_items: - pretask = MaaFWPretask.model_validate(pretask_data) - task_name = build_pretask_task_name(pretask) - if task_name in state.pretask_names: - logger.warning( - "MaaFW ProjectInterface pretask 重复,已保留首次定义:%s;文件:%s", - task_name, - source_path, - ) - continue - state.pretask_names.add(task_name) - target["pretask"].append(copy.deepcopy(pretask_data)) - - -def _seed_root_sections( - root_data: dict[str, Any], - source_path: Path, - state: _MergeState, -) -> None: - _validate_tasks(root_data.get("task"), source_path) - _validate_options(root_data.get("option"), source_path) - _validate_preset_section(root_data.get("preset"), source_path) - - root_groups = root_data.pop("group", None) - root_global_options = root_data.pop("global_option", None) - root_pretasks = root_data.pop("pretask", None) - _merge_groups(root_data, root_groups, source_path, state) - _merge_global_options(root_data, root_global_options, source_path, state) - _merge_pretasks(root_data, root_pretasks, source_path, state) - - if root_data.pop("setting", None) is not None: - logger.warning( - "MaaFW ProjectInterface setting 设置页声明暂不支持,已忽略;文件:%s", - source_path, - ) - - -def _merge_fragment_sections( - target: dict[str, Any], - fragment: dict[str, Any], - source_path: Path, - state: _MergeState, -) -> None: - tasks = fragment.get("task") - options = fragment.get("option") - global_options = fragment.get("global_option") - presets = fragment.get("preset") - groups = fragment.get("group") - pretasks = fragment.get("pretask") - - _validate_tasks(tasks, source_path) - _validate_options(options, source_path) - _validate_preset_section(presets, source_path) - - if tasks: - target.setdefault("task", []) - target["task"].extend(copy.deepcopy(tasks)) - if options: - target.setdefault("option", {}) - target["option"].update(copy.deepcopy(options)) - _merge_global_options(target, global_options, source_path, state) - if presets: - target.setdefault("preset", []) - target["preset"].extend(copy.deepcopy(presets)) - _merge_groups(target, groups, source_path, state) - _merge_pretasks(target, pretasks, source_path, state) - - -def _merge_imports_into_target( - target: dict[str, Any], - import_paths: list[str], - base_dir: Path, - state: _MergeState, - stack: list[Path], - context: _LoadContext | None = None, -) -> None: - for import_path in import_paths: - resolved_path = _resolve_import_path(import_path, base_dir) - if resolved_path in stack: - chain = " -> ".join(str(item) for item in [*stack, resolved_path]) - raise MaaFWInterfaceLoadError(f"检测到循环导入: {chain}") - - fragment = _read_json_dict(resolved_path, context) - _validate_importable_fragment(fragment, resolved_path) - - _merge_fragment_sections(target, fragment, resolved_path, state) - - child_imports = _normalize_import_list(fragment.get("import"), resolved_path) - _merge_imports_into_target( - target, - child_imports, - base_dir, - state, - [*stack, resolved_path], - context, - ) - - -def _scan_scan_select_cases( - option_name: str, - option_data: dict[str, Any], - base_dir: Path, - context: _LoadContext | None = None, -) -> list[dict[str, str]]: - raw_cases = option_data.get("cases") - if raw_cases is not None and (not isinstance(raw_cases, list) or raw_cases): - raise MaaFWInterfaceLoadError( - f"scan_select 选项 {option_name} 的 cases 必须省略或为空数组" - ) - - scan_dir = option_data.get("scan_dir") - scan_filter = option_data.get("scan_filter") - if not isinstance(scan_dir, str) or not scan_dir.strip(): - raise MaaFWInterfaceLoadError( - f"scan_select 选项 {option_name} 的 scan_dir 必须是非空字符串" - ) - if not isinstance(scan_filter, str) or not scan_filter.strip(): - raise MaaFWInterfaceLoadError( - f"scan_select 选项 {option_name} 的 scan_filter 必须是非空字符串" - ) - - resolved_scan_dir = _resolve_project_relative_path( - base_dir, - scan_dir, - field_name=f"scan_select 选项 {option_name} 的 scan_dir", - ) - normalized_scan_filter = _normalize_project_relative_path( - scan_filter, - field_name=f"scan_select 选项 {option_name} 的 scan_filter", - ) - if context is not None: - context.scan_select_specs.add((resolved_scan_dir, normalized_scan_filter)) - - if not resolved_scan_dir.exists() or not resolved_scan_dir.is_dir(): - raise MaaFWInterfaceLoadError( - f"scan_select 选项 {option_name} 的 scan_dir 不存在或不是目录: {scan_dir}" - ) - - try: - matched_paths = sorted( - { - file_path.relative_to(resolved_scan_dir).as_posix() - for file_path in resolved_scan_dir.glob(normalized_scan_filter) - if file_path.is_file() - } - ) - except Exception as exc: - raise MaaFWInterfaceLoadError( - f"scan_select 选项 {option_name} 扫描失败: {scan_filter}" - ) from exc - - return [{"name": path, "label": path} for path in matched_paths] - - -def _expand_scan_select_options( - data: dict[str, Any], - base_dir: Path, - context: _LoadContext | None = None, -) -> None: - options = data.get("option") - if not isinstance(options, dict): - return - - for option_name, option_data in options.items(): - if not isinstance(option_data, dict): - continue - if option_data.get("type") != "scan_select": - continue - option_data["cases"] = _scan_scan_select_cases( - option_name, - option_data, - base_dir, - context, - ) - - -def _collect_reachable_option_names( - option_names: list[str], - option_map: dict[str, MaaFWOption], - collected: set[str], -) -> None: - for option_name in option_names: - option = option_map.get(option_name) - if option is None: - raise MaaFWInterfaceLoadError(f"任务引用了不存在的选项: {option_name}") - if option_name in collected: - continue - - collected.add(option_name) - for case_item in option.cases or []: - if case_item.option: - _collect_reachable_option_names(case_item.option, option_map, collected) - - -def _validate_option_name_list( - option_names: list[str] | None, - option_map: dict[str, MaaFWOption], - *, - location: str, -) -> None: - for option_name in option_names or []: - if option_name not in option_map: - raise MaaFWInterfaceLoadError(f"{location} 引用了不存在的选项: {option_name}") - - -def _validate_option_case_values( - option_name: str, - option: MaaFWOption, - value: Any, - *, - location: str, -) -> None: - case_names = {case.name for case in option.cases or []} - - if option.type in {"select", "switch", "scan_select"}: - if not isinstance(value, str): - raise MaaFWInterfaceLoadError(f"{location}.{option_name} 必须是字符串") - if value not in case_names: - raise MaaFWInterfaceLoadError( - f"{location}.{option_name} 引用了不存在的 case: {value}" - ) - return - - if option.type == "checkbox": - if not isinstance(value, list) or not all( - isinstance(item, str) for item in value - ): - raise MaaFWInterfaceLoadError(f"{location}.{option_name} 必须是字符串数组") - invalid_cases = [item for item in value if item not in case_names] - if invalid_cases: - raise MaaFWInterfaceLoadError( - f"{location}.{option_name} 引用了不存在的 case: {', '.join(invalid_cases)}" - ) - return - - if option.type == "input": - if not isinstance(value, dict): - raise MaaFWInterfaceLoadError(f"{location}.{option_name} 必须是对象") - input_names = {input_item.name for input_item in option.inputs or []} - for input_name, input_value in value.items(): - if input_name not in input_names: - raise MaaFWInterfaceLoadError( - f"{location}.{option_name} 引用了不存在的输入项: {input_name}" - ) - if not isinstance(input_value, str): - raise MaaFWInterfaceLoadError( - f"{location}.{option_name}.{input_name} 必须是字符串" - ) - - -def _validate_task_context_constraints(interface_model: MaaFWInterface) -> None: - resource_names = {resource.name for resource in interface_model.resource} - controller_names = {controller.name for controller in interface_model.controller} - - for resource in interface_model.resource: - for controller_name in resource.controller or []: - if controller_name not in controller_names: - raise MaaFWInterfaceLoadError( - f"resource {resource.name} 引用了不存在的 controller: {controller_name}" - ) - - for task in interface_model.task: - task_ref = f"{task.name}({task.entry})" - for controller_name in task.controller or []: - if controller_name not in controller_names: - raise MaaFWInterfaceLoadError( - f"任务 {task_ref} 引用了不存在的 controller: {controller_name}" - ) - for resource_name in task.resource or []: - if resource_name not in resource_names: - raise MaaFWInterfaceLoadError( - f"任务 {task_ref} 引用了不存在的 resource: {resource_name}" - ) - -def _validate_option_references(interface_model: MaaFWInterface) -> None: - option_map = interface_model.option - _validate_option_name_list( - interface_model.global_option, - option_map, - location="global_option", - ) - - for resource in interface_model.resource: - _validate_option_name_list( - resource.option, - option_map, - location=f"resource {resource.name}", - ) - - for controller in interface_model.controller: - _validate_option_name_list( - controller.option, - option_map, - location=f"controller {controller.name}", - ) - - for task in interface_model.task: - _validate_option_name_list( - task.option, - option_map, - location=f"task {task.name}", - ) - - - -def _sanitize_pretasks(interface_model: MaaFWInterface) -> None: - controller_names = {controller.name for controller in interface_model.controller} - resource_names = {resource.name for resource in interface_model.resource} - option_names = set(interface_model.option) - valid_pretasks: list[MaaFWPretask] = [] - - for pretask in iter_pretasks(interface_model): - task_name = build_pretask_task_name(pretask) - invalid_controllers = sorted(set(pretask.controller or []) - controller_names) - invalid_resources = sorted(set(pretask.resource or []) - resource_names) - if invalid_controllers or invalid_resources: - details: list[str] = [] - if invalid_controllers: - details.append(f"controller={','.join(invalid_controllers)}") - if invalid_resources: - details.append(f"resource={','.join(invalid_resources)}") - logger.warning( - "MaaFW ProjectInterface pretask 引用了不存在的上下文,已忽略:%s(%s)", - task_name, - "; ".join(details), - ) - continue - - valid_options: list[str] = [] - for option_name in pretask.option or []: - if option_name not in option_names: - logger.warning( - "MaaFW ProjectInterface pretask 引用了不存在的 option,已忽略:%s.%s", - task_name, - option_name, - ) - continue - valid_options.append(option_name) - pretask.option = valid_options - valid_pretasks.append(pretask) - - interface_model.pretask = valid_pretasks or None - - -def _warn_unsupported_option_types(interface_model: MaaFWInterface) -> None: - for option_name, option in interface_model.option.items(): - if option.type not in SUPPORTED_OPTION_TYPES: - logger.warning( - "MaaFW ProjectInterface option 类型暂不支持,已忽略:%s(type=%s)", - option_name, - option.type, - ) - - -def _validate_presets(interface_model: MaaFWInterface) -> None: - task_name_map = {task.name: task for task in interface_model.task} - option_map = interface_model.option - common_option_names = _build_common_option_names(interface_model) - reachable_options_by_task: dict[str, set[str]] = {} - - for task in interface_model.task: - collected: set[str] = set() - _collect_reachable_option_names(common_option_names, option_map, collected) - _collect_reachable_option_names(task.option or [], option_map, collected) - reachable_options_by_task[task.name] = collected - - for preset in interface_model.preset: - seen_task_names: set[str] = set() - for preset_task in preset.task or []: - if preset_task.name in seen_task_names: - raise MaaFWInterfaceLoadError( - f"preset {preset.name} 中存在重复任务: {preset_task.name}" - ) - seen_task_names.add(preset_task.name) - - task = task_name_map.get(preset_task.name) - if task is None: - continue - - reachable_options = reachable_options_by_task.get(task.name, set()) - for option_name, option_value in (preset_task.option or {}).items(): - if option_name not in reachable_options: - continue - option = option_map.get(option_name) - if option is None: - continue - _validate_option_case_values( - option_name, - option, - option_value, - location=f"preset {preset.name}.task {task.name}", - ) - - -def _build_common_option_names(interface_model: MaaFWInterface) -> list[str]: - option_names: list[str] = [] - option_names.extend(interface_model.global_option or []) - for resource in interface_model.resource: - option_names.extend(resource.option or []) - for controller in interface_model.controller: - option_names.extend(controller.option or []) - return option_names - - -def rescan_scan_select_option( - interface_model: MaaFWInterface, - option_name: str, - base_dir: str | Path, -) -> list[dict[str, str]]: - option = interface_model.option.get(option_name) - if option is None: - raise MaaFWInterfaceLoadError(f"scan_select 选项不存在: {option_name}") - if option.type != "scan_select": - raise MaaFWInterfaceLoadError(f"选项不是 scan_select 类型: {option_name}") - - resolved_base_dir = Path(base_dir).resolve() - option_data = option.model_dump(mode="json", exclude_none=True) - option_data["cases"] = [] - scanned_cases = _scan_scan_select_cases(option_name, option_data, resolved_base_dir) - option.cases = [MaaFWOptionCase.model_validate(item) for item in scanned_cases] - return scanned_cases - - -def _load_interface_model_with_context(base_dir: str | Path) -> tuple[MaaFWInterface, _LoadContext]: - resolved_base_dir = Path(base_dir).resolve() - if not resolved_base_dir.exists() or not resolved_base_dir.is_dir(): - raise MaaFWInterfaceLoadError("请设置 MaaFW 项目目录") - - context = _LoadContext() - root_path = _resolve_interface_path(resolved_base_dir) - root_data = _read_json_dict(root_path, context) - _warn_unsupported_root_fields(root_data, root_path) - merged_data = copy.deepcopy(root_data) - merge_state = _MergeState() - - _seed_root_sections(merged_data, root_path, merge_state) - root_imports = _normalize_import_list(merged_data.get("import"), root_path) - _merge_imports_into_target( - merged_data, - root_imports, - resolved_base_dir, - merge_state, - [root_path], - context, - ) - _expand_scan_select_options(merged_data, resolved_base_dir, context) - - try: - interface_model = MaaFWInterface.model_validate(merged_data) - except Exception as exc: - raise MaaFWInterfaceLoadError(f"校验 interface 配置失败: {exc}") from exc - - _sanitize_pretasks(interface_model) - _warn_unsupported_option_types(interface_model) - _validate_task_context_constraints(interface_model) - _validate_option_references(interface_model) - _validate_presets(interface_model) - return interface_model, context - - -def load_interface_model(base_dir: str | Path) -> MaaFWInterface: - interface_model, _ = _load_interface_model_with_context(base_dir) - return interface_model - - -def load_interface_model_cached( - base_dir: str | Path, - *, - force_reload: bool = False, -) -> MaaFWInterface: - """Load MaaFW ProjectInterface with memory and disk cache.""" - resolved_base_dir = Path(base_dir).resolve() - with _cache_lock: - cache_path = _disk_cache_path(resolved_base_dir) - _cleanup_expired_disk_cache(cache_path) - - cached = _interface_cache.get(resolved_base_dir) - if cached and not force_reload: - signature, interface_model, dependency_paths, scan_select_specs = cached - current_signature = _build_signature( - resolved_base_dir, - dependency_paths, - scan_select_specs, - ) - if current_signature == signature: - _touch_disk_cache(cache_path) - logger.debug(f"复用 MaaFW interface 缓存:{resolved_base_dir}") - return interface_model.model_copy(deep=False) - logger.info(f"MaaFW interface 缓存已失效,重新加载:{resolved_base_dir}") - - if not force_reload: - disk_cached = _load_from_disk_cache(resolved_base_dir) - if disk_cached is not None: - signature, interface_model, dependency_paths, scan_select_specs = disk_cached - _interface_cache[resolved_base_dir] = ( - signature, - interface_model, - dependency_paths, - scan_select_specs, - ) - return interface_model.model_copy(deep=False) - - interface_model, context = _load_interface_model_with_context(resolved_base_dir) - signature = _build_signature( - resolved_base_dir, - context.dependency_paths, - context.scan_select_specs, - ) - _interface_cache[resolved_base_dir] = ( - signature, - interface_model, - context.dependency_paths, - context.scan_select_specs, - ) - _save_disk_cache( - resolved_base_dir, - signature, - interface_model, - context.dependency_paths, - context.scan_select_specs, - ) - return interface_model.model_copy(deep=False) - - -def _disk_cache_dir() -> Path: - return Path.cwd() / "data/cache/maafw_interface_loader" - - -def _disk_cache_path(root_path: Path) -> Path: - cache_key = hashlib.sha256(str(root_path).casefold().encode("utf-8")).hexdigest() - return _disk_cache_dir() / f"{cache_key}.json" - - -def _cleanup_expired_disk_cache(protected_cache_path: Path) -> None: - global _last_disk_cache_cleanup_at - now = time.time() - if now - _last_disk_cache_cleanup_at < DISK_CACHE_CLEANUP_INTERVAL_SECONDS: - return - - _last_disk_cache_cleanup_at = now - cache_dir = _disk_cache_dir() - if not cache_dir.is_dir(): - return - - cutoff = now - DISK_CACHE_MAX_AGE_SECONDS - protected_cache_path = protected_cache_path.resolve() - for cache_file in cache_dir.glob("*.json"): - try: - if cache_file.resolve() == protected_cache_path: - continue - if cache_file.stat().st_mtime < cutoff: - cache_file.unlink() - logger.info(f"已清理过期 MaaFW interface 缓存:{cache_file}") - except Exception as exc: - logger.warning(f"清理 MaaFW interface 缓存失败:{cache_file},{exc}") - - -def _touch_disk_cache(cache_path: Path) -> None: - try: - if cache_path.is_file(): - cache_path.touch() - except Exception as exc: - logger.debug(f"更新 MaaFW interface 缓存使用时间失败:{exc}") - - -def _file_signature(path: Path) -> tuple: - try: - stat = path.stat() - except OSError: - return ("missing", str(path), 0, 0) - return ("file", str(path), stat.st_mtime_ns, stat.st_size) - - -def _interface_candidates(root_path: Path) -> list[Path]: - return [(root_path / file_name).resolve() for file_name in ("interface.json", "interface.jsonc")] - - -def _build_signature( - root_path: Path, - dependency_paths: set[Path], - scan_select_specs: set[tuple[Path, str]], -) -> tuple: - signature_parts = [] - candidate_paths = {path.resolve() for path in _interface_candidates(root_path)} - - for path in _interface_candidates(root_path): - signature_parts.append(_file_signature(path)) - - for path in sorted(dependency_paths, key=lambda item: str(item)): - if path.resolve() not in candidate_paths: - signature_parts.append(_file_signature(path)) - - for scan_path, scan_filter in sorted(scan_select_specs, key=lambda item: (str(item[0]), item[1])): - signature_parts.append(("scan", str(scan_path), scan_filter)) - signature_parts.append(_file_signature(scan_path)) - try: - scan_files = sorted(scan_path.glob(scan_filter)) - except Exception as exc: - signature_parts.append(("scan-error", str(scan_path), scan_filter, type(exc).__name__, str(exc))) - continue - - for file in scan_files: - if file.is_file(): - signature_parts.append(_file_signature(file.resolve())) - - return tuple(signature_parts) - - -def _signature_to_json(signature: tuple) -> list[list]: - return [list(part) for part in signature] - - -def _is_valid_disk_cache_payload(payload: dict[str, Any]) -> bool: - return ( - isinstance(payload.get("interface"), dict) - and isinstance(payload.get("dependency_paths"), list) - and isinstance(payload.get("scan_select_specs"), list) - and isinstance(payload.get("signature"), list) - ) - - -def _load_from_disk_cache( - root_path: Path, -) -> tuple[tuple, MaaFWInterface, set[Path], set[tuple[Path, str]]] | None: - cache_path = _disk_cache_path(root_path) - if not cache_path.is_file(): - return None - - try: - payload = json.loads(cache_path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - return None - if payload.get("version") != DISK_CACHE_VERSION: - return None - cached_root = payload.get("root_path") - if not isinstance(cached_root, str) or Path(cached_root).resolve() != root_path.resolve(): - logger.info(f"MaaFW interface 缓存路径不匹配,重新加载:{root_path}") - return None - if not _is_valid_disk_cache_payload(payload): - logger.info(f"MaaFW interface 缓存结构已过期:{root_path}") - return None - - dependency_paths = { - Path(path) - for path in payload.get("dependency_paths", []) - if isinstance(path, str) - } - scan_select_specs = { - (Path(item[0]), item[1]) - for item in payload.get("scan_select_specs", []) - if isinstance(item, list) - and len(item) == 2 - and isinstance(item[0], str) - and isinstance(item[1], str) - } - current_signature = _build_signature( - root_path, - dependency_paths, - scan_select_specs, - ) - if payload.get("signature") != _signature_to_json(current_signature): - logger.info(f"MaaFW interface 缓存已失效:{root_path}") - return None - - interface_model = MaaFWInterface.model_validate(payload["interface"]) - _touch_disk_cache(cache_path) - logger.info(f"读取 MaaFW interface 缓存:{root_path}") - return current_signature, interface_model, dependency_paths, scan_select_specs - except Exception as exc: - logger.warning(f"读取 MaaFW interface 缓存失败,回退实时解析:{exc}") - return None - - -def _save_disk_cache( - root_path: Path, - signature: tuple, - interface_model: MaaFWInterface, - dependency_paths: set[Path], - scan_select_specs: set[tuple[Path, str]], -) -> None: - cache_path = _disk_cache_path(root_path) - payload = { - "version": DISK_CACHE_VERSION, - "root_path": str(root_path), - "dependency_paths": [ - str(path) for path in sorted(dependency_paths, key=lambda item: str(item)) - ], - "scan_select_specs": [ - [str(path), scan_filter] - for path, scan_filter in sorted(scan_select_specs, key=lambda item: (str(item[0]), item[1])) - ], - "signature": _signature_to_json(signature), - "interface": interface_model.model_dump(mode="json", by_alias=True), - } - - try: - cache_path.parent.mkdir(parents=True, exist_ok=True) - temp_path = cache_path.with_suffix(".tmp") - temp_path.write_text( - json.dumps(payload, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - temp_path.replace(cache_path) - logger.debug(f"已写入 MaaFW interface 缓存:{cache_path}") - except Exception as exc: - logger.warning(f"写入 MaaFW interface 缓存失败:{exc}") diff --git a/plugins/automas_maafw_interface/src/automas_maafw_interface/models.py b/plugins/automas_maafw_interface/src/automas_maafw_interface/models.py deleted file mode 100644 index 19593008..00000000 --- a/plugins/automas_maafw_interface/src/automas_maafw_interface/models.py +++ /dev/null @@ -1,308 +0,0 @@ -# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software -# Copyright © 2025-2026 AUTO-MAS Team - -# This file is part of AUTO-MAS. - -# AUTO-MAS is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of -# the License, or (at your option) any later version. - -# AUTO-MAS is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty -# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See -# the GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with AUTO-MAS. If not, see . - - -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field, model_validator - - -MaaFWDocumentContent = str | list[str] -MaaFWPipelineOverride = dict[str, Any] -MaaFWPresetOptionValue = str | list[str] | dict[str, str] -MaaFWTaskOptionValue = str | list[str] | dict[str, str] -MaaFWTaskOptionsByTask = dict[str, dict[str, MaaFWTaskOptionValue]] - -PRETASK_TASK_PREFIX = "__MXU_PRETASK__" -PRETASK_TASK_ENTRY = "MXU_PRETASK" -SUPPORTED_OPTION_TYPES = frozenset( - {"select", "checkbox", "input", "switch", "scan_select"} -) - - -class MaaFWAdbController(BaseModel): - model_config = ConfigDict(extra="allow") - - -class MaaFWWin32Controller(BaseModel): - model_config = ConfigDict(extra="allow") - - class_regex: str | None = None - window_regex: str | None = None - mouse: str | None = None - keyboard: str | None = None - screencap: str | None = None - - -class MaaFWMacOSController(BaseModel): - model_config = ConfigDict(extra="allow") - - title_regex: str | None = None - input: str | None = None - screencap: str | None = None - - -class MaaFWPlayCoverController(BaseModel): - model_config = ConfigDict(extra="allow") - - uuid: str | None = None - - -class MaaFWGamepadController(BaseModel): - model_config = ConfigDict(extra="allow") - - class_regex: str | None = None - window_regex: str | None = None - gamepad_type: str | None = None - screencap: str | None = None - - -class MaaFWWlRootsController(BaseModel): - model_config = ConfigDict(extra="allow") - - -class MaaFWController(BaseModel): - model_config = ConfigDict(extra="allow") - - name: str - label: str | None = None - description: str | None = None - icon: str | None = None - type: str - display_short_side: int | None = 720 - display_long_side: int | None = None - display_raw: bool | None = False - permission_required: bool | None = False - attach_resource_path: list[str] | None = None - option: list[str] | None = None - adb: MaaFWAdbController | None = None - win32: MaaFWWin32Controller | None = None - macos: MaaFWMacOSController | None = None - playcover: MaaFWPlayCoverController | None = None - gamepad: MaaFWGamepadController | None = None - wlroots: MaaFWWlRootsController | None = None - - -class MaaFWResource(BaseModel): - model_config = ConfigDict(extra="allow") - - name: str - label: str | None = None - description: str | None = None - icon: str | None = None - path: list[str] = Field(default_factory=list) - controller: list[str] | None = None - option: list[str] | None = None - hash: str | None = None - - -class MaaFWAgent(BaseModel): - model_config = ConfigDict(extra="allow") - - child_exec: str - child_args: list[str] | None = None - identifier: str | None = None - embedded: bool | None = None - - -class MaaFWPretask(BaseModel): - model_config = ConfigDict(extra="allow") - - exec: str - args: list[str] | None = None - name: str | None = None - label: str | None = None - description: str | None = None - icon: str | None = None - option: list[str] | None = None - resource: list[str] | None = None - controller: list[str] | None = None - - -class MaaFWTask(BaseModel): - model_config = ConfigDict(extra="allow") - - name: str - label: str | None = None - entry: str - default_check: bool | None = False - description: str | None = None - doc: MaaFWDocumentContent | None = None - desc: MaaFWDocumentContent | None = None - icon: str | None = None - group: list[str] | None = None - resource: list[str] | None = None - controller: list[str] | None = None - pipeline_override: MaaFWPipelineOverride | None = None - option: list[str] | None = None - - -class MaaFWGroup(BaseModel): - model_config = ConfigDict(extra="allow") - - name: str - label: str | None = None - description: str | None = None - icon: str | None = None - default_expand: bool | None = True - - -class MaaFWOptionCase(BaseModel): - model_config = ConfigDict(extra="allow") - - name: str - label: str | None = None - description: str | None = None - icon: str | None = None - option: list[str] | None = None - pipeline_override: MaaFWPipelineOverride | None = None - - -class MaaFWInputCase(BaseModel): - model_config = ConfigDict(extra="allow") - - name: str - label: str | None = None - description: str | None = None - icon: str | None = None - default: str | None = None - pipeline_type: str | None = None - verify: str | None = None - verify_error: str | None = None - pattern_msg: str | None = None - - @model_validator(mode="after") - def fill_verify_error_alias(self): - if self.pattern_msg is None and self.verify_error: - self.pattern_msg = self.verify_error - if self.verify_error is None and self.pattern_msg: - self.verify_error = self.pattern_msg - return self - - -class MaaFWOption(BaseModel): - model_config = ConfigDict(extra="allow") - - type: str = "select" - label: str | None = None - description: str | None = None - icon: str | None = None - controller: list[str] | None = None - resource: list[str] | None = None - cases: list[MaaFWOptionCase] | None = None - inputs: list[MaaFWInputCase] | None = None - scan_dir: str | None = None - scan_filter: str | None = None - pipeline_override: MaaFWPipelineOverride | None = None - default_case: str | list[str] | None = None - - -class MaaFWPresetTask(BaseModel): - model_config = ConfigDict(extra="allow") - - name: str - enabled: bool | None = True - option: dict[str, MaaFWPresetOptionValue] | None = None - - -class MaaFWPreset(BaseModel): - model_config = ConfigDict(extra="allow") - - name: str - label: str | None = None - description: str | None = None - icon: str | None = None - task: list[MaaFWPresetTask] | None = None - - -class MaaFWInterface(BaseModel): - model_config = ConfigDict( - extra="allow", - populate_by_name=True, - serialize_by_alias=True, - ) - - interface_version: Literal[2] - languages: dict[str, str] | None = None - name: str - label: str | None = None - title: str | None = None - icon: str | None = None - mirrorchyan_rid: str | None = None - mirrorchyan_multiplatform: bool | None = None - github: str | None = None - version: str | None = None - contact: str | None = None - license: str | None = None - welcome: str | None = None - description: str | None = None - controller: list[MaaFWController] = Field(default_factory=list) - resource: list[MaaFWResource] = Field(default_factory=list) - group: list[MaaFWGroup] | None = None - pretask: MaaFWPretask | list[MaaFWPretask] | None = None - agent: MaaFWAgent | list[MaaFWAgent] | None = None - task: list[MaaFWTask] = Field(default_factory=list) - option: dict[str, MaaFWOption] = Field(default_factory=dict) - global_option: list[str] | None = None - import_: list[str] | None = Field(default=None, alias="import") - preset: list[MaaFWPreset] = Field(default_factory=list) - - @model_validator(mode="after") - def fill_display_defaults(self): - if self.label is None: - self.label = self.name - if self.title is None and self.label and self.version: - self.title = f"{self.label} {self.version}" - return self - - -def iter_pretasks(interface: MaaFWInterface) -> list[MaaFWPretask]: - """Return ProjectInterface pretasks as a stable list.""" - - raw_pretasks = interface.pretask - if isinstance(raw_pretasks, list): - return raw_pretasks - return [raw_pretasks] if raw_pretasks is not None else [] - - -def build_pretask_task_name(pretask: MaaFWPretask) -> str: - """Build the pseudo-task name used by MXU-compatible clients.""" - - return f"{PRETASK_TASK_PREFIX}{pretask.name or pretask.exec}" - - -def is_pretask_task_name(task_name: str) -> bool: - """Return whether a persisted task name identifies a pretask pseudo-task.""" - - return task_name.startswith(PRETASK_TASK_PREFIX) - - -def find_pretask_by_task_name( - interface: MaaFWInterface, - task_name: str, -) -> MaaFWPretask | None: - """Resolve a persisted pseudo-task name to its ProjectInterface pretask.""" - - return next( - ( - pretask - for pretask in iter_pretasks(interface) - if build_pretask_task_name(pretask) == task_name - ), - None, - ) diff --git a/plugins/automas_maafw_interface/src/automas_maafw_interface/plugin.py b/plugins/automas_maafw_interface/src/automas_maafw_interface/plugin.py deleted file mode 100644 index f0b94d04..00000000 --- a/plugins/automas_maafw_interface/src/automas_maafw_interface/plugin.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from .service import MaaFWInterfaceService - -if TYPE_CHECKING: - from auto_mas_core import PluginContext - - -DEFAULT_INSTANCE = { - "name": "MaaFW Interface", - "enabled": True, - "config": {}, -} - -schema = { - "__no_plugin_config__": { - "type": "boolean", - "default": True, - "hidden": True, - "configurable": False, - "title": "No plugin-level configuration", - }, -} - - -class Plugin: - provides = ["maafw.interface.v1"] - - def __init__(self, ctx: "PluginContext") -> None: - self.ctx = ctx - self.service = MaaFWInterfaceService() - - async def on_start(self) -> None: - self.ctx.set("maafw.interface.v1", self.service) - self.ctx.logger.info("maafw.interface.v1 ready") - - async def on_stop(self, reason: str) -> None: - self.ctx.logger.info(f"maafw.interface.v1 stopped, reason={reason}") diff --git a/plugins/automas_maafw_interface/src/automas_maafw_interface/preview.py b/plugins/automas_maafw_interface/src/automas_maafw_interface/preview.py deleted file mode 100644 index 045770e7..00000000 --- a/plugins/automas_maafw_interface/src/automas_maafw_interface/preview.py +++ /dev/null @@ -1,356 +0,0 @@ -from __future__ import annotations - -import importlib.util -import os -from pathlib import Path -from typing import Any - -import json5 -from pydantic import BaseModel, Field - -from .models import ( - PRETASK_TASK_ENTRY, - SUPPORTED_OPTION_TYPES, - MaaFWInterface, - build_pretask_task_name, - iter_pretasks, -) -from .task_config import _build_task_option_maps, build_interface_preset_snapshot - - -class MaaFWInterfaceValidationReport(BaseModel): - ok: bool - message: str = "" - - -# EmulatorExtras (ADB 截图/输入加速) 是 MaaFW 的 Windows-only 特性。每个模拟器 -# 族支持的加速子集是固定的,此关系表对齐 MaaFW 控制器定义。能力是否真正可用取 -# 决于运行时安装的 maa 是否带 MaaAdbControlUnit.dll,由下方探测函数按真实环境判断。 -_EMULATOR_EXTRA_RELATION: dict[str, dict[str, bool]] = { - "mumu": {"screencap": True, "input": True}, - "ldplayer": {"screencap": True, "input": False}, -} - - -def _maafw_emulator_extras_runtime_available() -> bool: - """Return whether the installed MaaFW runtime exposes ADB EmulatorExtras. - - 定位运行环境真实安装的 ``maa`` 包并检查 ``MaaAdbControlUnit.dll`` 是否存在。 - 用 ``importlib.util.find_spec`` 定位包目录——它只查找 spec、不执行 ``maa/__init__`` - 也不加载原生绑定,因此不会把 maa 载入主进程,满足导入边界约束。非 Windows 不可用。 - """ - - if os.name != "nt": - return False - spec = importlib.util.find_spec("maa") - if spec is None or not spec.submodule_search_locations: - return False - maa_package_dir = Path(next(iter(spec.submodule_search_locations))) - return (maa_package_dir / "bin" / "MaaAdbControlUnit.dll").is_file() - - -def build_adb_emulator_extra_capabilities() -> dict[str, dict[str, bool]]: - """Return per-emulator EmulatorExtras capabilities for the installed MaaFW.""" - - if not _maafw_emulator_extras_runtime_available(): - return {} - return { - emulator_type: dict(relation) - for emulator_type, relation in _EMULATOR_EXTRA_RELATION.items() - } - - -class MaaFWInterfacePreviewData(BaseModel): - path: str - project: dict[str, Any] - globalOption: list[str] = Field(default_factory=list) - controllers: list[dict[str, Any]] = Field(default_factory=list) - resources: list[dict[str, Any]] = Field(default_factory=list) - groups: list[dict[str, Any]] = Field(default_factory=list) - tasks: list[dict[str, Any]] = Field(default_factory=list) - options: list[dict[str, Any]] = Field(default_factory=list) - presets: list[dict[str, Any]] = Field(default_factory=list) - importCount: int = 0 - agentCount: int = 0 - controlCapabilities: dict[str, Any] = Field(default_factory=dict) - - -def build_interface_preview_data( - root_path: str | Path, - interface: MaaFWInterface, -) -> MaaFWInterfacePreviewData: - root = Path(root_path).resolve() - i18n_mapping = _load_i18n_mapping(root, interface) - task_order = [task.name for task in interface.task] - task_option_maps = _build_task_option_maps(interface) - supported_option_names = { - option_name - for option_name, option in interface.option.items() - if option.type in SUPPORTED_OPTION_TYPES - } - - def filter_option_names(option_names: list[str] | None) -> list[str]: - return [ - option_name - for option_name in option_names or [] - if option_name in supported_option_names - ] - - def tr_text(value: str | None) -> str | None: - translated = _resolve_i18n_value(value, i18n_mapping) - return translated if isinstance(translated, str) else value - - def tr_description(value: str | None) -> str | None: - return resolve_description(root, tr_text(value)) - - agent_count = 0 - if isinstance(interface.agent, list): - agent_count = len(interface.agent) - elif interface.agent is not None: - agent_count = 1 - - presets: list[dict[str, Any]] = [] - for preset in interface.preset: - snapshot = build_interface_preset_snapshot( - interface, - preset, - task_order=task_order, - task_option_maps=task_option_maps, - include_default_options=False, - ) - checked_count = sum(1 for checked in snapshot.taskChecked.values() if checked) - presets.append( - { - "name": preset.name, - "label": tr_text(preset.label), - "description": tr_description(preset.description), - "taskCount": len(preset.task or []), - "checkedCount": checked_count, - "snapshot": snapshot.model_dump(mode="json"), - } - ) - - return MaaFWInterfacePreviewData( - path=str(root), - project={ - "name": interface.name, - "label": tr_text(interface.label), - "title": tr_text(interface.title), - "version": interface.version, - "github": interface.github, - "mirrorchyanRid": interface.mirrorchyan_rid, - "mirrorchyanMultiplatform": interface.mirrorchyan_multiplatform, - "description": tr_description(interface.description), - "icon": interface.icon, - }, - globalOption=filter_option_names(interface.global_option), - controllers=[ - { - "name": controller.name, - "label": tr_text(controller.label), - "type": controller.type, - "description": tr_description(controller.description), - "icon": controller.icon, - "option": filter_option_names(controller.option), - "permissionRequired": bool(controller.permission_required), - } - for controller in interface.controller - ], - resources=[ - { - "name": resource.name, - "label": tr_text(resource.label), - "description": tr_description(resource.description), - "icon": resource.icon, - "path": resource.path, - "controller": resource.controller or [], - "option": filter_option_names(resource.option), - } - for resource in interface.resource - ], - groups=[ - { - "name": group.name, - "label": tr_text(group.label), - "description": tr_description(group.description), - "icon": group.icon, - "defaultExpand": bool(group.default_expand), - } - for group in interface.group or [] - ], - tasks=[ - *[ - { - "name": build_pretask_task_name(pretask), - "label": tr_text(pretask.label) or pretask.name or pretask.exec, - "entry": PRETASK_TASK_ENTRY, - "description": tr_description(pretask.description), - "icon": pretask.icon, - "group": [], - "controller": pretask.controller or [], - "resource": pretask.resource or [], - "option": filter_option_names(pretask.option), - "defaultCheck": False, - } - for pretask in iter_pretasks(interface) - ], - *[ - { - "name": task.name, - "label": tr_text(task.label), - "entry": task.entry, - "description": tr_description(task.description), - "icon": task.icon, - "group": task.group or [], - "controller": task.controller or [], - "resource": task.resource or [], - "option": filter_option_names(task.option), - "defaultCheck": bool(task.default_check), - } - for task in interface.task - ], - ], - options=[ - { - "name": option_name, - "type": option.type, - "label": tr_text(option.label), - "description": tr_description(option.description), - "icon": option.icon, - "controller": option.controller or [], - "resource": option.resource or [], - "cases": [ - { - "name": case.name, - "label": tr_text(case.label), - "description": tr_description(case.description), - "icon": case.icon, - "option": filter_option_names(case.option), - } - for case in option.cases or [] - ], - "inputs": [ - { - "name": input_item.name, - "label": tr_text(input_item.label), - "description": tr_description(input_item.description), - "icon": input_item.icon, - "default": input_item.default, - "pipelineType": input_item.pipeline_type, - "verify": input_item.verify, - "verifyError": input_item.verify_error, - "patternMsg": input_item.pattern_msg, - } - for input_item in option.inputs or [] - ], - "defaultCase": option.default_case, - } - for option_name, option in interface.option.items() - if option.type in SUPPORTED_OPTION_TYPES - ], - presets=presets, - importCount=len(interface.import_ or []), - agentCount=agent_count, - controlCapabilities={"emulatorExtras": build_adb_emulator_extra_capabilities()}, - ) - - -def resolve_description(root_path: Path, description: str | None) -> str | None: - if not isinstance(description, str): - return description - - raw_description = description.strip() - if not raw_description or "\n" in raw_description or raw_description.startswith("<"): - return description - if raw_description.startswith(("http://", "https://")): - return description - - description_path = Path(raw_description) - if description_path.is_absolute() or ".." in description_path.parts: - return description - - try: - root = root_path.resolve() - resolved_path = (root / description_path).resolve() - resolved_path.relative_to(root) - except Exception: - return description - - if not resolved_path.is_file(): - return description - if resolved_path.suffix.lower() not in {".md", ".markdown", ".txt"}: - return description - - try: - if resolved_path.stat().st_size > 512 * 1024: - return description - return resolved_path.read_text(encoding="utf-8") - except UnicodeDecodeError: - try: - return resolved_path.read_text(encoding="utf-8-sig") - except Exception: - return description - except Exception: - return description - - -def _load_i18n_mapping(root_path: Path, interface: MaaFWInterface) -> dict[str, Any]: - if not interface.languages: - return {} - - language_file = interface.languages.get("zh_cn") - if not isinstance(language_file, str) or not language_file.strip(): - return {} - - language_path = _resolve_project_path(root_path, language_file) - if language_path is None or not language_path.is_file(): - return {} - - try: - with language_path.open("r", encoding="utf-8") as file: - data = json5.load(file) - return data if isinstance(data, dict) else {} - except Exception: - return {} - - -def _resolve_i18n_value(value: Any, mapping: dict[str, Any]) -> Any: - if isinstance(value, dict): - return {key: _resolve_i18n_value(item, mapping) for key, item in value.items()} - if isinstance(value, list): - return [_resolve_i18n_value(item, mapping) for item in value] - if isinstance(value, str) and value.startswith("$"): - translated = _lookup_i18n_text(value, mapping) - if translated is not None: - return translated - return value - - -def _lookup_i18n_text(key: str, mapping: dict[str, Any]) -> str | None: - normalized_key = key[1:] if key.startswith("$") else key - if not normalized_key: - return None - - current: Any = mapping - for part in normalized_key.split("."): - if not isinstance(current, dict) or part not in current: - current = None - break - current = current[part] - if isinstance(current, str): - return current - - flat_value = mapping.get(normalized_key) - return flat_value if isinstance(flat_value, str) else None - - -def _resolve_project_path(root_path: Path, raw_path: str) -> Path | None: - candidate = Path(raw_path.replace("{PROJECT_DIR}", str(root_path))) - if not candidate.is_absolute(): - candidate = root_path / candidate - resolved_path = candidate.resolve() - try: - resolved_path.relative_to(root_path.resolve()) - except ValueError: - return None - return resolved_path diff --git a/plugins/automas_maafw_interface/src/automas_maafw_interface/py.typed b/plugins/automas_maafw_interface/src/automas_maafw_interface/py.typed deleted file mode 100644 index 8b137891..00000000 --- a/plugins/automas_maafw_interface/src/automas_maafw_interface/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/automas_maafw_interface/src/automas_maafw_interface/service.py b/plugins/automas_maafw_interface/src/automas_maafw_interface/service.py deleted file mode 100644 index 24728bae..00000000 --- a/plugins/automas_maafw_interface/src/automas_maafw_interface/service.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from .loader import ( - MaaFWInterfaceLoadError, - load_interface_model_cached, - rescan_scan_select_option, -) -from .models import MaaFWInterface -from .preview import ( - MaaFWInterfacePreviewData, - MaaFWInterfaceValidationReport, - build_interface_preview_data, -) -from .task_config import ( - MaaFWTaskPresetSnapshot, - build_interface_preset_snapshot, - normalize_snapshot, - normalize_task_execution_payload, -) - - -class MaaFWInterfaceService: - """maafw.interface.v1 service.""" - - def load(self, path: str | Path, *, force_reload: bool = False) -> MaaFWInterface: - return load_interface_model_cached(path, force_reload=force_reload) - - def preview( - self, - path: str | Path, - *, - force_reload: bool = False, - ) -> MaaFWInterfacePreviewData: - root_path = Path(path).resolve() - interface = self.load(root_path, force_reload=force_reload) - return build_interface_preview_data(root_path, interface) - - def validate(self, interface: MaaFWInterface | dict[str, Any]) -> MaaFWInterfaceValidationReport: - try: - self._coerce_interface(interface) - except Exception as exc: - return MaaFWInterfaceValidationReport(ok=False, message=str(exc)) - return MaaFWInterfaceValidationReport(ok=True) - - def build_default_snapshot( - self, - interface: MaaFWInterface | dict[str, Any], - *, - preset: str | None = None, - ) -> MaaFWTaskPresetSnapshot: - interface_model = self._coerce_interface(interface) - preset_model = None - if preset is not None: - preset_model = next((item for item in interface_model.preset if item.name == preset), None) - if preset_model is None: - raise MaaFWInterfaceLoadError(f"preset not found: {preset}") - elif interface_model.preset: - preset_model = interface_model.preset[0] - - if preset_model is None: - return normalize_snapshot( - { - "taskOrder": [task.name for task in interface_model.task], - "taskChecked": { - task.name: bool(task.default_check) - for task in interface_model.task - }, - "taskOptions": {}, - }, - interface_model, - ) - - return normalize_snapshot( - build_interface_preset_snapshot(interface_model, preset_model), - interface_model, - ) - - def normalize_snapshot( - self, - interface: MaaFWInterface | dict[str, Any], - snapshot: MaaFWTaskPresetSnapshot | dict[str, Any] | None, - ) -> MaaFWTaskPresetSnapshot: - return normalize_snapshot(snapshot, self._coerce_interface(interface)) - - def normalize_execution_payload( - self, - interface: MaaFWInterface | dict[str, Any], - tasks: Any, - options: Any, - *, - controller: str | None = None, - resource: str | None = None, - ) -> tuple[list[str], dict[str, dict[str, Any]]]: - return normalize_task_execution_payload( - tasks, - options, - self._coerce_interface(interface), - controller_name=controller, - resource_name=resource, - ) - - def rescan_option(self, path: str | Path, option_name: str) -> list[dict[str, str]]: - root_path = Path(path).resolve() - interface = self.load(root_path, force_reload=True) - return rescan_scan_select_option(interface, option_name, root_path) - - @staticmethod - def _coerce_interface(interface: MaaFWInterface | dict[str, Any]) -> MaaFWInterface: - if isinstance(interface, MaaFWInterface): - return interface - if hasattr(interface, "model_dump"): - return MaaFWInterface.model_validate( - interface.model_dump(mode="json", by_alias=True) - ) - return MaaFWInterface.model_validate(interface) diff --git a/plugins/automas_maafw_interface/src/automas_maafw_interface/task_config.py b/plugins/automas_maafw_interface/src/automas_maafw_interface/task_config.py deleted file mode 100644 index bae3e431..00000000 --- a/plugins/automas_maafw_interface/src/automas_maafw_interface/task_config.py +++ /dev/null @@ -1,621 +0,0 @@ -# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software -# Copyright © 2025-2026 AUTO-MAS Team - -# This file is part of AUTO-MAS. - -# AUTO-MAS is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of -# the License, or (at your option) any later version. - -# AUTO-MAS is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty -# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See -# the GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with AUTO-MAS. If not, see . - - -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from .models import ( - SUPPORTED_OPTION_TYPES, - MaaFWInterface, - MaaFWOption, - MaaFWPreset, - MaaFWPresetOptionValue, - MaaFWTaskOptionValue, - MaaFWTaskOptionsByTask, - build_pretask_task_name, - is_pretask_task_name, - iter_pretasks, -) - - -CUSTOM_PRESET_NAME = "__auto_mas_custom_preset__" - - -class MaaFWTaskPresetSnapshot(BaseModel): - taskOrder: list[str] = Field(default_factory=list) - taskChecked: dict[str, bool] = Field(default_factory=dict) - taskOptions: MaaFWTaskOptionsByTask = Field(default_factory=dict) - - -class MaaFWTaskConfig(BaseModel): - model_config = ConfigDict(extra="ignore") - - selectedPreset: str = CUSTOM_PRESET_NAME - presets: dict[str, MaaFWTaskPresetSnapshot] = Field(default_factory=dict) - - @model_validator(mode="before") - @classmethod - def normalize_raw_config(cls, value: Any): - if not isinstance(value, dict): - return value - - selected_preset = _normalize_preset_name(value.get("selectedPreset")) - raw_presets = value.get("presets") - normalized_presets: dict[str, dict[str, Any]] = {} - if isinstance(raw_presets, dict): - for preset_name, snapshot in raw_presets.items(): - if isinstance(preset_name, str): - normalized_presets[preset_name] = _normalize_raw_snapshot(snapshot) - - return { - "selectedPreset": selected_preset, - "presets": normalized_presets, - } - - -def normalize_task_config( - config: MaaFWTaskConfig, - interface_model: MaaFWInterface, -) -> MaaFWTaskConfig: - preset_snapshots: dict[str, MaaFWTaskPresetSnapshot] = {} - custom_snapshot = config.presets.get(CUSTOM_PRESET_NAME) - preset_snapshots[CUSTOM_PRESET_NAME] = normalize_snapshot( - custom_snapshot, - interface_model, - ) - - for preset in interface_model.preset: - persisted_snapshot = config.presets.get(preset.name) - snapshot = persisted_snapshot or build_interface_preset_snapshot( - interface_model, - preset, - ) - preset_snapshots[preset.name] = normalize_snapshot(snapshot, interface_model) - - selected_preset = _normalize_preset_name(config.selectedPreset) - if selected_preset not in preset_snapshots: - selected_preset = CUSTOM_PRESET_NAME - - return MaaFWTaskConfig( - selectedPreset=selected_preset, - presets=preset_snapshots, - ) - - -def normalize_snapshot( - snapshot: MaaFWTaskPresetSnapshot | dict[str, Any] | None, - interface_model: MaaFWInterface, -) -> MaaFWTaskPresetSnapshot: - default_task_order = _build_default_task_order(interface_model) - pretask_task_order = [ - build_pretask_task_name(pretask) for pretask in iter_pretasks(interface_model) - ] - all_task_ids = [*pretask_task_order, *default_task_order] - valid_task_ids = set(all_task_ids) - normalized_order: list[str] = [] - seen_task_ids: set[str] = set() - raw_snapshot = _normalize_raw_snapshot(snapshot) - - raw_task_order = [ - task_id - for task_id in raw_snapshot["taskOrder"] - if task_id in valid_task_ids - ] - partitioned_task_order = [ - task_id for task_id in raw_task_order if is_pretask_task_name(task_id) - ] + [ - task_id for task_id in raw_task_order if not is_pretask_task_name(task_id) - ] - for task_id in partitioned_task_order: - if task_id in valid_task_ids and task_id not in seen_task_ids: - normalized_order.append(task_id) - seen_task_ids.add(task_id) - - for task_id in default_task_order: - if task_id not in seen_task_ids: - normalized_order.append(task_id) - - normalized_checked = {task_id: False for task_id in all_task_ids} - for task_id, checked in raw_snapshot["taskChecked"].items(): - if task_id in valid_task_ids: - normalized_checked[task_id] = bool(checked) - - normalized_options = normalize_task_options_by_task( - raw_snapshot["taskOptions"], - normalized_order, - interface_model, - ) - return MaaFWTaskPresetSnapshot( - taskOrder=normalized_order, - taskChecked=normalized_checked, - taskOptions=normalized_options, - ) - - -def normalize_task_options_by_task( - raw_task_options: dict[str, Any] | None, - task_names: list[str], - interface_model: MaaFWInterface, - *, - controller_name: str | None = None, - resource_name: str | None = None, -) -> MaaFWTaskOptionsByTask: - task_option_maps = _build_task_option_maps( - interface_model, - controller_name=controller_name, - resource_name=resource_name, - ) - normalized: MaaFWTaskOptionsByTask = {} - - for task_name in [item for item in task_names if isinstance(item, str)]: - option_map = task_option_maps.get(task_name, {}) - defaults, value_types = _build_option_defaults(option_map) - case_name_sets = _build_option_case_name_sets(option_map) - raw_options_for_task = ( - raw_task_options.get(task_name) - if isinstance(raw_task_options, dict) - else None - ) - normalized[task_name] = _normalize_options_for_task( - raw_options_for_task, - option_map, - defaults, - value_types, - case_name_sets, - ) - - return normalized - - -def normalize_task_execution_payload( - raw_task_list: Any, - raw_task_options: Any, - interface_model: MaaFWInterface, - *, - controller_name: str | None = None, - resource_name: str | None = None, -) -> tuple[list[str], MaaFWTaskOptionsByTask]: - pretask_task_names = [ - build_pretask_task_name(pretask) for pretask in iter_pretasks(interface_model) - ] - valid_task_names = { - *pretask_task_names, - *(task.name for task in interface_model.task), - } - normalized_task_list: list[str] = [] - seen_task_names: set[str] = set() - - if isinstance(raw_task_list, list): - for task_name in raw_task_list: - if not isinstance(task_name, str): - continue - if task_name not in valid_task_names or task_name in seen_task_names: - continue - normalized_task_list.append(task_name) - seen_task_names.add(task_name) - - normalized_task_list = [ - task_name - for task_name in normalized_task_list - if is_pretask_task_name(task_name) - ] + [ - task_name - for task_name in normalized_task_list - if not is_pretask_task_name(task_name) - ] - - normalized_task_options = normalize_task_options_by_task( - raw_task_options if isinstance(raw_task_options, dict) else None, - normalized_task_list, - interface_model, - controller_name=controller_name, - resource_name=resource_name, - ) - return normalized_task_list, normalized_task_options - - -def build_interface_preset_snapshot( - interface_model: MaaFWInterface, - preset: MaaFWPreset, - *, - task_order: list[str] | None = None, - task_option_maps: dict[str, dict[str, MaaFWOption]] | None = None, - include_default_options: bool = True, -) -> MaaFWTaskPresetSnapshot: - task_order = task_order or _build_default_task_order(interface_model) - task_checked = {task_name: False for task_name in task_order} - task_option_maps = task_option_maps or _build_task_option_maps(interface_model) - task_options_by_task: MaaFWTaskOptionsByTask = {} - - if include_default_options: - for task_name in task_order: - defaults, _ = _build_option_defaults(task_option_maps.get(task_name, {})) - task_options_by_task[task_name] = defaults - - ordered_preset_tasks: list[str] = [] - seen_task_names: set[str] = set() - for preset_task in preset.task or []: - if preset_task.name not in task_checked or preset_task.name in seen_task_names: - continue - - ordered_preset_tasks.append(preset_task.name) - seen_task_names.add(preset_task.name) - task_checked[preset_task.name] = bool( - True if preset_task.enabled is None else preset_task.enabled - ) - - option_map = task_option_maps.get(preset_task.name, {}) - target_options = task_options_by_task.setdefault(preset_task.name, {}) - for option_name, option_value in (preset_task.option or {}).items(): - if option_name not in option_map: - continue - _apply_preset_option_value( - option_name, - option_value, - option_map, - target_options, - ) - - normalized_order = ordered_preset_tasks + [ - task_name for task_name in task_order if task_name not in seen_task_names - ] - return MaaFWTaskPresetSnapshot( - taskOrder=normalized_order, - taskChecked=task_checked, - taskOptions=task_options_by_task, - ) - - -def _normalize_raw_snapshot(snapshot: Any) -> dict[str, Any]: - if isinstance(snapshot, MaaFWTaskPresetSnapshot): - return { - "taskOrder": [item for item in snapshot.taskOrder if isinstance(item, str)], - "taskChecked": { - task_id: bool(checked) - for task_id, checked in snapshot.taskChecked.items() - if isinstance(task_id, str) - }, - "taskOptions": _normalize_raw_task_options(snapshot.taskOptions), - } - - if not isinstance(snapshot, dict): - return { - "taskOrder": [], - "taskChecked": {}, - "taskOptions": {}, - } - - task_order = snapshot.get("taskOrder") - task_checked = snapshot.get("taskChecked") - return { - "taskOrder": ( - [item for item in task_order if isinstance(item, str)] - if isinstance(task_order, list) - else [] - ), - "taskChecked": ( - { - task_id: bool(checked) - for task_id, checked in task_checked.items() - if isinstance(task_id, str) - } - if isinstance(task_checked, dict) - else {} - ), - "taskOptions": _normalize_raw_task_options(snapshot.get("taskOptions")), - } - - -def _normalize_raw_task_options(value: Any) -> dict[str, dict[str, MaaFWTaskOptionValue]]: - normalized: dict[str, dict[str, MaaFWTaskOptionValue]] = {} - if not isinstance(value, dict): - return normalized - - for task_id, option_map in value.items(): - if not isinstance(task_id, str) or not isinstance(option_map, dict): - continue - - normalized_options: dict[str, MaaFWTaskOptionValue] = {} - for option_name, option_value in option_map.items(): - if not isinstance(option_name, str): - continue - normalized_value = _normalize_option_value_for_storage(option_value) - if normalized_value is not None: - normalized_options[option_name] = normalized_value - normalized[task_id] = normalized_options - - return normalized - - -def _normalize_option_value_for_storage(value: Any) -> MaaFWTaskOptionValue | None: - if isinstance(value, str): - return value - if isinstance(value, list): - return [item for item in value if isinstance(item, str)] - if isinstance(value, dict): - return { - key: item - for key, item in value.items() - if isinstance(key, str) and isinstance(item, str) - } - return None - - -def _normalize_preset_name(value: Any) -> str: - if isinstance(value, str) and value.strip(): - return value.strip() - return CUSTOM_PRESET_NAME - - -def _build_default_task_order(interface_model: MaaFWInterface) -> list[str]: - return [task.name for task in interface_model.task] - - -def _build_task_option_maps( - interface_model: MaaFWInterface, - *, - controller_name: str | None = None, - resource_name: str | None = None, -) -> dict[str, dict[str, MaaFWOption]]: - option_map = interface_model.option - task_option_maps: dict[str, dict[str, MaaFWOption]] = {} - common_option_names = _build_common_option_names( - interface_model, - controller_name=controller_name, - resource_name=resource_name, - ) - - for task in interface_model.task: - collected: dict[str, MaaFWOption] = {} - _collect_task_options(common_option_names, option_map, collected) - _collect_task_options(task.option or [], option_map, collected) - task_option_maps[task.name] = collected - - for pretask in iter_pretasks(interface_model): - if ( - controller_name is not None - and pretask.controller - and controller_name not in pretask.controller - ): - continue - if ( - resource_name is not None - and pretask.resource - and resource_name not in pretask.resource - ): - continue - collected: dict[str, MaaFWOption] = {} - _collect_task_options(pretask.option or [], interface_model.option, collected) - task_option_maps[build_pretask_task_name(pretask)] = collected - - return task_option_maps - - -def _build_common_option_names( - interface_model: MaaFWInterface, - *, - controller_name: str | None = None, - resource_name: str | None = None, -) -> list[str]: - option_names: list[str] = [] - option_names.extend(interface_model.global_option or []) - - for resource in interface_model.resource: - if resource_name is not None and resource.name != resource_name: - continue - option_names.extend(resource.option or []) - - for controller in interface_model.controller: - if controller_name is not None and controller.name != controller_name: - continue - option_names.extend(controller.option or []) - - return option_names - - -def _collect_task_options( - option_names: list[str], - option_map: dict[str, MaaFWOption], - target: dict[str, MaaFWOption], -) -> None: - for option_name in option_names: - if option_name in target: - continue - option = option_map.get(option_name) - if option is None or option.type not in SUPPORTED_OPTION_TYPES: - continue - - target[option_name] = option - for case in option.cases or []: - if case.option: - _collect_task_options(case.option, option_map, target) - - -def _build_option_defaults( - option_map: dict[str, MaaFWOption], -) -> tuple[dict[str, MaaFWTaskOptionValue], dict[str, str]]: - defaults: dict[str, MaaFWTaskOptionValue] = {} - value_types: dict[str, str] = {} - - for option_name, option in option_map.items(): - if option.type in {"select", "scan_select", "switch"}: - default_value = option.default_case - if not isinstance(default_value, str): - default_value = option.cases[0].name if option.cases else "" - defaults[option_name] = default_value - value_types[option_name] = "string" - continue - - if option.type == "checkbox": - selected_values = ( - set(option.default_case) - if isinstance(option.default_case, list) - else set() - ) - defaults[option_name] = [ - case.name - for case in option.cases or [] - if case.name in selected_values - ] - value_types[option_name] = "string_list" - continue - - if option.type == "input": - input_defaults: dict[str, str] = {} - for input_case in option.inputs or []: - input_defaults[input_case.name] = input_case.default or "" - defaults[option_name] = input_defaults - value_types[option_name] = "object" - - return defaults, value_types - - -def _build_option_case_name_sets( - option_map: dict[str, MaaFWOption], -) -> dict[str, set[str]]: - case_name_sets: dict[str, set[str]] = {} - - for option_name, option in option_map.items(): - if option.type in {"select", "scan_select", "switch", "checkbox"}: - case_name_sets[option_name] = {case.name for case in option.cases or []} - - return case_name_sets - - -def _normalize_options_for_task( - raw_options_for_task: Any, - option_map: dict[str, MaaFWOption], - defaults: dict[str, MaaFWTaskOptionValue], - value_types: dict[str, str], - case_name_sets: dict[str, set[str]], -) -> dict[str, MaaFWTaskOptionValue]: - normalized_options = { - key: _clone_option_value(value) for key, value in defaults.items() - } - if not isinstance(raw_options_for_task, dict): - return normalized_options - - for option_key, option_value in raw_options_for_task.items(): - if not isinstance(option_key, str) or option_key not in option_map: - continue - - expected_type = value_types.get(option_key) - if expected_type is None: - normalized_value = _normalize_option_value_for_storage(option_value) - if normalized_value is not None: - normalized_options[option_key] = normalized_value - continue - - if expected_type == "string" and isinstance(option_value, str): - allowed_cases = case_name_sets.get(option_key) - if allowed_cases is not None and option_value not in allowed_cases: - continue - normalized_options[option_key] = option_value - continue - - if expected_type == "string_list" and isinstance(option_value, list): - normalized_items = [item for item in option_value if isinstance(item, str)] - allowed_cases = case_name_sets.get(option_key) - if allowed_cases is not None: - normalized_items = [ - item for item in normalized_items if item in allowed_cases - ] - normalized_options[option_key] = normalized_items - continue - - if expected_type == "object" and isinstance(option_value, dict): - option = option_map.get(option_key) - if option is None or option.type != "input": - continue - existing_value = normalized_options.get(option_key) - normalized_input = ( - { - key: item - for key, item in existing_value.items() - if isinstance(key, str) and isinstance(item, str) - } - if isinstance(existing_value, dict) - else {} - ) - for input_case in option.inputs or []: - input_value = option_value.get(input_case.name) - if isinstance(input_value, str): - normalized_input[input_case.name] = input_value - normalized_options[option_key] = normalized_input - - return normalized_options - - -def _clone_option_value(value: MaaFWTaskOptionValue) -> MaaFWTaskOptionValue: - if isinstance(value, list): - return [item for item in value if isinstance(item, str)] - if isinstance(value, dict): - return { - key: item - for key, item in value.items() - if isinstance(key, str) and isinstance(item, str) - } - return value - - -def _apply_preset_option_value( - option_name: str, - value: MaaFWPresetOptionValue, - option_map: dict[str, MaaFWOption], - target_options: dict[str, MaaFWTaskOptionValue], -) -> None: - option = option_map.get(option_name) - if option is None: - return - - if option.type == "input": - if not isinstance(value, dict): - return - existing_value = target_options.get(option_name) - normalized_input: dict[str, str] = ( - { - key: item - for key, item in existing_value.items() - if isinstance(key, str) and isinstance(item, str) - } - if isinstance(existing_value, dict) - else {} - ) - for input_case in option.inputs or []: - input_value = value.get(input_case.name) - if isinstance(input_value, str): - normalized_input[input_case.name] = input_value - target_options[option_name] = normalized_input - return - - if option.type == "checkbox": - if isinstance(value, list): - target_options[option_name] = [ - item for item in value if isinstance(item, str) - ] - return - - if isinstance(value, str): - target_options[option_name] = value - return - - normalized_value = _normalize_option_value_for_storage(value) - if normalized_value is not None: - target_options[option_name] = normalized_value diff --git a/plugins/automas_maafw_project_update/README.md b/plugins/automas_maafw_project_update/README.md deleted file mode 100644 index 5fa405d9..00000000 --- a/plugins/automas_maafw_project_update/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# automas-maafw-project-update - -Standalone MaaFW project update service and AUTO-MAS plugin. - -It provides `maafw.project_update.v1` and supports `mirrorchyan` and -`github_release` providers without depending on MaaFW runner or agent runtime. diff --git a/plugins/automas_maafw_project_update/pyproject.toml b/plugins/automas_maafw_project_update/pyproject.toml deleted file mode 100644 index 436b2780..00000000 --- a/plugins/automas_maafw_project_update/pyproject.toml +++ /dev/null @@ -1,25 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "automas-maafw-project-update" -version = "0.1.0" -description = "Standalone MaaFW project update plugin for AUTO-MAS" -readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" -dependencies = [ - "aiofiles>=23", - "automas-maafw-interface>=0.1.0", - "httpx>=0.27", - "packaging>=23", -] - -[project.entry-points."auto_mas.plugins"] -automas_maafw_project_update = "automas_maafw_project_update.plugin:Plugin" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -automas_maafw_project_update = ["py.typed"] diff --git a/plugins/automas_maafw_project_update/src/automas_maafw_project_update/__init__.py b/plugins/automas_maafw_project_update/src/automas_maafw_project_update/__init__.py deleted file mode 100644 index 7a3ca5af..00000000 --- a/plugins/automas_maafw_project_update/src/automas_maafw_project_update/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from .service import MaaFWProjectUpdateService -from .updater import ( - MaaFWProjectUpdateCandidate, - MaaFWProjectUpdateError, - MaaFWProjectUpdateResult, - MaaFWUpdateProviderInfo, - apply_maafw_project_update, - check_maafw_project_update, - list_update_providers, - update_maafw_project_if_needed, -) - -__all__ = [ - "MaaFWProjectUpdateCandidate", - "MaaFWProjectUpdateError", - "MaaFWProjectUpdateResult", - "MaaFWProjectUpdateService", - "MaaFWUpdateProviderInfo", - "apply_maafw_project_update", - "check_maafw_project_update", - "list_update_providers", - "update_maafw_project_if_needed", -] diff --git a/plugins/automas_maafw_project_update/src/automas_maafw_project_update/plugin.py b/plugins/automas_maafw_project_update/src/automas_maafw_project_update/plugin.py deleted file mode 100644 index 4230ecce..00000000 --- a/plugins/automas_maafw_project_update/src/automas_maafw_project_update/plugin.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from .service import MaaFWProjectUpdateService - -if TYPE_CHECKING: - from auto_mas_core import PluginContext - - -DEFAULT_INSTANCE = { - "name": "MaaFW Project Update", - "enabled": True, - "config": {}, -} - -schema = { - "__no_plugin_config__": { - "type": "boolean", - "default": True, - "hidden": True, - "configurable": False, - "title": "No plugin-level configuration", - }, -} - - -class Plugin: - provides = ["maafw.project_update.v1"] - - def __init__(self, ctx: "PluginContext") -> None: - self.ctx = ctx - self.service = MaaFWProjectUpdateService() - - async def on_start(self) -> None: - self.ctx.set("maafw.project_update.v1", self.service) - self.ctx.logger.info("maafw.project_update.v1 ready") - - async def on_stop(self, reason: str) -> None: - self.ctx.logger.info(f"maafw.project_update.v1 stopped, reason={reason}") diff --git a/plugins/automas_maafw_project_update/src/automas_maafw_project_update/py.typed b/plugins/automas_maafw_project_update/src/automas_maafw_project_update/py.typed deleted file mode 100644 index 8b137891..00000000 --- a/plugins/automas_maafw_project_update/src/automas_maafw_project_update/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/automas_maafw_project_update/src/automas_maafw_project_update/service.py b/plugins/automas_maafw_project_update/src/automas_maafw_project_update/service.py deleted file mode 100644 index 54c1188e..00000000 --- a/plugins/automas_maafw_project_update/src/automas_maafw_project_update/service.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import httpx -from automas_maafw_interface.models import MaaFWInterface - -from .updater import ( - MaaFWProjectUpdateCandidate, - MaaFWProjectUpdateResult, - MaaFWUpdateProviderInfo, - apply_maafw_project_update, - check_maafw_project_update, - list_update_providers, - update_maafw_project_if_needed, -) - - -class MaaFWProjectUpdateService: - """maafw.project_update.v1 service.""" - - def list_providers(self) -> list[MaaFWUpdateProviderInfo]: - return list_update_providers() - - async def check_update( - self, - interface: MaaFWInterface | dict[str, Any], - *, - current_version: str | None = None, - source_config: dict[str, Any] | None = None, - proxy: httpx.Proxy | None = None, - send_log: Any = None, - ) -> MaaFWProjectUpdateCandidate | None: - return await check_maafw_project_update( - self._coerce_interface(interface), - current_version=current_version, - source_config=source_config, - proxy=proxy, - send_log=send_log, - ) - - async def apply_update( - self, - project_path: str | Path, - candidate: MaaFWProjectUpdateCandidate, - *, - proxy: httpx.Proxy | None = None, - send_log: Any = None, - ) -> None: - await apply_maafw_project_update( - Path(project_path).resolve(), - candidate, - proxy=proxy, - send_log=send_log, - ) - - async def update_if_needed( - self, - project_path: str | Path, - interface: MaaFWInterface | dict[str, Any], - *, - mirror_cdk: str = "", - channel: str = "stable", - proxy: httpx.Proxy | None = None, - send_log: Any = None, - source_config: dict[str, Any] | None = None, - ) -> MaaFWProjectUpdateResult: - return await update_maafw_project_if_needed( - Path(project_path).resolve(), - self._coerce_interface(interface), - mirror_cdk=mirror_cdk, - channel=channel, - proxy=proxy, - send_log=send_log, - source_config=source_config, - ) - - @staticmethod - def _coerce_interface(interface: MaaFWInterface | dict[str, Any]) -> MaaFWInterface: - if isinstance(interface, MaaFWInterface): - return interface - if hasattr(interface, "model_dump"): - return MaaFWInterface.model_validate( - interface.model_dump(mode="json", by_alias=True) - ) - return MaaFWInterface.model_validate(interface) diff --git a/plugins/automas_maafw_project_update/src/automas_maafw_project_update/updater.py b/plugins/automas_maafw_project_update/src/automas_maafw_project_update/updater.py deleted file mode 100644 index c6aaece8..00000000 --- a/plugins/automas_maafw_project_update/src/automas_maafw_project_update/updater.py +++ /dev/null @@ -1,850 +0,0 @@ -from __future__ import annotations - -import asyncio -import hashlib -import json -import re -import shutil -import zipfile -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Callable - -import aiofiles -import httpx -from automas_maafw_interface.models import MaaFWInterface -from packaging import version - - -UPDATE_WORK_DIR = ".mas-update" -DOWNLOAD_FILE_NAME = "download.zip" -DOWNLOAD_TEMP_NAME = "download.tmp" -DOWNLOAD_RETRY_TIMES = 3 -HTTP_HEADERS = {"User-Agent": "AutoMasGui"} - -MIRROR_ERROR_INFO = { - 1001: "MirrorChyan URL parameters are invalid", - 7001: "MirrorChyan CDK is expired", - 7002: "MirrorChyan CDK is invalid", - 7003: "MirrorChyan CDK download limit reached today", - 7004: "MirrorChyan CDK type does not match the resource", - 7005: "MirrorChyan CDK has been banned", - 8001: "MirrorChyan resource is not available for this platform", - 8002: "MirrorChyan OS parameter is invalid", - 8003: "MirrorChyan arch parameter is invalid", - 8004: "MirrorChyan channel parameter is invalid", - 1: "MirrorChyan returned unknown error", -} - - -@dataclass -class MaaFWUpdateProviderInfo: - name: str - label: str - description: str = "" - - -@dataclass -class MaaFWProjectUpdateCandidate: - source: str - version: str - download_url: str | None = None - sha256: str | None = None - - -@dataclass -class MaaFWProjectUpdateResult: - checked: bool - updated: bool - current_version: str - latest_version: str | None = None - source: str | None = None - message: str = "" - - -class MaaFWProjectUpdateError(RuntimeError): - """Raised when a MaaFW project package cannot be checked or applied.""" - - -def list_update_providers() -> list[MaaFWUpdateProviderInfo]: - return [ - MaaFWUpdateProviderInfo( - name="mirrorchyan", - label="MirrorChyan", - description="Use MirrorChyan resource latest API.", - ), - MaaFWUpdateProviderInfo( - name="github_release", - label="GitHub Release", - description="Use GitHub Releases and zip assets.", - ), - ] - - -async def update_maafw_project_if_needed( - project_path: Path, - interface_model: MaaFWInterface, - *, - mirror_cdk: str = "", - channel: str = "stable", - proxy: httpx.Proxy | None = None, - send_log: Callable[[str], None] | None = None, - source_config: dict[str, Any] | None = None, -) -> MaaFWProjectUpdateResult: - send_update_log = send_log or (lambda _: None) - current_version = interface_model.version or "" - update_channel = channel or "stable" - - if not current_version: - message = "interface does not declare version, skip MaaFW project update" - send_update_log(message) - return MaaFWProjectUpdateResult( - checked=False, - updated=False, - current_version=current_version, - message=message, - ) - - send_update_log("start checking MaaFW project update") - send_update_log(f"current version: {current_version}") - send_update_log(f"update channel: {update_channel}") - - merged_source_config = dict(source_config or {}) - merged_source_config.setdefault("mirror_cdk", mirror_cdk) - merged_source_config.setdefault("channel", update_channel) - candidate = await check_maafw_project_update( - interface_model, - current_version=current_version, - source_config=merged_source_config, - proxy=proxy, - send_log=send_update_log, - ) - - if candidate is None: - message = f"MaaFW project is up to date: {current_version}" - send_update_log(message) - return MaaFWProjectUpdateResult( - checked=True, - updated=False, - current_version=current_version, - message=message, - ) - - send_update_log( - f"found MaaFW project update: {current_version} -> {candidate.version} ({candidate.source})" - ) - await apply_maafw_project_update( - project_path.resolve(), - candidate, - proxy=proxy, - send_log=send_update_log, - ) - - message = f"MaaFW project update applied: {candidate.version}" - send_update_log(message) - return MaaFWProjectUpdateResult( - checked=True, - updated=True, - current_version=current_version, - latest_version=candidate.version, - source=candidate.source, - message=message, - ) - - -async def check_maafw_project_update( - interface_model: MaaFWInterface, - *, - current_version: str | None = None, - source_config: dict[str, Any] | None = None, - proxy: httpx.Proxy | None = None, - send_log: Callable[[str], None] | None = None, -) -> MaaFWProjectUpdateCandidate | None: - config = dict(source_config or {}) - provider = str(config.get("source") or "").strip().lower() - if not provider: - provider = "mirrorchyan" if interface_model.mirrorchyan_rid else "github_release" - - current = current_version if current_version is not None else (interface_model.version or "") - send_update_log = send_log or (lambda _: None) - - if provider == "mirrorchyan": - if not interface_model.mirrorchyan_rid: - send_update_log("MirrorChyan RID is not configured, skip update") - return None - send_update_log(f"MirrorChyan RID: {interface_model.mirrorchyan_rid}") - if interface_model.mirrorchyan_multiplatform: - send_update_log("MirrorChyan platform: win/x64") - return await _check_mirrorchyan_update( - interface_model, - current_version=current, - mirror_cdk=str(config.get("mirror_cdk") or config.get("cdk") or ""), - channel=str(config.get("channel") or "stable"), - proxy=proxy, - ) - - if provider == "github_release": - return await _check_github_release_update( - interface_model, - current_version=current, - source_config=config, - proxy=proxy, - ) - - raise MaaFWProjectUpdateError(f"unsupported MaaFW project update provider: {provider}") - - -async def apply_maafw_project_update( - project_path: Path, - candidate: MaaFWProjectUpdateCandidate, - *, - proxy: httpx.Proxy | None = None, - send_log: Callable[[str], None] | None = None, -) -> None: - send_update_log = send_log or (lambda _: None) - if not candidate.download_url: - raise MaaFWProjectUpdateError("update provider did not return a download URL") - - package_path = await _download_update_package( - project_path.resolve(), - candidate.download_url, - expected_sha256=candidate.sha256, - proxy=proxy, - send_log=send_update_log, - ) - await _apply_update_package(project_path.resolve(), package_path, send_update_log) - - -async def _check_mirrorchyan_update( - interface_model: MaaFWInterface, - *, - current_version: str, - mirror_cdk: str, - channel: str, - proxy: httpx.Proxy | None, -) -> MaaFWProjectUpdateCandidate | None: - rid = interface_model.mirrorchyan_rid - if not rid: - return None - - params: dict[str, str] = { - "user_agent": "AutoMasGui", - "current_version": current_version, - "cdk": mirror_cdk or "", - "channel": channel or "stable", - } - if interface_model.mirrorchyan_multiplatform: - params["os"] = "win" - params["arch"] = "x64" - - url = f"https://mirrorchyan.com/api/resources/{rid}/latest" - try: - async with httpx.AsyncClient(proxy=proxy, follow_redirects=True, timeout=30.0) as client: - response = await client.get(url, params=params, headers=HTTP_HEADERS) - except httpx.HTTPError as exc: - raise MaaFWProjectUpdateError( - f"MirrorChyan update check failed: {_sanitize_log_message(str(exc))}" - ) from None - - result = _load_response_json(response) - if response.status_code != 200 or result.get("code", 0) != 0: - error_code = result.get("code") - if error_code in MIRROR_ERROR_INFO: - raise MaaFWProjectUpdateError(MIRROR_ERROR_INFO[error_code]) - raise MaaFWProjectUpdateError(f"MirrorChyan returned HTTP {response.status_code}") - - data = result.get("data") - if not isinstance(data, dict): - raise MaaFWProjectUpdateError("MirrorChyan did not return version data") - - latest_version = str( - data.get("version_name") or data.get("version") or data.get("name") or "" - ).strip() - if not latest_version: - raise MaaFWProjectUpdateError("MirrorChyan did not return version") - if not _is_remote_newer(latest_version, current_version): - return None - - return MaaFWProjectUpdateCandidate( - source="mirrorchyan", - version=latest_version, - download_url=str(data.get("url") or "").strip() or None, - sha256=str(data.get("sha256") or "").strip() or None, - ) - - -async def _check_github_release_update( - interface_model: MaaFWInterface, - *, - current_version: str, - source_config: dict[str, Any], - proxy: httpx.Proxy | None, -) -> MaaFWProjectUpdateCandidate | None: - repo = _normalize_github_repo(str(source_config.get("repo") or interface_model.github or "")) - if not repo: - return None - - tag = str(source_config.get("tag") or "").strip() - api_url = ( - f"https://api.github.com/repos/{repo}/releases/tags/{tag}" - if tag - else f"https://api.github.com/repos/{repo}/releases/latest" - ) - token = str(source_config.get("token") or "").strip() - headers = dict(HTTP_HEADERS) - headers["Accept"] = "application/vnd.github+json" - if token: - headers["Authorization"] = f"Bearer {token}" - - async with httpx.AsyncClient(proxy=proxy, follow_redirects=True, timeout=30.0) as client: - response = await client.get(api_url, headers=headers) - - if response.status_code == 404: - return None - data = _load_response_json(response) - if response.status_code >= 400: - message = str(data.get("message") or "").strip() - raise MaaFWProjectUpdateError( - f"GitHub release check failed: HTTP {response.status_code} {message}" - ) - - latest_version = str(data.get("tag_name") or data.get("name") or "").strip() - if not latest_version: - raise MaaFWProjectUpdateError("GitHub release did not return version") - if not _is_remote_newer(latest_version, current_version): - return None - - asset_pattern = str(source_config.get("asset_pattern") or r"\.zip$").strip() - download_url = _select_github_release_asset(data, asset_pattern) - if not download_url: - download_url = str(data.get("zipball_url") or "").strip() or None - - return MaaFWProjectUpdateCandidate( - source="github_release", - version=latest_version, - download_url=download_url, - sha256=str(source_config.get("sha256") or "").strip() or None, - ) - - -async def _download_update_package( - project_path: Path, - download_url: str, - *, - expected_sha256: str | None = None, - proxy: httpx.Proxy | None, - send_log: Callable[[str], None], -) -> Path: - update_dir = project_path / UPDATE_WORK_DIR - update_dir.mkdir(parents=True, exist_ok=True) - - temp_path = update_dir / DOWNLOAD_TEMP_NAME - package_path = update_dir / DOWNLOAD_FILE_NAME - _remove_path(temp_path) - _remove_path(package_path) - - send_log(f"start downloading MaaFW update package: {_sanitize_log_message(download_url)}") - last_error: Exception | None = None - for attempt in range(1, DOWNLOAD_RETRY_TIMES + 1): - _remove_path(temp_path) - try: - await _stream_update_package( - temp_path, - download_url, - proxy=proxy, - ) - _ensure_downloaded_zip(temp_path) - _ensure_expected_sha256(temp_path, expected_sha256) - temp_path.replace(package_path) - send_log(f"MaaFW update package downloaded: {package_path.stat().st_size} bytes") - return package_path - except Exception as exc: - last_error = exc - _remove_path(temp_path) - if attempt >= DOWNLOAD_RETRY_TIMES: - break - send_log( - "download failed, retrying " - f"({attempt}/{DOWNLOAD_RETRY_TIMES}): {_sanitize_log_message(str(exc))}" - ) - await asyncio.sleep(1) - - if isinstance(last_error, MaaFWProjectUpdateError): - raise MaaFWProjectUpdateError(_sanitize_log_message(str(last_error))) from None - raise MaaFWProjectUpdateError( - "download MaaFW update package failed: " - f"{_sanitize_log_message(str(last_error))}" - ) from None - - -async def _stream_update_package( - temp_path: Path, - download_url: str, - *, - proxy: httpx.Proxy | None, -) -> None: - async with httpx.AsyncClient(proxy=proxy, follow_redirects=True, timeout=30.0) as client: - async with client.stream("GET", download_url, headers=HTTP_HEADERS) as response: - if response.status_code not in (200, 206): - error_hint = await _read_download_error_hint(response) - if error_hint: - raise MaaFWProjectUpdateError( - f"download update package failed: HTTP {response.status_code}, {error_hint}" - ) - raise MaaFWProjectUpdateError( - f"download update package failed: HTTP {response.status_code}" - ) - - async with aiofiles.open(temp_path, "wb") as file: - async for chunk in response.aiter_bytes(chunk_size=8192): - if chunk: - await file.write(chunk) - - -async def _read_download_error_hint(response: httpx.Response) -> str: - try: - content = await response.aread() - except Exception: - return "" - return _build_download_error_hint(content) - - -def _ensure_downloaded_zip(package_path: Path) -> None: - if not package_path.exists() or package_path.stat().st_size == 0: - raise MaaFWProjectUpdateError("update source returned an empty file") - if zipfile.is_zipfile(package_path): - return - - error_hint = _read_local_download_error_hint(package_path) - if error_hint: - raise MaaFWProjectUpdateError(f"download update package failed: {error_hint}") - raise MaaFWProjectUpdateError("update source did not return a valid zip file") - - -def _ensure_expected_sha256(package_path: Path, expected_sha256: str | None) -> None: - expected = (expected_sha256 or "").strip().lower() - if not expected: - return - - actual = _calculate_sha256(package_path) - if actual == expected: - return - - raise MaaFWProjectUpdateError( - f"sha256 mismatch, expected {expected[:12]}..., actual {actual[:12]}..." - ) - - -def _calculate_sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as file: - for chunk in iter(lambda: file.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def _read_local_download_error_hint(path: Path) -> str: - try: - content = path.read_bytes()[:4096] - except Exception: - return "" - return _build_download_error_hint(content) - - -def _build_download_error_hint(content: bytes) -> str: - if not content: - return "update source returned empty response" - - text = _decode_download_error_text(content) - if not text: - return "" - - try: - data = json.loads(text) - except json.JSONDecodeError: - if text.lstrip().startswith("<"): - return "update source returned an HTML page instead of zip" - return "" - - if not isinstance(data, dict): - return "" - - error_code = data.get("code") - if error_code in MIRROR_ERROR_INFO: - return MIRROR_ERROR_INFO[error_code] - - message = str(data.get("msg") or data.get("message") or "").strip() - if not message: - return "" - return f"update source returned error: {_sanitize_log_message(message)}" - - -def _decode_download_error_text(content: bytes) -> str: - for encoding in ("utf-8", "gb18030"): - try: - return content.decode(encoding).strip() - except UnicodeDecodeError: - continue - return "" - - -async def _apply_update_package( - project_path: Path, - package_path: Path, - send_log: Callable[[str], None], -) -> None: - update_dir = project_path / UPDATE_WORK_DIR - extract_dir = update_dir / "extract" - backup_dir = update_dir / "backup" - - _remove_path(extract_dir) - _remove_path(backup_dir) - extract_dir.mkdir(parents=True, exist_ok=True) - - try: - _safe_extract_zip(package_path, extract_dir) - package_root = _find_package_root(extract_dir) - changes_path = _find_changes_file(package_root, extract_dir) - - if changes_path is None: - send_log("applying full MaaFW update package") - _apply_full_package(project_path, package_root, backup_dir) - else: - send_log("applying incremental MaaFW update package") - _apply_incremental_package( - project_path, - package_root, - changes_path, - backup_dir, - extract_dir, - ) - send_log("MaaFW update package applied") - finally: - _remove_path(extract_dir) - _remove_path(package_path) - - -def _apply_full_package(project_path: Path, package_root: Path, backup_dir: Path) -> None: - backup_dir.mkdir(parents=True, exist_ok=True) - touched_paths: set[Path] = set() - - try: - for child in package_root.iterdir(): - if child.name in {UPDATE_WORK_DIR, "changes.json"}: - continue - - target = _resolve_project_relative_path(project_path, child.name) - touched_paths.add(target) - _backup_target(project_path, target, backup_dir) - _copy_path(child, target) - except Exception: - _restore_incremental_backup(project_path, backup_dir, touched_paths) - raise - else: - _remove_path(backup_dir) - - -def _apply_incremental_package( - project_path: Path, - package_root: Path, - changes_path: Path, - backup_dir: Path, - extract_dir: Path, -) -> None: - changes = _load_changes(changes_path) - payload_root = _resolve_payload_root( - package_root, - changes_path, - changes, - extract_dir, - ) - backup_dir.mkdir(parents=True, exist_ok=True) - touched_paths: set[Path] = set() - - try: - for raw_path in _iter_deleted_paths(changes): - target = _resolve_project_relative_path(project_path, raw_path) - touched_paths.add(target) - _backup_target(project_path, target, backup_dir) - _remove_path(target) - - for source in payload_root.rglob("*"): - if source.is_dir(): - continue - if source == changes_path: - continue - if source.name == "changes.json": - continue - - relative_path = source.relative_to(payload_root) - target = _resolve_project_relative_path(project_path, relative_path.as_posix()) - touched_paths.add(target) - _backup_target(project_path, target, backup_dir) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, target) - except Exception: - _restore_incremental_backup(project_path, backup_dir, touched_paths) - raise - else: - _remove_path(backup_dir) - - -def _load_response_json(response: httpx.Response) -> dict[str, Any]: - try: - data = response.json() - except Exception as exc: - raise MaaFWProjectUpdateError("update source did not return JSON") from exc - if not isinstance(data, dict): - raise MaaFWProjectUpdateError("update source returned invalid JSON shape") - return data - - -def _is_remote_newer(remote_version: str, current_version: str) -> bool: - remote = remote_version.strip() - current = current_version.strip() - if not remote: - return False - if not current: - return True - - try: - return version.parse(_normalize_version(remote)) > version.parse( - _normalize_version(current) - ) - except version.InvalidVersion: - return remote != current - - -def _normalize_version(raw_version: str) -> str: - return raw_version.strip().lstrip("vV") - - -def _safe_extract_zip(package_path: Path, extract_dir: Path) -> None: - try: - with zipfile.ZipFile(package_path, "r") as zip_ref: - for member in zip_ref.infolist(): - target = (extract_dir / member.filename).resolve() - if not _is_within_path(target, extract_dir): - raise MaaFWProjectUpdateError( - f"update package contains unsafe path: {member.filename}" - ) - zip_ref.extractall(extract_dir) - except zipfile.BadZipFile as exc: - raise MaaFWProjectUpdateError("update package is not a valid zip file") from exc - - -def _find_package_root(extract_dir: Path) -> Path: - for candidate in [extract_dir, *_direct_child_dirs(extract_dir)]: - if _has_interface_file(candidate): - return candidate - - for interface_file in extract_dir.rglob("interface.json*"): - if interface_file.name in {"interface.json", "interface.jsonc"}: - return interface_file.parent - - raise MaaFWProjectUpdateError("interface.json was not found in update package") - - -def _find_changes_file(package_root: Path, extract_dir: Path) -> Path | None: - for candidate in (package_root / "changes.json", extract_dir / "changes.json"): - if candidate.exists() and candidate.is_file(): - return candidate - return None - - -def _has_interface_file(path: Path) -> bool: - return (path / "interface.json").is_file() or (path / "interface.jsonc").is_file() - - -def _direct_child_dirs(path: Path) -> list[Path]: - return [child for child in path.iterdir() if child.is_dir()] - - -def _load_changes(changes_path: Path) -> dict[str, Any]: - try: - data = json.loads(changes_path.read_text(encoding="utf-8")) - except Exception as exc: - raise MaaFWProjectUpdateError(f"cannot parse changes.json: {exc}") from exc - if not isinstance(data, dict): - raise MaaFWProjectUpdateError("changes.json must be a JSON object") - return data - - -def _resolve_payload_root( - package_root: Path, - changes_path: Path, - changes: dict[str, Any], - extract_dir: Path, -) -> Path: - for key in ("payload", "files", "root"): - raw_path = changes.get(key) - if not isinstance(raw_path, str) or not raw_path.strip(): - continue - candidate = (changes_path.parent / raw_path).resolve() - if not candidate.exists() or not candidate.is_dir(): - continue - if not _is_within_path(candidate, extract_dir): - raise MaaFWProjectUpdateError(f"changes.json {key} path is unsafe: {raw_path}") - return candidate - - for folder_name in ("payload", "files"): - candidate = package_root / folder_name - if candidate.exists() and candidate.is_dir(): - return candidate - - return package_root - - -def _iter_deleted_paths(changes: dict[str, Any]) -> list[str]: - deleted_paths: list[str] = [] - for key in ( - "delete", - "deleted", - "deleted_dir", - "remove", - "removed", - "unlink", - "unlinks", - ): - value = changes.get(key) - if isinstance(value, list): - deleted_paths.extend(item for item in value if isinstance(item, str)) - elif isinstance(value, dict): - deleted_paths.extend(item for item in value if isinstance(item, str)) - return deleted_paths - - -def _backup_target(project_path: Path, target: Path, backup_dir: Path) -> None: - if not target.exists(): - return - - relative_path = target.relative_to(project_path) - backup_path = backup_dir / relative_path - if backup_path.exists(): - return - - backup_path.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(target), str(backup_path)) - - -def _restore_incremental_backup( - project_path: Path, - backup_dir: Path, - touched_paths: set[Path], -) -> None: - try: - for target in sorted(touched_paths, key=lambda item: len(item.parts), reverse=True): - _remove_path(target) - - if not backup_dir.exists(): - return - for backup_child in sorted(backup_dir.rglob("*"), key=lambda item: len(item.parts)): - if backup_child.is_dir(): - continue - relative_path = backup_child.relative_to(backup_dir) - target = project_path / relative_path - target.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(backup_child), str(target)) - finally: - _remove_path(backup_dir) - - -def _resolve_project_relative_path(project_path: Path, raw_path: str) -> Path: - normalized = raw_path.strip().replace("\\", "/") - if not normalized: - raise MaaFWProjectUpdateError("update package contains empty path") - - candidate = Path(normalized) - if candidate.is_absolute() or candidate.drive or candidate.root: - raise MaaFWProjectUpdateError(f"update package contains absolute path: {raw_path}") - if any(part in {"", ".", ".."} for part in candidate.parts): - raise MaaFWProjectUpdateError(f"update package contains invalid path: {raw_path}") - if candidate.parts[0] == UPDATE_WORK_DIR: - raise MaaFWProjectUpdateError(f"update package cannot write to {UPDATE_WORK_DIR}: {raw_path}") - - target = (project_path / candidate).resolve() - if not _is_within_path(target, project_path): - raise MaaFWProjectUpdateError(f"update package path escapes project root: {raw_path}") - return target - - -def _copy_path(source: Path, target: Path) -> None: - target.parent.mkdir(parents=True, exist_ok=True) - if source.is_dir(): - shutil.copytree(source, target, dirs_exist_ok=True) - else: - shutil.copy2(source, target) - - -def _remove_path(path: Path) -> None: - if not path.exists() and not path.is_symlink(): - return - if path.is_dir() and not path.is_symlink(): - shutil.rmtree(path, ignore_errors=True) - else: - path.unlink(missing_ok=True) - - -def _is_within_path(path: Path, base_dir: Path) -> bool: - try: - path.resolve().relative_to(base_dir.resolve()) - return True - except ValueError: - return False - - -def _normalize_github_repo(raw_value: str) -> str: - value = raw_value.strip() - if not value: - return "" - value = value.removesuffix(".git") - for prefix in ("https://github.com/", "http://github.com/", "github.com/"): - if value.startswith(prefix): - value = value[len(prefix):] - break - value = value.strip("/") - parts = value.split("/") - if len(parts) < 2 or not parts[0] or not parts[1]: - return "" - return f"{parts[0]}/{parts[1]}" - - -def _select_github_release_asset(data: dict[str, Any], asset_pattern: str) -> str | None: - assets = data.get("assets") - if not isinstance(assets, list): - return None - - try: - pattern = re.compile(asset_pattern) - except re.error as exc: - raise MaaFWProjectUpdateError(f"invalid GitHub asset pattern: {exc}") from exc - - for asset in assets: - if not isinstance(asset, dict): - continue - name = str(asset.get("name") or "") - if not pattern.search(name): - continue - url = str(asset.get("browser_download_url") or "").strip() - if url: - return url - return None - - -def _sanitize_log_message(message: str) -> str: - sensitive_patterns = [ - (r"(cdk=)[^&\s]+", r"\1***"), - (r"(password=)[^&\s]+", r"\1***"), - (r"(token=)[^&\s]+", r"\1***"), - (r"(api_key=)[^&\s]+", r"\1***"), - (r"(secret=)[^&\s]+", r"\1***"), - ] - sanitized_message = message - for pattern, replacement in sensitive_patterns: - sanitized_message = re.sub( - pattern, - replacement, - sanitized_message, - flags=re.IGNORECASE, - ) - return sanitized_message diff --git a/plugins/automas_maafw_runner/README.md b/plugins/automas_maafw_runner/README.md deleted file mode 100644 index a1555979..00000000 --- a/plugins/automas_maafw_runner/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# automas-maafw-runner - -Isolated MaaFW runner service for AUTO-MAS. - -It provides `maafw.runner.v1`. The service builds run plans in the host process -and runs MaaFW through a worker subprocess so importing the service does not load -`maa` into the AUTO-MAS main process. - -The wheel includes the MaaFW runtime worker code. `maa` is imported only by the -worker subprocess entrypoint, not by `MaaFWRunnerService`. diff --git a/plugins/automas_maafw_runner/pyproject.toml b/plugins/automas_maafw_runner/pyproject.toml deleted file mode 100644 index cf442c5f..00000000 --- a/plugins/automas_maafw_runner/pyproject.toml +++ /dev/null @@ -1,25 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "automas-maafw-runner" -version = "0.1.1" -description = "Isolated MaaFW runner service plugin for AUTO-MAS" -readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" -dependencies = [ - "automas-maafw-agent-env>=0.1.0", - "automas-maafw-interface>=0.1.1", - "json5>=0.9", - "pydantic>=2", -] - -[project.entry-points."auto_mas.plugins"] -automas_maafw_runner = "automas_maafw_runner.plugin:Plugin" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -automas_maafw_runner = ["py.typed"] diff --git a/plugins/automas_maafw_runner/src/automas_maafw_runner/__init__.py b/plugins/automas_maafw_runner/src/automas_maafw_runner/__init__.py deleted file mode 100644 index 2c041cf3..00000000 --- a/plugins/automas_maafw_runner/src/automas_maafw_runner/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from .environment import MaaFWRunnerEnvironment -from .models import ( - MaaFWDeviceConfig, - MaaFWResolvedPath, - MaaFWResourceBundlePlan, - MaaFWRunnerJobPayload, - MaaFWRunPlan, - MaaFWRunResult, - MaaFWSkippedTaskPlan, - MaaFWTaskRunPlan, -) -from .run_plan import MaaFWRunPlanError, build_maafw_run_plan -from .service import MaaFWRunnerService - -__all__ = [ - "MaaFWDeviceConfig", - "MaaFWResolvedPath", - "MaaFWResourceBundlePlan", - "MaaFWRunPlan", - "MaaFWRunPlanError", - "MaaFWRunResult", - "MaaFWRunnerEnvironment", - "MaaFWRunnerJobPayload", - "MaaFWRunnerService", - "MaaFWSkippedTaskPlan", - "MaaFWTaskRunPlan", - "build_maafw_run_plan", -] diff --git a/plugins/automas_maafw_runner/src/automas_maafw_runner/environment.py b/plugins/automas_maafw_runner/src/automas_maafw_runner/environment.py deleted file mode 100644 index 88c76140..00000000 --- a/plugins/automas_maafw_runner/src/automas_maafw_runner/environment.py +++ /dev/null @@ -1,353 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -import re -import shutil -import subprocess -import sys -import sysconfig -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Iterable - - -RUNNER_ENV_MANIFEST_NAME = ".auto_mas_maafw_runner_env.json" -RUNNER_DEFAULT_PACKAGES = ( - "maafw", - "pydantic==2.11.7", - "json5==0.14.0", -) -RUNNER_ENV_TIMEOUT = 300 -REQUIREMENT_NAME_RE = re.compile( - r"^\s*([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)" - r"\s*(?:\[[^\]]+\])?\s*(?:===|[<>=!~]=?|@|;|\s|$)" -) - - -@dataclass(frozen=True) -class MaaFWRunnerEnvironment: - python_executable: Path - venv_path: Path - env: dict[str, str] - packages: tuple[str, ...] - maafw_version: str | None - - -def prepare_runner_environment( - project_path: str | Path, - *, - managed_env_root: str | Path | None = None, - import_paths: Iterable[str | Path] = (), - send_log: Callable[[str], None] | None = None, -) -> MaaFWRunnerEnvironment: - """Prepare a project-scoped runner whose dependencies follow the project.""" - - project = Path(project_path).resolve() - root = ( - Path(managed_env_root).resolve() - if managed_env_root is not None - else (Path.cwd() / "config" / "maafw_runner_venvs").resolve() - ) - venv_path = root / _runner_env_name(project) - if venv_path.exists() and not _is_valid_venv(venv_path): - _reset_managed_venv(venv_path, root) - - if not _is_valid_venv(venv_path): - venv_path.parent.mkdir(parents=True, exist_ok=True) - bootstrap_python = _venv_bootstrap_python() - _send_log( - send_log, - f"[MaaFW Runner] 创建项目隔离 venv: {venv_path} " - f"(引导 Python: {bootstrap_python})", - ) - _run_setup_command( - [bootstrap_python, "-m", "venv", str(venv_path)], - cwd=Path.cwd(), - ) - - python_executable = _venv_python(venv_path) - packages = tuple(build_runner_packages(project)) - manifest = _build_manifest(project, packages) - manifest_path = venv_path / RUNNER_ENV_MANIFEST_NAME - env = build_runner_environment(venv_path, import_paths=import_paths) - if not _manifest_matches(manifest_path, manifest): - _send_log( - send_log, - f"[MaaFW Runner] 安装项目运行依赖: {', '.join(packages)}", - ) - _run_setup_command( - [ - str(python_executable), - "-m", - "pip", - "install", - "--upgrade", - "--disable-pip-version-check", - "--quiet", - *packages, - ], - cwd=project, - env=env, - ) - _write_manifest(manifest_path, manifest) - _send_log(send_log, f"[MaaFW Runner] 项目运行依赖已准备: {venv_path}") - else: - _send_log(send_log, f"[MaaFW Runner] 项目隔离 venv 已就绪: {venv_path}") - - maafw_version = _installed_maafw_version(python_executable, env) - if maafw_version: - _send_log(send_log, f"[MaaFW Runner] 使用项目 MaaFW: v{maafw_version}") - - return MaaFWRunnerEnvironment( - python_executable=python_executable, - venv_path=venv_path, - env=env, - packages=packages, - maafw_version=maafw_version, - ) - - -def build_runner_packages(project_path: str | Path) -> list[str]: - project_packages = _load_requirements(Path(project_path).resolve()) - project_distribution_names = { - name - for requirement in project_packages - if (name := requirement_distribution_name(requirement)) is not None - } - packages = [ - package - for package in RUNNER_DEFAULT_PACKAGES - if requirement_distribution_name(package) not in project_distribution_names - ] - packages.extend(project_packages) - return packages - - -def requirement_distribution_name(requirement: str) -> str | None: - match = REQUIREMENT_NAME_RE.match(requirement) - if match is None: - return None - return re.sub(r"[-_.]+", "-", match.group(1)).lower() - - -def build_runner_environment( - venv_path: str | Path, - *, - import_paths: Iterable[str | Path] = (), -) -> dict[str, str]: - env = os.environ.copy() - for name in ( - "PYTHONHOME", - "PYTHONUSERBASE", - "PIP_TARGET", - "PIP_PREFIX", - "PIP_USER", - ): - env.pop(name, None) - - venv = Path(venv_path).resolve() - scripts_dir = venv / ("Scripts" if os.name == "nt" else "bin") - resolved_import_paths = [ - str(Path(path).resolve()) - for path in import_paths - if Path(path).exists() - ] - existing_python_path = env.get("PYTHONPATH", "") - if existing_python_path: - resolved_import_paths.append(existing_python_path) - - env["VIRTUAL_ENV"] = str(venv) - env["PYTHONNOUSERSITE"] = "1" - env["PYTHONIOENCODING"] = "utf-8" - env["PATH"] = f"{scripts_dir}{os.pathsep}{env.get('PATH', '')}" - if resolved_import_paths: - env["PYTHONPATH"] = os.pathsep.join(resolved_import_paths) - else: - env.pop("PYTHONPATH", None) - return env - - -def prefer_active_venv_site_packages( - site_packages: str | Path | None = None, -) -> Path | None: - """Keep the project Runner packages ahead of shared plugin dependencies.""" - - raw_path = site_packages or sysconfig.get_path("purelib") - if not raw_path: - return None - - active_site_packages = Path(raw_path).resolve() - normalized_path = str(active_site_packages) - sys.path[:] = [ - item - for item in sys.path - if _normalized_sys_path(item) != normalized_path - ] - sys.path.insert(0, normalized_path) - return active_site_packages - - -def _load_requirements(project_path: Path) -> list[str]: - requirements_path = project_path / "requirements.txt" - packages: list[str] = [] - try: - for raw_line in requirements_path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - packages.append(line) - except FileNotFoundError: - pass - return packages - - -def _runner_env_name(project_path: Path) -> str: - key = str(project_path) - if os.name == "nt": - key = key.casefold() - digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16] - return f"maafw_runner_{digest}" - - -def _build_manifest(project_path: Path, packages: tuple[str, ...]) -> dict[str, object]: - requirements_path = project_path / "requirements.txt" - interface_path = next( - ( - project_path / file_name - for file_name in ("interface.json", "interface.jsonc") - if (project_path / file_name).is_file() - ), - None, - ) - requirements_hash = ( - hashlib.sha256(requirements_path.read_bytes()).hexdigest() - if requirements_path.is_file() - else "" - ) - interface_hash = ( - hashlib.sha256(interface_path.read_bytes()).hexdigest() - if interface_path is not None - else "" - ) - return { - "schemaVersion": 4, - "projectPath": str(project_path), - "requirementsHash": requirements_hash, - "interfaceHash": interface_hash, - "packages": list(packages), - "pythonVersion": f"{sys.version_info.major}.{sys.version_info.minor}", - } - - -def _manifest_matches(manifest_path: Path, expected: dict[str, object]) -> bool: - try: - current = json.loads(manifest_path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return False - return current == expected - - -def _write_manifest(manifest_path: Path, manifest: dict[str, object]) -> None: - temporary_path = manifest_path.with_suffix(f"{manifest_path.suffix}.tmp") - temporary_path.write_text( - json.dumps(manifest, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - temporary_path.replace(manifest_path) - - -def _run_setup_command( - command: list[str], - *, - cwd: Path, - env: dict[str, str] | None = None, -) -> None: - try: - result = subprocess.run( - command, - capture_output=True, - timeout=RUNNER_ENV_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - cwd=cwd, - env=env, - ) - except subprocess.TimeoutExpired as exc: - raise RuntimeError(f"MaaFW Runner 环境准备超时: {command[:3]}") from exc - - if result.returncode == 0: - return - detail = (result.stderr or result.stdout or "").strip() - raise RuntimeError( - f"MaaFW Runner 环境准备失败 (exit={result.returncode}): {detail[:800]}" - ) - - -def _installed_maafw_version( - python_executable: Path, - env: dict[str, str], -) -> str | None: - probe_env = env.copy() - probe_env.pop("PYTHONPATH", None) - try: - result = subprocess.run( - [ - str(python_executable), - "-c", - "import importlib.metadata as m; print(m.version('maafw'))", - ], - capture_output=True, - timeout=15, - text=True, - encoding="utf-8", - errors="replace", - env=probe_env, - ) - except (OSError, subprocess.TimeoutExpired): - return None - if result.returncode != 0: - return None - version = result.stdout.strip() - return version or None - - -def _normalized_sys_path(path: str) -> str: - try: - return str(Path(path).resolve()) - except (OSError, RuntimeError): - return path - - -def _reset_managed_venv(venv_path: Path, managed_root: Path) -> None: - resolved_venv = venv_path.resolve() - if ( - resolved_venv.parent != managed_root.resolve() - or not resolved_venv.name.startswith("maafw_runner_") - ): - raise RuntimeError(f"拒绝重建非托管 MaaFW Runner venv: {venv_path}") - shutil.rmtree(resolved_venv, ignore_errors=True) - - -def _venv_python(venv_path: Path) -> Path: - if os.name == "nt": - return venv_path / "Scripts" / "python.exe" - return venv_path / "bin" / "python" - - -def _is_valid_venv(venv_path: Path) -> bool: - return _venv_python(venv_path).is_file() and (venv_path / "pyvenv.cfg").is_file() - - -def _venv_bootstrap_python() -> str: - portable_python = Path.cwd() / "environment" / "python" / "python.exe" - if portable_python.is_file(): - return str(portable_python) - return sys.executable - - -def _send_log(send_log: Callable[[str], None] | None, message: str) -> None: - if send_log is not None: - send_log(message) diff --git a/plugins/automas_maafw_runner/src/automas_maafw_runner/models.py b/plugins/automas_maafw_runner/src/automas_maafw_runner/models.py deleted file mode 100644 index a458b9f4..00000000 --- a/plugins/automas_maafw_runner/src/automas_maafw_runner/models.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -from typing import Any, Literal - -from automas_maafw_agent_env.models import MaaFWAgentCommandPlan -from pydantic import BaseModel, ConfigDict, Field - - -MaaFWControllerType = Literal["Adb", "Win32"] - - -class MaaFWResolvedPath(BaseModel): - raw: str - resolved: str - exists: bool - isFile: bool = False - isDir: bool = False - - -class MaaFWResourceBundlePlan(BaseModel): - name: str - label: str | None = None - paths: list[MaaFWResolvedPath] = Field(default_factory=list) - attachedPaths: list[MaaFWResolvedPath] = Field(default_factory=list) - - -class MaaFWTaskRunPlan(BaseModel): - name: str - label: str | None = None - entry: str - options: dict[str, Any] = Field(default_factory=dict) - pipelineOverride: dict[str, Any] = Field(default_factory=dict) - logOptions: dict[str, Any] = Field(default_factory=dict) - overrideNodes: list[str] = Field(default_factory=list) - - -class MaaFWSkippedTaskPlan(BaseModel): - name: str - label: str | None = None - entry: str | None = None - reason: str - - -class MaaFWPretaskRunPlan(BaseModel): - name: str - label: str | None = None - executable: str - args: list[str] = Field(default_factory=list) - options: dict[str, Any] = Field(default_factory=dict) - - -class MaaFWRunPlan(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - - path: str - projectName: str - projectLabel: str | None = None - controllerName: str - controllerType: str - resourceName: str - resource: MaaFWResourceBundlePlan - agents: list[MaaFWAgentCommandPlan] = Field(default_factory=list) - pretasks: list[MaaFWPretaskRunPlan] = Field(default_factory=list) - piEnv: dict[str, str] = Field(default_factory=dict) - tasks: list[MaaFWTaskRunPlan] = Field(default_factory=list) - skippedTasks: list[MaaFWSkippedTaskPlan] = Field(default_factory=list) - - -class MaaFWDeviceConfig(BaseModel): - type: MaaFWControllerType - adbPath: str | None = None - address: str | None = None - hWnd: int | None = None - screencapMethods: int = 0 - inputMethods: int = 0 - screencapMethod: int = 0 - mouseMethod: int = 0 - keyboardMethod: int = 0 - config: dict[str, Any] = Field(default_factory=dict) - - -class MaaFWRunResult(BaseModel): - success: bool - projectName: str - controllerName: str - resourceName: str - completedTasks: list[str] = Field(default_factory=list) - failedTask: str | None = None - errorMessage: str | None = None - - -class MaaFWRunnerJobPayload(BaseModel): - plan: MaaFWRunPlan - deviceConfig: MaaFWDeviceConfig diff --git a/plugins/automas_maafw_runner/src/automas_maafw_runner/pipeline_override.py b/plugins/automas_maafw_runner/src/automas_maafw_runner/pipeline_override.py deleted file mode 100644 index 62ecc19f..00000000 --- a/plugins/automas_maafw_runner/src/automas_maafw_runner/pipeline_override.py +++ /dev/null @@ -1,335 +0,0 @@ -from __future__ import annotations - -import copy -from typing import Any, cast - -import json5 -from automas_maafw_interface.models import ( - SUPPORTED_OPTION_TYPES, - MaaFWController, - MaaFWInterface, - MaaFWOption, - MaaFWPipelineOverride, - MaaFWResource, - MaaFWTask, - MaaFWTaskOptionValue, -) - - -def deep_merge_pipeline_override( - base: MaaFWPipelineOverride | None, - override: MaaFWPipelineOverride | None, -) -> MaaFWPipelineOverride: - merged: MaaFWPipelineOverride = copy.deepcopy(base) if base else {} - if not override: - return merged - - for key, value in override.items(): - existing = merged.get(key) - if isinstance(existing, dict) and isinstance(value, dict): - merged[key] = deep_merge_pipeline_override(existing, value) - else: - merged[key] = copy.deepcopy(value) - return merged - - -class MaaFWPipelineOverrideBuilder: - def __init__( - self, - interface_model: MaaFWInterface, - *, - controller_names: set[str], - resource_name: str | None, - ) -> None: - self.interface_model = interface_model - self.controller_names = controller_names - self.resource_name = resource_name - - def build_task_pipeline_override( - self, - task_name: str, - options: dict[str, MaaFWTaskOptionValue], - ) -> MaaFWPipelineOverride: - task_definition = self._get_task_definition(task_name) - if task_definition is None: - return {} - - resource_definition = self._get_resource_definition() - controller_option_names: list[str] = [] - for controller in self._get_active_controller_definitions(): - if controller.option: - controller_option_names.extend(controller.option) - - merged = copy.deepcopy(task_definition.pipeline_override) or {} - option_groups = [ - self.interface_model.global_option or [], - resource_definition.option if resource_definition and resource_definition.option else [], - controller_option_names, - task_definition.option or [], - ] - for option_names in option_groups: - merged = deep_merge_pipeline_override( - merged, - self._build_option_group_override(option_names, options), - ) - return merged - - def _get_task_definition(self, task_name: str) -> MaaFWTask | None: - return next((task for task in self.interface_model.task if task.name == task_name), None) - - def _get_resource_definition(self) -> MaaFWResource | None: - if self.resource_name is None: - return None - return next( - (resource for resource in self.interface_model.resource if resource.name == self.resource_name), - None, - ) - - def _get_active_controller_definitions(self) -> list[MaaFWController]: - return [ - controller - for controller in self.interface_model.controller - if controller.name in self.controller_names - ] - - def _is_option_active_for_context(self, option: MaaFWOption) -> bool: - if option.controller and not self.controller_names.intersection(option.controller): - return False - if option.resource and (self.resource_name is None or self.resource_name not in option.resource): - return False - return True - - def _normalize_choice_value( - self, - option_name: str, - option: MaaFWOption, - options: dict[str, MaaFWTaskOptionValue], - ) -> str: - case_names = [case.name for case in option.cases or []] - default_value = option.default_case if isinstance(option.default_case, str) else "" - if default_value not in case_names: - default_value = case_names[0] if case_names else "" - - raw_value = options.get(option_name) - if isinstance(raw_value, str) and raw_value in case_names: - return raw_value - return default_value - - def _normalize_checkbox_values( - self, - option_name: str, - option: MaaFWOption, - options: dict[str, MaaFWTaskOptionValue], - ) -> list[str]: - case_order = [case.name for case in option.cases or []] - default_values = option.default_case if isinstance(option.default_case, list) else [] - raw_value = options.get(option_name) - - if raw_value is None: - selected_values = [value for value in default_values if isinstance(value, str)] - elif isinstance(raw_value, list): - selected_values = [value for value in raw_value if isinstance(value, str)] - elif isinstance(raw_value, str): - try: - parsed_value = json5.loads(raw_value) - except Exception: - parsed_value = [raw_value] if raw_value in case_order else [] - selected_values = ( - [value for value in parsed_value if isinstance(value, str)] - if isinstance(parsed_value, list) - else [] - ) - else: - selected_values = [] - - selected_set = set(selected_values) - return [case_name for case_name in case_order if case_name in selected_set] - - def _coerce_input_value(self, raw_value: str, pipeline_type: str | None) -> tuple[object, str]: - normalized_type = (pipeline_type or "string").lower() - if normalized_type in {"bool", "boolean"}: - typed_value = raw_value.lower() in {"true", "1", "yes", "y", "on"} - return typed_value, "true" if typed_value else "false" - if normalized_type in {"int", "integer"}: - typed_value = int(raw_value) - return typed_value, str(typed_value) - if normalized_type in {"float", "double", "number"}: - typed_value = float(raw_value) - return typed_value, str(typed_value) - return raw_value, raw_value - - def _substitute_placeholders( - self, - value: Any, - typed_replacements: dict[str, object], - text_replacements: dict[str, str], - ) -> Any: - if isinstance(value, dict): - return { - key: self._substitute_placeholders(nested_value, typed_replacements, text_replacements) - for key, nested_value in value.items() - } - if isinstance(value, list): - return [ - self._substitute_placeholders(item, typed_replacements, text_replacements) - for item in value - ] - if isinstance(value, str): - if value in typed_replacements: - return copy.deepcopy(typed_replacements[value]) - substituted = value - for placeholder, replacement in text_replacements.items(): - substituted = substituted.replace(placeholder, replacement) - return substituted - return copy.deepcopy(value) - - def _assign_scan_select_attach_value( - self, - value: Any, - option_name: str, - selected_value: str, - ) -> Any: - if isinstance(value, dict): - copied = { - key: self._assign_scan_select_attach_value(nested_value, option_name, selected_value) - for key, nested_value in value.items() - } - attach_value = copied.get("attach") - if isinstance(attach_value, dict) and option_name in attach_value: - updated_attach = copy.deepcopy(attach_value) - updated_attach[option_name] = selected_value - copied["attach"] = updated_attach - return copied - if isinstance(value, list): - return [ - self._assign_scan_select_attach_value(item, option_name, selected_value) - for item in value - ] - return copy.deepcopy(value) - - def _build_input_override( - self, - option_name: str, - option: MaaFWOption, - options: dict[str, MaaFWTaskOptionValue], - ) -> MaaFWPipelineOverride: - if not option.pipeline_override or not option.inputs: - return {} - - typed_replacements: dict[str, object] = {} - text_replacements: dict[str, str] = {} - for input_item in option.inputs: - raw_option_value = options.get(option_name) - raw_text = input_item.default or "" - if isinstance(raw_option_value, dict): - field_value = raw_option_value.get(input_item.name) - if isinstance(field_value, str): - raw_text = field_value - elif isinstance(raw_option_value, str): - raw_text = raw_option_value - elif isinstance(raw_option_value, list): - raw_text = raw_option_value[0] if raw_option_value else "" - - typed_value, text_value = self._coerce_input_value(raw_text, input_item.pipeline_type) - placeholder = f"{{{input_item.name}}}" - typed_replacements[placeholder] = typed_value - text_replacements[placeholder] = text_value - - return cast( - MaaFWPipelineOverride, - self._substitute_placeholders( - option.pipeline_override, - typed_replacements, - text_replacements, - ), - ) - - def _build_scan_select_override( - self, - option_name: str, - option: MaaFWOption, - options: dict[str, MaaFWTaskOptionValue], - ) -> MaaFWPipelineOverride: - if not option.pipeline_override: - return {} - if option.cases is None: - return copy.deepcopy(option.pipeline_override) - selected_value = self._normalize_choice_value(option_name, option, options) - return cast( - MaaFWPipelineOverride, - self._assign_scan_select_attach_value(option.pipeline_override, option_name, selected_value), - ) - - def _build_option_override( - self, - option_name: str, - options: dict[str, MaaFWTaskOptionValue], - lineage: set[str] | None = None, - ) -> MaaFWPipelineOverride: - option = self.interface_model.option.get(option_name) - if ( - option is None - or option.type not in SUPPORTED_OPTION_TYPES - or not self._is_option_active_for_context(option) - ): - return {} - - lineage = lineage or set() - if option_name in lineage: - return {} - next_lineage = {*lineage, option_name} - - merged: MaaFWPipelineOverride = {} - if option.type == "input": - return deep_merge_pipeline_override( - merged, - self._build_input_override(option_name, option, options), - ) - - if option.type == "scan_select": - merged = deep_merge_pipeline_override( - merged, - self._build_scan_select_override(option_name, option, options), - ) - elif option.pipeline_override: - merged = deep_merge_pipeline_override(merged, option.pipeline_override) - - if option.type in {"select", "switch"} and option.cases: - active_case_name = self._normalize_choice_value(option_name, option, options) - active_case = next((case for case in option.cases if case.name == active_case_name), None) - if active_case and active_case.pipeline_override: - merged = deep_merge_pipeline_override(merged, active_case.pipeline_override) - if active_case and active_case.option: - merged = deep_merge_pipeline_override( - merged, - self._build_option_group_override(active_case.option, options, next_lineage), - ) - return merged - - if option.type == "checkbox" and option.cases: - selected_case_names = set(self._normalize_checkbox_values(option_name, option, options)) - for case in option.cases: - if case.name not in selected_case_names: - continue - if case.pipeline_override: - merged = deep_merge_pipeline_override(merged, case.pipeline_override) - if case.option: - merged = deep_merge_pipeline_override( - merged, - self._build_option_group_override(case.option, options, next_lineage), - ) - return merged - - def _build_option_group_override( - self, - option_names: list[str], - options: dict[str, MaaFWTaskOptionValue], - lineage: set[str] | None = None, - ) -> MaaFWPipelineOverride: - merged: MaaFWPipelineOverride = {} - for option_name in option_names: - merged = deep_merge_pipeline_override( - merged, - self._build_option_override(option_name, options, lineage), - ) - return merged diff --git a/plugins/automas_maafw_runner/src/automas_maafw_runner/plugin.py b/plugins/automas_maafw_runner/src/automas_maafw_runner/plugin.py deleted file mode 100644 index e8b1e300..00000000 --- a/plugins/automas_maafw_runner/src/automas_maafw_runner/plugin.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from .service import MaaFWRunnerService - -if TYPE_CHECKING: - from auto_mas_core import PluginContext - - -DEFAULT_INSTANCE = { - "name": "MaaFW Runner", - "enabled": True, - "config": {}, -} - -schema = { - "__no_plugin_config__": { - "type": "boolean", - "default": True, - "hidden": True, - "configurable": False, - "title": "No plugin-level configuration", - }, -} - - -class Plugin: - provides = ["maafw.runner.v1"] - - def __init__(self, ctx: "PluginContext") -> None: - self.ctx = ctx - self.service = MaaFWRunnerService() - - async def on_start(self) -> None: - self.ctx.set("maafw.runner.v1", self.service) - self.ctx.logger.info("maafw.runner.v1 ready") - - async def on_stop(self, reason: str) -> None: - self.ctx.logger.info(f"maafw.runner.v1 stopped, reason={reason}") diff --git a/plugins/automas_maafw_runner/src/automas_maafw_runner/py.typed b/plugins/automas_maafw_runner/src/automas_maafw_runner/py.typed deleted file mode 100644 index 8b137891..00000000 --- a/plugins/automas_maafw_runner/src/automas_maafw_runner/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/automas_maafw_runner/src/automas_maafw_runner/run_plan.py b/plugins/automas_maafw_runner/src/automas_maafw_runner/run_plan.py deleted file mode 100644 index 0dbb7d81..00000000 --- a/plugins/automas_maafw_runner/src/automas_maafw_runner/run_plan.py +++ /dev/null @@ -1,650 +0,0 @@ -from __future__ import annotations - -import copy -import json -from importlib import metadata -from pathlib import Path -from typing import Any - -import json5 -from automas_maafw_agent_env import build_maafw_agent_command_plans -from automas_maafw_interface.models import ( - SUPPORTED_OPTION_TYPES, - MaaFWController, - MaaFWInterface, - MaaFWOption, - MaaFWPretask, - MaaFWResource, - MaaFWTask, - MaaFWTaskOptionValue, - MaaFWTaskOptionsByTask, - find_pretask_by_task_name, - is_pretask_task_name, -) -from automas_maafw_interface.task_config import ( - MaaFWTaskPresetSnapshot, - build_interface_preset_snapshot, - normalize_snapshot, - normalize_task_execution_payload, -) - -from .models import ( - MaaFWResolvedPath, - MaaFWPretaskRunPlan, - MaaFWResourceBundlePlan, - MaaFWRunPlan, - MaaFWSkippedTaskPlan, - MaaFWTaskRunPlan, -) -from .pipeline_override import MaaFWPipelineOverrideBuilder - - -PI_INTERFACE_VERSION = "v2.8.1" -PI_CLIENT_LANGUAGE = "zh_cn" -PI_CLIENT_NAME = "AUTO-MAS" -MAAFW_DIRECT_CONTROLLER_TYPES = {"Adb", "Win32"} -SENSITIVE_CONFIG_KEYWORDS = ( - "account", - "credential", - "email", - "password", - "passwd", - "phone", - "pwd", - "secret", - "token", - "username", - "令牌", - "口令", - "密钥", - "密码", - "手机号", - "邮箱", - "账户", - "账号", - "用户名", - "凭据", -) - - -class MaaFWRunPlanError(ValueError): - """Raised when a MaaFW project cannot be converted into a runnable plan.""" - - -def build_maafw_run_plan( - base_dir: str | Path, - interface_model: MaaFWInterface | dict[str, Any], - *, - controller_name: str | None = None, - resource_name: str | None = None, - selected_preset: str | None = None, - task_snapshot: MaaFWTaskPresetSnapshot | dict[str, Any] | None = None, - task_names: list[str] | None = None, - task_options: dict[str, Any] | None = None, - managed_env_root: str | Path | None = None, -) -> MaaFWRunPlan: - interface = _coerce_interface(interface_model) - resolved_base_dir = Path(base_dir).resolve() - controller = _select_controller(interface, controller_name) - resource = _select_resource(interface, resource_name, controller) - selected_task_names, selected_task_options = _select_tasks( - interface, - controller_name=controller.name, - resource_name=resource.name, - selected_preset=selected_preset, - task_snapshot=task_snapshot, - task_names=task_names, - task_options=task_options, - ) - selected_pretask_names = [ - task_name for task_name in selected_task_names if is_pretask_task_name(task_name) - ] - selected_common_task_names = [ - task_name for task_name in selected_task_names if not is_pretask_task_name(task_name) - ] - task_map = {task.name: task for task in interface.task} - controller_names = {controller.name} - pipeline_builder = MaaFWPipelineOverrideBuilder( - interface, - controller_names=controller_names, - resource_name=resource.name, - ) - - runnable_tasks: list[MaaFWTaskRunPlan] = [] - skipped_tasks: list[MaaFWSkippedTaskPlan] = [] - for task_name in selected_common_task_names: - task = task_map.get(task_name) - if task is None: - skipped_tasks.append(MaaFWSkippedTaskPlan(name=task_name, reason="任务不存在")) - continue - - compatible, reason = _check_task_compatible( - task, - controller_names=controller_names, - resource_name=resource.name, - ) - if not compatible: - skipped_tasks.append( - MaaFWSkippedTaskPlan( - name=task.name, - label=task.label, - entry=task.entry, - reason=reason, - ) - ) - continue - - options = selected_task_options.get(task.name, {}) - pipeline_override = pipeline_builder.build_task_pipeline_override( - task.name, - options, - ) - runnable_tasks.append( - MaaFWTaskRunPlan( - name=task.name, - label=task.label, - entry=task.entry, - options=options, - pipelineOverride=pipeline_override, - logOptions=_build_task_log_options(interface, options), - overrideNodes=list(pipeline_override), - ) - ) - - if not runnable_tasks: - raise MaaFWRunPlanError("当前 controller/resource 下没有可执行任务") - - return MaaFWRunPlan( - path=str(resolved_base_dir), - projectName=interface.name, - projectLabel=interface.label, - controllerName=controller.name, - controllerType=controller.type, - resourceName=resource.name, - resource=_build_resource_bundle_plan(resolved_base_dir, resource, controller), - agents=build_maafw_agent_command_plans( - resolved_base_dir, - interface.agent, - managed_env_root=managed_env_root, - ), - pretasks=_build_pretask_plans( - resolved_base_dir, - interface, - controller, - resource, - selected_pretask_names, - selected_task_options, - ), - piEnv=_build_pi_env(resolved_base_dir, interface, controller, resource), - tasks=runnable_tasks, - skippedTasks=skipped_tasks, - ) - - -def _coerce_interface(interface_model: MaaFWInterface | dict[str, Any]) -> MaaFWInterface: - if isinstance(interface_model, MaaFWInterface): - return interface_model - if hasattr(interface_model, "model_dump"): - return MaaFWInterface.model_validate(interface_model.model_dump(mode="json", by_alias=True)) - return MaaFWInterface.model_validate(interface_model) - - -def _select_controller( - interface_model: MaaFWInterface, - controller_name: str | None, -) -> MaaFWController: - if controller_name: - controller = next((item for item in interface_model.controller if item.name == controller_name), None) - if controller is None: - raise MaaFWRunPlanError(f"未找到 controller: {controller_name}") - _ensure_direct_controller(controller) - return controller - - if not interface_model.controller: - raise MaaFWRunPlanError("interface 未声明 controller") - controller = next( - (item for item in interface_model.controller if item.type in MAAFW_DIRECT_CONTROLLER_TYPES), - None, - ) - if controller is None: - declared_types = ", ".join(f"{item.name}({item.type})" for item in interface_model.controller) - raise MaaFWRunPlanError( - "AUTO-MAS MaaFW Direct currently supports only Adb/Win32 " - f"controllers; use the project UI for: {declared_types}" - ) - return controller - - -def _ensure_direct_controller(controller: MaaFWController) -> None: - if controller.type in MAAFW_DIRECT_CONTROLLER_TYPES: - return - raise MaaFWRunPlanError( - "AUTO-MAS MaaFW Direct currently supports only Adb/Win32 " - f"controllers; use the project UI for {controller.name}({controller.type})" - ) - - -def _select_resource( - interface_model: MaaFWInterface, - resource_name: str | None, - controller: MaaFWController, -) -> MaaFWResource: - if resource_name: - resource = next((item for item in interface_model.resource if item.name == resource_name), None) - if resource is None: - raise MaaFWRunPlanError(f"未找到 resource: {resource_name}") - if resource.controller and controller.name not in resource.controller: - raise MaaFWRunPlanError( - f"resource {resource.name} 不支持 controller {controller.name}" - ) - return resource - - for resource in interface_model.resource: - if not resource.controller or controller.name in resource.controller: - return resource - - if not interface_model.resource: - raise MaaFWRunPlanError("interface 未声明 resource") - raise MaaFWRunPlanError(f"没有适用于 controller {controller.name} 的 resource") - - -def _select_tasks( - interface_model: MaaFWInterface, - *, - controller_name: str, - resource_name: str, - selected_preset: str | None, - task_snapshot: MaaFWTaskPresetSnapshot | dict[str, Any] | None, - task_names: list[str] | None, - task_options: dict[str, Any] | None, -) -> tuple[list[str], MaaFWTaskOptionsByTask]: - if task_names is not None: - selected_names, selected_options = normalize_task_execution_payload( - task_names, - task_options, - interface_model, - controller_name=controller_name, - resource_name=resource_name, - ) - return selected_names, selected_options - - snapshot = _resolve_snapshot( - interface_model, - selected_preset=selected_preset, - task_snapshot=task_snapshot, - ) - selected_names = [ - task_name - for task_name in snapshot.taskOrder - if snapshot.taskChecked.get(task_name, False) - ] - selected_names, selected_options = normalize_task_execution_payload( - selected_names, - snapshot.taskOptions, - interface_model, - controller_name=controller_name, - resource_name=resource_name, - ) - return selected_names, selected_options - - -def _resolve_snapshot( - interface_model: MaaFWInterface, - *, - selected_preset: str | None, - task_snapshot: MaaFWTaskPresetSnapshot | dict[str, Any] | None, -) -> MaaFWTaskPresetSnapshot: - if task_snapshot is not None: - return normalize_snapshot(task_snapshot, interface_model) - - if selected_preset: - preset = next((item for item in interface_model.preset if item.name == selected_preset), None) - if preset is None: - raise MaaFWRunPlanError(f"未找到 preset: {selected_preset}") - return normalize_snapshot( - build_interface_preset_snapshot(interface_model, preset), - interface_model, - ) - - if interface_model.preset: - return normalize_snapshot( - build_interface_preset_snapshot(interface_model, interface_model.preset[0]), - interface_model, - ) - - return normalize_snapshot( - { - "taskOrder": [task.name for task in interface_model.task], - "taskChecked": {task.name: bool(task.default_check) for task in interface_model.task}, - "taskOptions": {}, - }, - interface_model, - ) - - -def _check_task_compatible( - task: MaaFWTask, - *, - controller_names: set[str], - resource_name: str, -) -> tuple[bool, str]: - if task.controller and not controller_names.intersection(task.controller): - return False, f"当前控制器不受支持,支持: {', '.join(task.controller)}" - if task.resource and resource_name not in task.resource: - return False, f"当前资源不受支持,支持: {', '.join(task.resource)}" - return True, "" - - -def _build_pretask_plans( - base_dir: Path, - interface_model: MaaFWInterface, - controller: MaaFWController, - resource: MaaFWResource, - selected_names: list[str], - task_options: MaaFWTaskOptionsByTask, -) -> list[MaaFWPretaskRunPlan]: - plans: list[MaaFWPretaskRunPlan] = [] - for task_name in selected_names: - pretask = find_pretask_by_task_name(interface_model, task_name) - if pretask is None: - continue - if pretask.controller and controller.name not in pretask.controller: - continue - if pretask.resource and resource.name not in pretask.resource: - continue - - serialized_options = _collect_pretask_option_values( - pretask, - interface_model, - task_options.get(task_name, {}), - controller_name=controller.name, - resource_name=resource.name, - ) - args = list(pretask.args or []) - if pretask.option: - args.append( - json.dumps( - serialized_options, - ensure_ascii=False, - separators=(",", ":"), - ) - ) - plans.append( - MaaFWPretaskRunPlan( - name=task_name, - label=pretask.label, - executable=_resolve_pretask_executable(base_dir, pretask.exec), - args=args, - options=serialized_options, - ) - ) - return plans - - -def _collect_pretask_option_values( - pretask: MaaFWPretask, - interface_model: MaaFWInterface, - option_values: dict[str, MaaFWTaskOptionValue], - *, - controller_name: str, - resource_name: str, -) -> dict[str, MaaFWTaskOptionValue]: - result: dict[str, MaaFWTaskOptionValue] = {} - - def collect(option_name: str, lineage: set[str]) -> None: - if option_name in lineage or option_name in result: - return - option = interface_model.option.get(option_name) - if ( - option is None - or option.type not in SUPPORTED_OPTION_TYPES - or not _is_option_compatible( - option, - controller_name=controller_name, - resource_name=resource_name, - ) - ): - return - value = option_values.get(option_name) - if value is None: - return - result[option_name] = copy.deepcopy(value) - - next_lineage = {*lineage, option_name} - if option.type in {"select", "scan_select", "switch"} and isinstance(value, str): - active_case = next((case for case in option.cases or [] if case.name == value), None) - for nested_name in active_case.option if active_case and active_case.option else []: - collect(nested_name, next_lineage) - elif option.type == "checkbox" and isinstance(value, list): - selected_names = set(value) - for case in option.cases or []: - if case.name not in selected_names: - continue - for nested_name in case.option or []: - collect(nested_name, next_lineage) - - for option_name in pretask.option or []: - collect(option_name, set()) - return result - - -def _is_option_compatible( - option: MaaFWOption, - *, - controller_name: str, - resource_name: str, -) -> bool: - if option.controller and controller_name not in option.controller: - return False - if option.resource and resource_name not in option.resource: - return False - return True - - -def _resolve_pretask_executable(base_dir: Path, raw_exec: str) -> str: - resolved = _resolve_project_path(base_dir, raw_exec) - candidate = Path(resolved.resolved) - if not candidate.is_file() and candidate.suffix == "": - windows_candidate = candidate.with_suffix(".exe") - if _is_within_base_dir(windows_candidate, base_dir) and windows_candidate.is_file(): - candidate = windows_candidate - if not candidate.is_file(): - raise MaaFWRunPlanError(f"pretask 可执行文件不存在: {raw_exec}") - return str(candidate) - - -def _build_task_log_options( - interface_model: MaaFWInterface, - options: dict[str, Any], -) -> dict[str, Any]: - safe_options: dict[str, Any] = {} - for option_name, value in options.items(): - option = interface_model.option.get(option_name) - safe_options[option_name] = _sanitize_config_log_value( - value, - redact_all=bool(option and option.type == "input"), - key=option_name, - ) - return safe_options - - -def _sanitize_config_log_value( - value: Any, - *, - redact_all: bool, - key: str, -) -> Any: - if redact_all or _is_sensitive_config_key(key): - if isinstance(value, dict): - return { - str(child_key): _configured_value_marker(child_value) - for child_key, child_value in value.items() - } - return _configured_value_marker(value) - if isinstance(value, dict): - return { - str(child_key): _sanitize_config_log_value( - child_value, - redact_all=False, - key=str(child_key), - ) - for child_key, child_value in value.items() - } - if isinstance(value, list): - return [ - _sanitize_config_log_value(item, redact_all=False, key=key) - for item in value - ] - return value - - -def _is_sensitive_config_key(key: str) -> bool: - normalized = str(key).casefold() - return any(keyword in normalized for keyword in SENSITIVE_CONFIG_KEYWORDS) - - -def _configured_value_marker(value: Any) -> str: - if value is None: - return "<未配置>" - if isinstance(value, str): - return "<已配置>" if value.strip() else "<未配置>" - if isinstance(value, (dict, list, tuple, set)): - return "<已配置>" if value else "<未配置>" - return "<已配置>" - - -def _build_resource_bundle_plan( - base_dir: Path, - resource: MaaFWResource, - controller: MaaFWController, -) -> MaaFWResourceBundlePlan: - return MaaFWResourceBundlePlan( - name=resource.name, - label=resource.label, - paths=[_resolve_project_path(base_dir, item) for item in resource.path], - attachedPaths=[ - _resolve_project_path(base_dir, item) - for item in controller.attach_resource_path or [] - ], - ) - - -def _resolve_project_path(base_dir: Path, raw_path: str) -> MaaFWResolvedPath: - replaced = raw_path.replace("{PROJECT_DIR}", str(base_dir)) - candidate = Path(replaced) - if not candidate.is_absolute(): - candidate = base_dir / candidate - resolved_path = candidate.resolve() - if not _is_within_base_dir(resolved_path, base_dir): - raise MaaFWRunPlanError(f"路径越界,禁止访问项目目录之外的资源: {raw_path}") - return MaaFWResolvedPath( - raw=raw_path, - resolved=str(resolved_path), - exists=resolved_path.exists(), - isFile=resolved_path.is_file(), - isDir=resolved_path.is_dir(), - ) - - -def _is_within_base_dir(path: Path, base_dir: Path) -> bool: - try: - path.relative_to(base_dir) - return True - except ValueError: - return False - - -def _build_pi_env( - base_dir: Path, - interface_model: MaaFWInterface, - controller: MaaFWController, - resource: MaaFWResource, -) -> dict[str, str]: - controller_payload = _resolve_i18n_payload( - controller.model_dump(mode="json", exclude_none=True), - base_dir, - interface_model, - ) - resource_payload = _resolve_i18n_payload( - resource.model_dump(mode="json", exclude_none=True), - base_dir, - interface_model, - ) - return { - "PI_INTERFACE_VERSION": PI_INTERFACE_VERSION, - "PI_CLIENT_NAME": PI_CLIENT_NAME, - "PI_CLIENT_VERSION": _load_client_version(), - "PI_CLIENT_LANGUAGE": PI_CLIENT_LANGUAGE, - "PI_CLIENT_MAAFW_VERSION": _load_maafw_version(), - "PI_VERSION": interface_model.version or "", - "PI_CONTROLLER": json.dumps(controller_payload, ensure_ascii=False, separators=(",", ":")), - "PI_RESOURCE": json.dumps(resource_payload, ensure_ascii=False, separators=(",", ":")), - } - - -def _load_client_version() -> str: - version_path = Path.cwd() / "res" / "version.json" - try: - data = json.loads(version_path.read_text(encoding="utf-8")) - version = data.get("version") - return version if isinstance(version, str) else "" - except Exception: - return "" - - -def _load_maafw_version() -> str: - try: - return f"v{metadata.version('maafw')}" - except Exception: - return "" - - -def _resolve_i18n_payload(payload: Any, base_dir: Path, interface_model: MaaFWInterface) -> Any: - mapping = _load_i18n_mapping(base_dir, interface_model) - return _resolve_i18n_value(payload, mapping) - - -def _load_i18n_mapping(base_dir: Path, interface_model: MaaFWInterface) -> dict[str, Any]: - if not interface_model.languages: - return {} - language_file = interface_model.languages.get(PI_CLIENT_LANGUAGE) - if not isinstance(language_file, str) or not language_file.strip(): - return {} - language_path = _resolve_project_path(base_dir, language_file) - if not language_path.exists or not language_path.isFile: - return {} - try: - data = json5.loads(Path(language_path.resolved).read_text(encoding="utf-8")) - return data if isinstance(data, dict) else {} - except Exception: - return {} - - -def _resolve_i18n_value(value: Any, mapping: dict[str, Any]) -> Any: - if isinstance(value, dict): - return {key: _resolve_i18n_value(item, mapping) for key, item in value.items()} - if isinstance(value, list): - return [_resolve_i18n_value(item, mapping) for item in value] - if isinstance(value, str) and value.startswith("$"): - translated = _lookup_i18n_text(value, mapping) - if translated is not None: - return translated - return value - - -def _lookup_i18n_text(key: str, mapping: dict[str, Any]) -> str | None: - normalized_key = key[1:] if key.startswith("$") else key - if not normalized_key: - return None - current: Any = mapping - for part in normalized_key.split("."): - if not isinstance(current, dict) or part not in current: - current = None - break - current = current[part] - if isinstance(current, str): - return current - flat_value = mapping.get(normalized_key) - if isinstance(flat_value, str): - return flat_value - return None diff --git a/plugins/automas_maafw_runner/src/automas_maafw_runner/runner.py b/plugins/automas_maafw_runner/src/automas_maafw_runner/runner.py deleted file mode 100644 index d5b2a162..00000000 --- a/plugins/automas_maafw_runner/src/automas_maafw_runner/runner.py +++ /dev/null @@ -1,2139 +0,0 @@ -# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software -# Copyright © 2025-2026 AUTO-MAS Team - -# This file is part of AUTO-MAS. - -# AUTO-MAS is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of -# the License, or (at your option) any later version. - -# AUTO-MAS is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with AUTO-MAS. If not, see . - - -import ast -import hashlib -import importlib -import json -import os -import re -import shutil -import subprocess -import sys -import threading -import time -from contextlib import suppress -from dataclasses import dataclass -from pathlib import Path -from typing import Any, BinaryIO, Callable, Literal, TextIO - -import maa as maa_package -from maa.agent_client import AgentClient -from maa.controller import ( - AdbController, - Controller, - ControllerEventSink, - MaaAdbInputMethodEnum, - MaaAdbScreencapMethodEnum, - MaaWin32InputMethodEnum, - MaaWin32ScreencapMethodEnum, - Win32Controller, -) -from maa.event_sink import NotificationType -from maa.job import Job, JobWithResult -from maa.library import Library -from maa.resource import Resource, ResourceEventSink -from maa.tasker import Tasker, TaskerEventSink -from maa.toolkit import Toolkit -from pydantic import BaseModel, Field - -try: - from .run_plan import ( - MaaFWResourceBundlePlan, - MaaFWRunPlan, - MaaFWTaskRunPlan, - build_maafw_agent_command_plans, - ) -except ImportError: - from run_plan import ( # type: ignore[no-redef] - MaaFWResourceBundlePlan, - MaaFWRunPlan, - MaaFWTaskRunPlan, - build_maafw_agent_command_plans, - ) - -ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") -ENCODINGS = ("utf-8", "gbk", "shift_jis", "utf-16") -MAAFW_DEBUG_LOG_PATH = Path("debug") / "maafw.log" -TASK_CONFIG_LOG_VALUE_LIMIT = 1200 - -_MAAFW_INITIALIZED = False -_MAAFW_INIT_LOCK = threading.Lock() - - -MaaFWControllerType = Literal["Adb", "Win32"] -AGENT_CONNECT_RETRY_COUNT = 30 -AGENT_CONNECT_RETRY_INTERVAL = 0.2 -AGENT_CONNECT_TIMEOUT_MS = 1000 -ADB_READY_RETRY_COUNT = 30 -ADB_READY_RETRY_INTERVAL = 1.0 -ADB_COMMAND_TIMEOUT = 5 -AGENT_PROJECT_RUNTIME_DIRS = ("debug", "logs", "temp") -AGENT_ENV_PATH_DIRS = ( - (), - ("maafw",), - ("runtimes", "win-x64"), - ("libs",), - ("deps",), -) -# Agent 自举所需的最小依赖包(pip 发行名) -AGENT_BOOTSTRAP_PACKAGE = "json-with-comments" -# pip 健康检测超时(秒) -PIP_HEALTH_CHECK_TIMEOUT = 15 -# pip 安装/修复超时(秒) -PIP_INSTALL_TIMEOUT = 120 -AGENT_ENV_MANIFEST_NAME = ".auto_mas_agent_env.json" -AGENT_COMPAT_SHIM_DIR_NAME = ".auto_mas_shims" -EMBEDDED_AGENT_SERVER_SINK_DECORATORS = { - "resource_sink", - "controller_sink", - "tasker_sink", - "context_sink", -} -MAAFW_FAILURE_EVENT_MESSAGES = { - "Node.NextList.Failed", - "Node.PipelineNode.Failed", - "Node.Action.Failed", - "Tasker.Task.Failed", -} -MAAFW_FAILURE_SUMMARY_LIMIT = 8 - - -def _project_maafw_runtime_path(project_path: Path | None) -> Path | None: - if project_path is None: - return None - - for candidate in ( - project_path / "maafw", - project_path / "runtimes" / "win-x64", - ): - if (candidate / "MaaFramework.dll").is_file(): - return candidate - return None - - -def decode_bytes(data: bytes) -> str: - if not data: - return "" - for encoding in ENCODINGS: - try: - return data.decode(encoding, errors="strict") - except (UnicodeDecodeError, LookupError): - continue - return data.decode("latin1", errors="replace") - - -def _should_adb_connect(address: str, attempt: int) -> bool: - return _is_network_adb_address(address) and ( - attempt == 0 or (attempt + 1) % 5 == 0 - ) - - -def _is_network_adb_address(address: str) -> bool: - host, separator, port = address.rpartition(":") - return bool(separator and host and port.isdigit()) - - -def _subprocess_detail(result: subprocess.CompletedProcess[str]) -> str: - stdout = (result.stdout or "").strip() - stderr = (result.stderr or "").strip() - if stdout and stderr: - return f"{stdout}; {stderr}" - return stderr or stdout or f"exit={result.returncode}" - - -def _is_adb_connect_success(detail: str) -> bool: - normalized = detail.lower() - if not normalized: - return False - failed_markers = ("failed", "unable", "cannot", "refused", "timed out") - return ( - ("connected to" in normalized or "already connected" in normalized) - and not any(marker in normalized for marker in failed_markers) - ) - - -def _format_enum_methods(enum_cls: Any, value: int) -> str: - raw_value = int(value) - members = getattr(enum_cls, "__members__", {}) - for name, member in members.items(): - if int(member) == raw_value: - return f"{name}({raw_value})" - - names: list[str] = [] - remaining = raw_value - for name, member in members.items(): - member_value = int(member) - if member_value == 0: - continue - if raw_value & member_value == member_value: - names.append(name) - remaining &= ~member_value - - if names and remaining == 0: - return f"{'|'.join(names)}({raw_value})" - if names: - return f"{'|'.join(names)}+{remaining}({raw_value})" - return str(raw_value) - - -def _format_latency(seconds: float) -> str: - return f"{seconds * 1000:.0f} ms" - - -def _ensure_maafw_client_library_mode(runtime_path: Path | None = None) -> None: - """Keep MaaFW loaded as a client library inside AUTO-MAS.""" - - if runtime_path is not None: - Library.open(runtime_path, agent_server=False) - - if Library.is_agent_server(): - maa_bin_path = Path(maa_package.__file__).resolve().parent / "bin" - Library.open(maa_bin_path, agent_server=False) - if Library.is_agent_server(): - # Library.open() is a no-op after MaaVersion argtypes are initialized. - Library._is_agent_server = False # type: ignore[attr-defined] - - # Lock Library.open() into client mode before any accidental maa.agent import. - Library.version() - if Library.is_agent_server(): - raise RuntimeError("MaaFW Library is still in AgentServer mode") - - -@dataclass(frozen=True) -class _EmbeddedAgentScanItem: - module_name: str - class_name: str | None = None - sink_kind: str | None = None - - -def _load_project_agent_requirements(project_path: Path) -> list[str]: - """读取 MaaFW 项目自己的 agent 依赖声明,避免串用 AUTO-MAS 依赖版本。""" - - requirements_path = project_path / "requirements.txt" - packages: list[str] = [] - try: - with requirements_path.open("r", encoding="utf-8") as file: - for raw_line in file: - line = raw_line.strip() - if not line or line.startswith("#"): - continue - packages.append(line) - except FileNotFoundError: - pass - except Exception: - pass - - normalized = {item.split(";", 1)[0].strip().lower() for item in packages} - if not any(item.startswith(AGENT_BOOTSTRAP_PACKAGE) for item in normalized): - packages.append(AGENT_BOOTSTRAP_PACKAGE) - return packages - - -def _project_agent_requirements_hash(project_path: Path) -> str: - packages = _load_project_agent_requirements(project_path) - payload = json.dumps(packages, ensure_ascii=False, separators=(",", ":")) - return hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def _project_interface_hash(project_path: Path) -> str: - for name in ("interface.json", "interface.jsonc"): - path = project_path / name - if path.is_file(): - return hashlib.sha256(path.read_bytes()).hexdigest() - return "" - - -def _build_agent_env_manifest(project_path: Path) -> dict[str, Any]: - return { - "schemaVersion": 1, - "projectPath": str(project_path.resolve()), - "interfaceHash": _project_interface_hash(project_path), - "requirementsHash": _project_agent_requirements_hash(project_path), - "requirements": _load_project_agent_requirements(project_path), - } - - -def _venv_python_path(venv_path: Path) -> Path: - if os.name == "nt": - return venv_path / "Scripts" / "python.exe" - return venv_path / "bin" / "python" - - -def _is_valid_venv_path(venv_path: Path) -> bool: - return _venv_python_path(venv_path).is_file() and (venv_path / "pyvenv.cfg").is_file() - - -def _venv_bootstrap_python() -> str: - portable_python = Path.cwd() / "environment" / "python" / "python.exe" - if portable_python.is_file(): - return str(portable_python) - return sys.executable - - -def _agent_compat_shim_dir(venv_path: Path) -> Path: - return venv_path / AGENT_COMPAT_SHIM_DIR_NAME - - -def _write_agent_compat_shims(venv_path: Path) -> Path: - shim_dir = _agent_compat_shim_dir(venv_path) - shim_dir.mkdir(parents=True, exist_ok=True) - (shim_dir / "sitecustomize.py").write_text( - "\n".join( - [ - "def _patch_legacy_maafw_resource():", - " try:", - " import maa.resource as maa_resource_module", - " if hasattr(maa_resource_module, 'resource'):", - " return", - " from maa.agent.agent_server import AgentServer", - " maa_resource_module.resource = AgentServer", - " except Exception:", - " pass", - "", - "_patch_legacy_maafw_resource()", - "", - ] - ), - encoding="utf-8", - ) - return shim_dir - - -def _ensure_maafw_global_init(project_path: Path | None = None) -> None: - global _MAAFW_INITIALIZED - if _MAAFW_INITIALIZED: - return - with _MAAFW_INIT_LOCK: - if _MAAFW_INITIALIZED: - return - _ensure_maafw_client_library_mode(_project_maafw_runtime_path(project_path)) - user_path = (project_path or Path.cwd()).resolve() - option_path = user_path / "config" / "maa_option.json" - option_path.parent.mkdir(parents=True, exist_ok=True) - option_path.write_text( - json.dumps( - { - "logging": True, - "save_draw": False, - "stdout_level": 2, - "save_on_error": False, - "draw_quality": 85, - }, - ensure_ascii=False, - indent=4, - ), - encoding="utf-8", - ) - Toolkit.init_option(str(user_path)) - _MAAFW_INITIALIZED = True - - -class MaaFWDeviceConfig(BaseModel): - type: MaaFWControllerType - adbPath: str | None = None - address: str | None = None - hWnd: int | None = None - screencapMethods: int = MaaAdbScreencapMethodEnum.Default - inputMethods: int = MaaAdbInputMethodEnum.Default - screencapMethod: int = MaaWin32ScreencapMethodEnum.DXGI_DesktopDup - mouseMethod: int = MaaWin32InputMethodEnum.Seize - keyboardMethod: int = MaaWin32InputMethodEnum.Seize - config: dict[str, Any] = Field(default_factory=dict) - - -class MaaFWRunResult(BaseModel): - success: bool - projectName: str - controllerName: str - resourceName: str - completedTasks: list[str] = Field(default_factory=list) - failedTask: str | None = None - errorMessage: str | None = None - - -class MaaFWRunner: - def __init__( - self, - plan: MaaFWRunPlan, - *, - send_log: Callable[[str], None] | None = None, - ) -> None: - self.plan: MaaFWRunPlan = plan - self.resource: Resource | None = None - self.tasker: Tasker | None = None - self.controller: Any | None = None - self.agent_clients: list[AgentClient] = [] - self.agent_processes: list[subprocess.Popen] = [] - self.agent_output_threads: list[threading.Thread] = [] - self.event_sinks: list[Any] = [] - self.embedded_agent_sys_paths: list[str] = [] - self.send_log: Callable[[str], None] = send_log or (lambda _: None) - self._initialized: bool = False - self._python_env_checked: dict[str, bool] = {} - self._stop_requested: threading.Event = threading.Event() - self._task_failure_summaries: list[str] = [] - self._failed_task_errors: list[tuple[str, str]] = [] - - def _ensure_initialized(self, device_config: MaaFWDeviceConfig) -> None: - if self._initialized: - return - - _ensure_maafw_global_init(Path(self.plan.path)) - self.resource = Resource() - self.tasker = Tasker() - self._install_resource_sink() - self._load_resources() - self._connect_device(device_config) - self._start_agents() - self._initialized = True - - def run(self, device_config: MaaFWDeviceConfig) -> MaaFWRunResult: - self._stop_requested.clear() - try: - self._ensure_initialized(device_config) - completed_tasks = self._run_tasks() - if self._failed_task_errors: - first_failed_task, _ = self._failed_task_errors[0] - error_message = ";".join( - f"{task_name}: {message}" - for task_name, message in self._failed_task_errors[:3] - ) - if len(self._failed_task_errors) > 3: - error_message += f";另有 {len(self._failed_task_errors) - 3} 个任务失败" - return MaaFWRunResult( - success=False, - projectName=self.plan.projectName, - controllerName=self.plan.controllerName, - resourceName=self.plan.resourceName, - completedTasks=completed_tasks, - failedTask=first_failed_task, - errorMessage=error_message, - ) - return MaaFWRunResult( - success=True, - projectName=self.plan.projectName, - controllerName=self.plan.controllerName, - resourceName=self.plan.resourceName, - completedTasks=completed_tasks, - ) - except Exception as exc: - failed_task = self.plan.tasks[len(self._completed_task_names())].name if ( - len(self._completed_task_names()) < len(self.plan.tasks) - ) else None - self.send_log(f"MaaFW 任务执行失败: {exc}") - return MaaFWRunResult( - success=False, - projectName=self.plan.projectName, - controllerName=self.plan.controllerName, - resourceName=self.plan.resourceName, - completedTasks=self._completed_task_names(), - failedTask=failed_task, - errorMessage=str(exc), - ) - - def cleanup(self) -> None: - self._stop_requested.set() - if self.tasker is not None: - try: - _ensure_maafw_client_library_mode() - if self.tasker.running: - self.tasker.post_stop().wait() - except Exception as exc: - self.send_log(f"停止 MaaFW tasker 失败: {exc}") - - def reset_for_retry(self) -> None: - self._stop_requested.set() - if self.tasker is not None: - try: - _ensure_maafw_client_library_mode() - if self.tasker.running: - self.tasker.post_stop().wait() - except Exception as exc: - self.send_log(f"停止 MaaFW tasker 准备重试失败: {exc}") - - def shutdown(self) -> None: - self._stop_requested.set() - try: - _ensure_maafw_client_library_mode() - self.cleanup() - finally: - for agent_client in self.agent_clients: - try: - agent_client.disconnect() - except Exception as exc: - self.send_log(f"断开 AgentClient 失败: {exc}") - - for process in self.agent_processes: - try: - if process.poll() is not None: - continue - process.terminate() - process.wait(timeout=5) - except subprocess.TimeoutExpired: - try: - process.kill() - process.wait(timeout=3) - except Exception as exc: - self.send_log(f"强制结束 agent 进程失败: {exc}") - except Exception as exc: - self.send_log(f"结束 agent 进程失败: {exc}") - - for process in self.agent_processes: - stdout = process.stdout - if stdout is None: - continue - try: - stdout.close() - except Exception: - pass - - for thread in self.agent_output_threads: - try: - thread.join(timeout=0.5) - except RuntimeError: - pass - - self.agent_clients.clear() - self.agent_processes.clear() - self.agent_output_threads.clear() - self.event_sinks.clear() - self.controller = None - self.resource = None - self.tasker = None - self._initialized = False - - def _load_resources(self) -> None: - for path_info in [*self.plan.resource.paths, *self.plan.resource.attachedPaths]: - if not path_info.exists or not path_info.isDir: - raise RuntimeError(f"资源目录不存在: {path_info.resolved}") - self._wait_job(self.resource.post_bundle(path_info.resolved)) - self.send_log(f"已加载资源: {path_info.resolved}") - - def _connect_device(self, device_config: MaaFWDeviceConfig) -> None: - if device_config.type != self.plan.controllerType: - raise RuntimeError( - f"设备类型 {device_config.type} 与 controller {self.plan.controllerType} 不一致" - ) - - if device_config.type == "Adb": - self._resolve_adb_device(device_config) - self._wait_adb_device_ready(device_config) - - self._log_controller_config(device_config) - self.controller = self._create_controller(device_config) - self._install_controller_sink(self.controller) - self._wait_job(self.controller.post_connection()) - if not self.tasker.bind(self.resource, self.controller): - raise RuntimeError("无法绑定 MaaFW resource/controller/tasker") - self._install_tasker_sink() - self.send_log(f"已连接 controller: {self.plan.controllerName}") - - def _resolve_adb_device(self, device_config: MaaFWDeviceConfig) -> None: - """Fill a missing ADB path through the active MaaFW Toolkit.""" - - if device_config.adbPath: - return - if not device_config.address: - raise RuntimeError("ADB controller 需要设备地址") - - devices = Toolkit.find_adb_devices() - device = next( - ( - item - for item in devices - if str(getattr(item, "address", "")) == device_config.address - ), - None, - ) - if device is None: - addresses = [ - str(getattr(item, "address", "")) - for item in devices - if str(getattr(item, "address", "")) - ] - detail = f";Toolkit 已发现: {', '.join(addresses)}" if addresses else "" - raise RuntimeError( - f"无法按地址发现 ADB 设备: {device_config.address}{detail}" - ) - - adb_path = str(getattr(device, "adb_path", "") or "").strip() - if not adb_path: - raise RuntimeError( - f"MaaFW Toolkit 未返回 ADB 路径: {device_config.address}" - ) - device_config.adbPath = adb_path - self.send_log( - f"已通过 MaaFW Toolkit 发现 ADB: {device_config.address}; 路径={adb_path}" - ) - - def _create_controller(self, device_config: MaaFWDeviceConfig) -> Any: - if device_config.type == "Adb": - if not device_config.adbPath or not device_config.address: - raise RuntimeError("ADB controller 需要 adbPath 和 address") - # screencapMethods/inputMethods 为 0 (Null) 时回退到 MaaFW 默认值, - # 确保含 EmulatorExtras 的默认截图/输入集合被启用。 - screencap_methods = device_config.screencapMethods or MaaAdbScreencapMethodEnum.Default - input_methods = device_config.inputMethods or MaaAdbInputMethodEnum.Default - return AdbController( - device_config.adbPath, - device_config.address, - screencap_methods, - input_methods, - device_config.config, - ) - - if device_config.type == "Win32": - if not device_config.hWnd: - raise RuntimeError("Win32 controller 需要窗口句柄,请先扫描或填写 HWnd") - # screencapMethod 为 0 (Null) 时回退到 DXGI_DesktopDup,避免控制器无法截图。 - screencap_method = device_config.screencapMethod or MaaWin32ScreencapMethodEnum.DXGI_DesktopDup - mouse_method = device_config.mouseMethod or MaaWin32InputMethodEnum.Seize - keyboard_method = device_config.keyboardMethod or MaaWin32InputMethodEnum.Seize - return Win32Controller( - device_config.hWnd, - screencap_method, - mouse_method, - keyboard_method, - ) - - raise RuntimeError( - "AUTO-MAS MaaFW Direct currently supports only Adb/Win32 " - f"controllers; use the project UI for {device_config.type}" - ) - - def _log_controller_config(self, device_config: MaaFWDeviceConfig) -> None: - if device_config.type == "Adb": - self.send_log( - "ADB controller 配置: " - f"截图方式={_format_enum_methods(MaaAdbScreencapMethodEnum, device_config.screencapMethods)}; " - f"触控方式={_format_enum_methods(MaaAdbInputMethodEnum, device_config.inputMethods)}; " - f"地址={device_config.address}" - ) - if device_config.config: - self.send_log( - "ADB controller 扩展配置: " - f"{json.dumps(device_config.config, ensure_ascii=False)}" - ) - return - - if device_config.type == "Win32": - self.send_log( - "Win32 controller 配置: " - f"截图方式={_format_enum_methods(MaaWin32ScreencapMethodEnum, device_config.screencapMethod)}; " - f"鼠标方式={_format_enum_methods(MaaWin32InputMethodEnum, device_config.mouseMethod)}; " - f"键盘方式={_format_enum_methods(MaaWin32InputMethodEnum, device_config.keyboardMethod)}; " - f"HWnd={device_config.hWnd}" - ) - - def _wait_adb_device_ready(self, device_config: MaaFWDeviceConfig) -> None: - if not device_config.adbPath or not device_config.address: - return - - last_detail = "" - network_connect_logged = False - for attempt in range(ADB_READY_RETRY_COUNT): - connect_detail = "" - if _should_adb_connect(device_config.address, attempt): - connected, connect_detail = self._connect_adb_network_device( - device_config, - ) - if connected and not network_connect_logged: - self.send_log( - f"ADB 网络设备已连接: {device_config.address}; " - f"{connect_detail}" - ) - network_connect_logged = True - - try: - result = subprocess.run( - [ - device_config.adbPath, - "-s", - device_config.address, - "get-state", - ], - capture_output=True, - timeout=ADB_COMMAND_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - ) - state = (result.stdout or "").strip() - detail = (result.stderr or state or "").strip() - if result.returncode == 0 and state == "device": - self.send_log( - f"ADB 设备已就绪: {device_config.address}; " - f"延迟={self._measure_adb_latency(device_config)}" - ) - return - last_detail = detail or f"exit={result.returncode}" - except subprocess.TimeoutExpired: - last_detail = f"get-state 超时 ({ADB_COMMAND_TIMEOUT}s)" - except Exception as exc: - last_detail = str(exc) - - if connect_detail and not network_connect_logged: - last_detail = ( - f"{last_detail}; connect: {connect_detail}" - if last_detail - else f"connect: {connect_detail}" - ) - - should_log = ( - attempt == 0 - or attempt == ADB_READY_RETRY_COUNT - 1 - or (attempt + 1) % 5 == 0 - ) - if should_log: - self.send_log( - f"ADB 设备未就绪,等待重试 " - f"({attempt + 1}/{ADB_READY_RETRY_COUNT}): " - f"{device_config.address}; {last_detail}" - ) - time.sleep(ADB_READY_RETRY_INTERVAL) - - raise RuntimeError( - f"ADB 设备未就绪: {device_config.address}; 最后状态: {last_detail}\n" - f"请确认模拟器已完全启动,且 " - f"`{device_config.adbPath} devices` 中该设备状态为 device,而不是 offline/unauthorized。" - ) - - def _measure_adb_latency(self, device_config: MaaFWDeviceConfig) -> str: - if not device_config.adbPath or not device_config.address: - return "unknown" - - try: - started_at = time.perf_counter() - result = subprocess.run( - [ - device_config.adbPath, - "-s", - device_config.address, - "shell", - "echo", - "auto_mas_ready", - ], - capture_output=True, - timeout=ADB_COMMAND_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - ) - elapsed = time.perf_counter() - started_at - except subprocess.TimeoutExpired: - return f"检测超时 ({ADB_COMMAND_TIMEOUT}s)" - except Exception as exc: - return str(exc) - - detail = _subprocess_detail(result) - if result.returncode == 0 and "auto_mas_ready" in (result.stdout or ""): - return _format_latency(elapsed) - return f"检测失败: {detail}" - - def _connect_adb_network_device( - self, - device_config: MaaFWDeviceConfig, - ) -> tuple[bool, str]: - if not device_config.adbPath or not device_config.address: - return False, "" - - try: - result = subprocess.run( - [ - device_config.adbPath, - "connect", - device_config.address, - ], - capture_output=True, - timeout=ADB_COMMAND_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - ) - except subprocess.TimeoutExpired: - return False, f"connect 超时 ({ADB_COMMAND_TIMEOUT}s)" - except Exception as exc: - return False, str(exc) - - detail = _subprocess_detail(result) - return result.returncode == 0 and _is_adb_connect_success(detail), detail - - def _start_agents(self) -> None: - self._load_embedded_agents() - self.prepare_agent_python_envs() - - for agent_plan in self.plan.agents: - if agent_plan.embedded: - continue - agent_client = self._create_agent_client(agent_plan.childExec) - if not agent_client.bind(self.resource): - raise RuntimeError("AgentClient 绑定资源失败") - - identifier = agent_client.identifier - if not identifier: - raise RuntimeError("AgentClient 未返回可用连接标识") - command = [ - item if item != "" else identifier - for item in agent_plan.command - ] - env = self._build_agent_env(agent_plan) - creationflags = ( - subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 - ) - self.send_log( - f"启动 Agent 子进程: {Path(command[0]).name} " - f"(cwd={agent_plan.cwd})" - ) - process = subprocess.Popen( - command, - cwd=agent_plan.cwd, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - creationflags=creationflags, - ) - self.agent_clients.append(agent_client) - self.agent_processes.append(process) - self._start_agent_output_reader(process, Path(command[0]).name) - try: - self._connect_agent_client( - agent_client, - process, - Path(command[0]).name, - ) - except Exception: - with suppress(Exception): - process.terminate() - raise - - if not agent_client.register_sink( - self.resource, - self.controller, - self.tasker, - ): - raise RuntimeError("AgentClient 注册 sink 失败") - - self.send_log(f"Agent 已启动: {command[0]}") - - def prepare_agent_python_envs(self) -> None: - """Prepare all MaaFW agent Python environments without starting agents.""" - - if not self.plan.agents: - self.send_log("[Python环境] 当前 MaaFW 项目没有声明 Agent") - return - - process_agents = [agent for agent in self.plan.agents if not agent.embedded] - if not process_agents: - self.send_log("[Python环境] 所有 Agent 均为 embedded,跳过子进程 Python 环境准备") - return - - self.send_log(f"[Python环境] 开始准备 {len(process_agents)} 个 Agent 环境") - from automas_maafw_agent_env.env import prepare_agent_envs - - prepare_agent_envs( - Path(self.plan.path), - process_agents, - send_log=self.send_log, - ) - self.send_log("[Python环境] Agent 环境准备完成") - - def _load_embedded_agents(self) -> None: - embedded_agents = [agent for agent in self.plan.agents if agent.embedded] - if not embedded_agents: - return - raise RuntimeError( - "embedded Agent must run as an isolated subprocess in AUTO-MAS; " - "rebuild the MaaFW run plan before starting agents" - ) - - def _resolve_embedded_agent_paths(self, agent_plan: Any) -> tuple[Path, Path | None]: - project_path = Path(self.plan.path) - entry_path: Path | None = None - for raw_arg in agent_plan.childArgs: - raw_path = str(raw_arg) - if not raw_path.lower().endswith(".py"): - continue - candidate = Path(raw_path) - if not candidate.is_absolute(): - candidate = project_path / candidate - entry_path = candidate.resolve() - break - - if entry_path is None: - default_entry = project_path / "agent" / "main.py" - entry_path = default_entry.resolve() if default_entry.is_file() else None - - if entry_path is not None and entry_path.is_file(): - return entry_path.parent, entry_path - return project_path / "agent", entry_path - - def _load_embedded_agent_custom(self, agent_root: Path) -> bool: - if not agent_root.is_dir(): - raise RuntimeError(f"Embedded Agent 目录不存在: {agent_root}") - if self.resource is None or self.controller is None or self.tasker is None: - raise RuntimeError("Embedded Agent 需要先初始化 resource/controller/tasker") - - scan_items = self._scan_embedded_agent_modules(agent_root) - if not scan_items: - self.send_log(f"[Embedded Agent] 未扫描到装饰器: {agent_root}") - return False - modules = sorted({item.module_name for item in scan_items}) - implicit_sinks = [ - item - for item in scan_items - if item.class_name is not None and item.sink_kind is not None - ] - - self._purge_embedded_modules(agent_root) - self._purge_module_name("agent") - added_paths = self._add_embedded_sys_paths(agent_root) - restore_patch = self._patch_embedded_agent_decorators() - before_actions = set(self.resource.custom_action_list or []) - before_recognitions = set(self.resource.custom_recognition_list or []) - before_event_sinks = len(self.event_sinks) - try: - for module_name in modules: - self._purge_module_name(module_name) - self.send_log(f"[Embedded Agent] 导入模块: {module_name}") - importlib.import_module(module_name) - for item in implicit_sinks: - self._register_embedded_implicit_sink(item) - finally: - restore_patch() - self._remove_embedded_sys_paths(added_paths) - - actions = sorted(set(self.resource.custom_action_list or []) - before_actions) - recognitions = sorted( - set(self.resource.custom_recognition_list or []) - before_recognitions - ) - sink_count = len(self.event_sinks) - before_event_sinks - self.send_log( - "[Embedded Agent] 注册完成: " - f"actions={actions or []}, recognitions={recognitions or []}, sinks={sink_count}" - ) - return bool(actions or recognitions or sink_count) - - def _scan_embedded_agent_modules(self, agent_root: Path) -> list[_EmbeddedAgentScanItem]: - items: set[_EmbeddedAgentScanItem] = set() - for file_path in sorted(agent_root.rglob("*.py")): - if "__pycache__" in file_path.parts: - continue - try: - tree = ast.parse(file_path.read_text(encoding="utf-8")) - except (OSError, SyntaxError, UnicodeDecodeError): - continue - module_name = self._embedded_module_name(agent_root, file_path) - if not module_name: - continue - - for node in ast.walk(tree): - if not isinstance(node, ast.ClassDef): - continue - - has_sink_decorator = False - for decorator in node.decorator_list: - decorator_kind = self._embedded_agent_decorator_kind(decorator) - if decorator_kind is None: - continue - items.add(_EmbeddedAgentScanItem(module_name=module_name)) - has_sink_decorator = has_sink_decorator or decorator_kind.endswith( - "_sink" - ) - - if has_sink_decorator: - continue - - implicit_sink_kind = self._implicit_embedded_sink_kind(node) - if implicit_sink_kind is not None: - items.add( - _EmbeddedAgentScanItem( - module_name=module_name, - class_name=node.name, - sink_kind=implicit_sink_kind, - ) - ) - - return sorted( - items, - key=lambda item: ( - item.module_name, - item.class_name or "", - item.sink_kind or "", - ), - ) - - @staticmethod - def _embedded_agent_decorator_kind(decorator: ast.expr) -> str | None: - if not isinstance(decorator, ast.Call): - return None - func = decorator.func - if not isinstance(func, ast.Attribute): - return None - - owner = func.value - owner_name = owner.id if isinstance(owner, ast.Name) else "" - if owner_name in {"resource", "Resource", "AgentServer"}: - if func.attr == "custom_action": - return "action" - if func.attr == "custom_recognition": - return "recognition" - if owner_name == "AgentServer" and func.attr in EMBEDDED_AGENT_SERVER_SINK_DECORATORS: - return func.attr - return None - - @staticmethod - def _implicit_embedded_sink_kind(node: ast.ClassDef) -> str | None: - for base in node.bases: - base_name = "" - if isinstance(base, ast.Name): - base_name = base.id - elif isinstance(base, ast.Attribute): - base_name = base.attr - if base_name == "ResourceEventSink": - return "resource_sink" - if base_name == "ControllerEventSink": - return "controller_sink" - if base_name == "TaskerEventSink": - return "tasker_sink" - if base_name == "ContextEventSink": - return "context_sink" - return None - - @staticmethod - def _embedded_module_name(agent_root: Path, file_path: Path) -> str | None: - try: - relative = file_path.relative_to(agent_root) - except ValueError: - return None - parts = list(relative.with_suffix("").parts) - if not parts: - return None - if parts[-1] == "__init__": - parts = parts[:-1] - return ".".join(parts) if parts else None - - def _patch_embedded_agent_decorators(self) -> Callable[[], None]: - _ensure_maafw_client_library_mode() - import maa.resource as maa_resource_module - from maa.agent.agent_server import AgentServer - - sentinel = object() - old_resource = getattr(maa_resource_module, "resource", sentinel) - old_custom_action = AgentServer.__dict__.get("custom_action", sentinel) - old_custom_recognition = AgentServer.__dict__.get( - "custom_recognition", sentinel - ) - old_resource_sink = AgentServer.__dict__.get("resource_sink", sentinel) - old_controller_sink = AgentServer.__dict__.get("controller_sink", sentinel) - old_tasker_sink = AgentServer.__dict__.get("tasker_sink", sentinel) - old_context_sink = AgentServer.__dict__.get("context_sink", sentinel) - - setattr(maa_resource_module, "resource", self.resource) - AgentServer.custom_action = staticmethod(self.resource.custom_action) - AgentServer.custom_recognition = staticmethod( - self.resource.custom_recognition - ) - AgentServer.resource_sink = staticmethod( - self._embedded_resource_sink_decorator - ) - AgentServer.controller_sink = staticmethod( - self._embedded_controller_sink_decorator - ) - AgentServer.tasker_sink = staticmethod( - self._embedded_tasker_sink_decorator - ) - AgentServer.context_sink = staticmethod( - self._embedded_context_sink_decorator - ) - - def restore() -> None: - if old_resource is sentinel: - with suppress(AttributeError): - delattr(maa_resource_module, "resource") - else: - setattr(maa_resource_module, "resource", old_resource) - for name, old_value in { - "custom_action": old_custom_action, - "custom_recognition": old_custom_recognition, - "resource_sink": old_resource_sink, - "controller_sink": old_controller_sink, - "tasker_sink": old_tasker_sink, - "context_sink": old_context_sink, - }.items(): - if old_value is sentinel: - with suppress(AttributeError): - delattr(AgentServer, name) - else: - setattr(AgentServer, name, old_value) - - return restore - - def _embedded_resource_sink_decorator(self) -> Callable[[type[Any]], type[Any]]: - def wrapper(sink_class: type[Any]) -> type[Any]: - sink = sink_class() - if self.resource is not None: - self.resource.add_sink(sink) - self.event_sinks.append(sink) - return sink_class - - return wrapper - - def _embedded_controller_sink_decorator(self) -> Callable[[type[Any]], type[Any]]: - def wrapper(sink_class: type[Any]) -> type[Any]: - sink = sink_class() - if self.controller is not None: - self.controller.add_sink(sink) - self.event_sinks.append(sink) - return sink_class - - return wrapper - - def _embedded_tasker_sink_decorator(self) -> Callable[[type[Any]], type[Any]]: - def wrapper(sink_class: type[Any]) -> type[Any]: - sink = sink_class() - if self.tasker is not None: - self.tasker.add_sink(sink) - self.event_sinks.append(sink) - return sink_class - - return wrapper - - def _embedded_context_sink_decorator(self) -> Callable[[type[Any]], type[Any]]: - def wrapper(sink_class: type[Any]) -> type[Any]: - sink = sink_class() - if self.tasker is not None and hasattr(self.tasker, "add_context_sink"): - self.tasker.add_context_sink(sink) - self.event_sinks.append(sink) - return sink_class - - return wrapper - - def _register_embedded_implicit_sink(self, item: _EmbeddedAgentScanItem) -> None: - if item.class_name is None or item.sink_kind is None: - return - module = sys.modules.get(item.module_name) - if module is None: - return - sink_class = getattr(module, item.class_name, None) - if sink_class is None: - self.send_log( - f"[Embedded Agent] 跳过不存在的 sink: {item.module_name}.{item.class_name}" - ) - return - - sink = sink_class() - if item.sink_kind == "resource_sink": - if self.resource is not None: - self.resource.add_sink(sink) - self.event_sinks.append(sink) - return - if item.sink_kind == "controller_sink": - if self.controller is not None: - self.controller.add_sink(sink) - self.event_sinks.append(sink) - return - if item.sink_kind == "tasker_sink": - if self.tasker is not None: - self.tasker.add_sink(sink) - self.event_sinks.append(sink) - return - if item.sink_kind == "context_sink": - if self.tasker is not None and hasattr(self.tasker, "add_context_sink"): - self.tasker.add_context_sink(sink) - self.event_sinks.append(sink) - - def _add_embedded_sys_paths(self, agent_root: Path) -> list[str]: - added_paths: list[str] = [] - for path in (str(agent_root), str(agent_root.parent)): - if path in sys.path: - continue - sys.path.insert(0, path) - added_paths.append(path) - self.embedded_agent_sys_paths.append(path) - return added_paths - - def _remove_embedded_sys_paths(self, paths: list[str]) -> None: - for path in paths: - with suppress(ValueError): - sys.path.remove(path) - with suppress(ValueError): - self.embedded_agent_sys_paths.remove(path) - - @staticmethod - def _purge_embedded_modules(agent_root: Path) -> None: - for module_name, module in list(sys.modules.items()): - if not isinstance(module_name, str): - continue - module_file = getattr(module, "__file__", None) - if not module_file: - continue - try: - if Path(module_file).resolve().is_relative_to(agent_root): - del sys.modules[module_name] - except (OSError, ValueError, KeyError): - continue - - @staticmethod - def _purge_module_name(module_name: str) -> None: - prefix = module_name + "." - for key in list(sys.modules.keys()): - if isinstance(key, str) and (key == module_name or key.startswith(prefix)): - with suppress(KeyError): - del sys.modules[key] - - def _create_agent_client(self, label: str) -> AgentClient: - try: - agent_client = AgentClient() - self.send_log( - f"AgentClient 使用 IPC 模式: " - f"{label}, identifier={agent_client.identifier}" - ) - return agent_client - except Exception as exc: - if os.name == "nt": - try: - agent_client = AgentClient.create_tcp() - self.send_log( - f"AgentClient IPC 模式创建失败,已回退 TCP: " - f"{label}, identifier={agent_client.identifier}" - ) - return agent_client - except Exception as tcp_exc: - raise RuntimeError( - f"创建 AgentClient 失败: {label}: IPC={exc}; TCP={tcp_exc}" - ) from tcp_exc - raise RuntimeError(f"创建 AgentClient 失败: {label}: {exc}") from exc - - def _connect_agent_client( - self, - agent_client: AgentClient, - process: subprocess.Popen, - label: str, - ) -> None: - last_error: Exception | None = None - if not agent_client.set_timeout(AGENT_CONNECT_TIMEOUT_MS): - self.send_log(f"AgentClient 设置连接超时失败: {label}") - for attempt in range(1, AGENT_CONNECT_RETRY_COUNT + 1): - exit_code = process.poll() - if exit_code is not None: - raise RuntimeError(f"Agent 进程已退出,无法连接: {label}, exit={exit_code}") - - try: - if agent_client.connect(): - if not agent_client.set_timeout(-1): - self.send_log(f"AgentClient 恢复运行超时失败: {label}") - if attempt > 1: - self.send_log(f"AgentClient 已连接: {label}, 尝试次数 {attempt}") - return - except Exception as exc: - last_error = exc - - time.sleep(AGENT_CONNECT_RETRY_INTERVAL) - - detail = f": {last_error}" if last_error else "" - raise RuntimeError(f"AgentClient 连接超时: {label}{detail}") - - def _start_agent_output_reader( - self, - process: subprocess.Popen, - label: str, - ) -> None: - if process.stdout is None: - return - - thread = threading.Thread( - target=self._read_agent_output, - args=(process.stdout, label), - name=f"maafw-agent-log-{process.pid}", - daemon=True, - ) - thread.start() - self.agent_output_threads.append(thread) - - def _read_agent_output(self, stream: BinaryIO | TextIO, label: str) -> None: - try: - while True: - line = stream.readline() - if line in ("", b""): - break - message = self._decode_agent_output_line(line).rstrip() - if message: - self.send_log(f"[Agent:{label}] {message}") - except ValueError: - return - except Exception as exc: - self.send_log(f"读取 agent 输出失败: {exc}") - - @staticmethod - def _decode_agent_output_line(line: bytes | str) -> str: - text = decode_bytes(line) if isinstance(line, bytes) else line - return ANSI_ESCAPE_RE.sub("", text) - - def _install_resource_sink(self) -> None: - try: - sink = _MaaFWResourceLogSink(self.send_log) - if self.resource.add_sink(sink) is not None: - self.event_sinks.append(sink) - except Exception as exc: - self.send_log(f"注册 MaaFW resource 日志监听失败: {exc}") - - def _install_controller_sink(self, controller: Controller) -> None: - try: - sink = _MaaFWControllerLogSink(self.send_log) - if controller.add_sink(sink) is not None: - self.event_sinks.append(sink) - except Exception as exc: - self.send_log(f"注册 MaaFW controller 日志监听失败: {exc}") - - def _install_tasker_sink(self) -> None: - try: - sink = _MaaFWTaskerLogSink( - self.send_log, - self._record_task_failure_summary, - ) - if self.tasker.add_sink(sink) is not None: - self.event_sinks.append(sink) - except Exception as exc: - self.send_log(f"注册 MaaFW tasker 日志监听失败: {exc}") - - def _build_agent_env(self, agent_plan: Any) -> dict[str, str]: - """构造 agent 子进程环境,严格隔离 AUTO-MAS 自身环境。 - - 清理 VIRTUAL_ENV、PYTHONHOME、旧 PYTHONPATH 等会导致串环境的变量, - 再显式设置当前项目所需的 PYTHONPATH;PATH 前置 agent Python 目录、 - Scripts 目录、项目根目录与项目必要 dll 目录。 - """ - env = os.environ.copy() - env.update(self.plan.piEnv) - - project_path = Path(self.plan.path) - - # 清理 AUTO-MAS 自身环境变量,防止 agent 串到 MAS .venv - env.pop("VIRTUAL_ENV", None) - env.pop("PYTHONHOME", None) - env.pop("PYTHONUSERBASE", None) - env.pop("PIP_TARGET", None) - env.pop("PIP_PREFIX", None) - env.pop("PIP_USER", None) - # 不继承 MAS 的 PYTHONPATH,显式设置为当前项目根目录 - python_path_items: list[str] = [] - if getattr(agent_plan, "runtimeKind", None) == "isolated_venv": - venv_path_str = getattr(agent_plan, "isolatedVenvPath", None) - if venv_path_str: - try: - python_path_items.append( - str(_write_agent_compat_shims(Path(venv_path_str))) - ) - except Exception as exc: - self.send_log(f"[Python环境] 写入 Agent 兼容层失败: {exc}") - python_path_items.append(str(project_path)) - env["PYTHONPATH"] = os.pathsep.join(python_path_items) - env["PYTHONIOENCODING"] = "utf-8" - - # PATH 前置:agent Python 目录、Scripts 目录、项目根目录、项目必要 dll 目录 - python_exe = Path(agent_plan.executable) - path_items: list[str] = [] - python_dir = python_exe.parent - if python_dir.is_dir(): - path_items.append(str(python_dir)) - scripts_dir = python_dir / ("Scripts" if os.name == "nt" else "bin") - if scripts_dir.is_dir(): - path_items.append(str(scripts_dir)) - path_items.append(str(project_path)) - for parts in AGENT_ENV_PATH_DIRS: - candidate = project_path.joinpath(*parts) - if candidate.is_dir(): - path_items.append(str(candidate)) - - current_path = env.get("PATH", "") - env["PATH"] = ( - os.pathsep.join([*path_items, current_path]) - if current_path - else os.pathsep.join(path_items) - ) - return env - - def _prepare_agent_project_dirs(self) -> None: - project_path = Path(self.plan.path) - try: - for dir_name in AGENT_PROJECT_RUNTIME_DIRS: - (project_path / dir_name).mkdir(exist_ok=True) - except Exception as exc: - raise RuntimeError(f"准备 MaaFW agent 运行目录失败: {exc}") from exc - - def _prepare_agent_python_env(self, agent_plan: Any) -> None: - """在启动 agent 子进程前准备 Python 环境,严格按 runtime_kind 分支处理。 - - - project_python: 使用项目自带 Python,仅检查健康状态,不自动改 release 目录 - - isolated_venv: 创建/复用项目专属隔离 venv,安装项目 requirements.txt - - external: 用户自备环境,不做任何操作 - - 绝不使用 AUTO-MAS 自身 Python 替代项目 Python,绝不污染 MAS .venv。 - """ - runtime_kind = getattr(agent_plan, "runtimeKind", None) - python_exe = agent_plan.command[0] - project_path = Path(self.plan.path) - self.send_log( - f"[Python环境] Agent {agent_plan.childExec} 使用 " - f"{runtime_kind or 'external'}: {python_exe}" - ) - - try: - resolved_python = str(Path(python_exe).resolve()) - except Exception: - resolved_python = python_exe - - if resolved_python in self._python_env_checked: - self.send_log(f"[Python环境] 已检查过该 Python,跳过重复检查: {python_exe}") - return - - if runtime_kind == "isolated_venv": - self._prepare_isolated_venv_env(agent_plan, project_path) - self._python_env_checked[resolved_python] = True - return - - if runtime_kind == "project_python": - self._prepare_project_python_env(python_exe, project_path) - self._python_env_checked[resolved_python] = True - return - - # external 或未知 runtime_kind:用户自备环境,不做任何操作 - self.send_log(f"[Python环境] 跳过外部环境检测: {python_exe}") - - def _prepare_project_python_env( - self, - python_exe: str, - project_path: Path, - ) -> None: - """准备项目自带 Python 环境:只检测健康状态,不自动修改 release。 - - 项目自带 Python 属于用户提供的 MaaFW release 内容。这里不运行 - ensurepip/pip install,不升级 maafw,也不补装依赖,避免持久修改 release 目录。 - """ - self.send_log(f"[Python环境] 检测项目 Python: {python_exe}") - test_env = self._build_agent_env_for_pip(project_path) - - pip_ok = self._check_pip_health( - python_exe, cwd=str(project_path), env=test_env - ) - if not pip_ok: - raise RuntimeError( - f"项目 Python 环境 pip 不可用,请手动修复后重试:\n" - f" Python 路径: {python_exe}\n" - f" 处理建议:\n" - f" 方法1: 重新下载并解压完整 MaaFW 项目包\n" - f" 方法2: 在项目目录中手动修复该项目自带 Python 的 pip 环境\n" - f" AUTO-MAS 不会自动修改项目 release 目录。" - ) - - def _prepare_isolated_venv_env( - self, - agent_plan: Any, - project_path: Path, - ) -> None: - """准备项目专属隔离 venv 环境。 - - 使用 AUTO-MAS 的 sys.executable 引导创建 venv,但 agent 实际运行在 - 隔离 venv 中,不会污染 AUTO-MAS 自身 .venv。依赖声明来自 - MaaFW 项目自己的 requirements.txt。 - """ - venv_path_str = getattr(agent_plan, "isolatedVenvPath", None) - if not venv_path_str: - raise RuntimeError("隔离 venv 路径未提供,无法创建隔离环境") - - venv_path = Path(venv_path_str) - python_exe = agent_plan.command[0] - - self.send_log(f"[Python环境] 准备隔离 venv: {venv_path}") - had_valid_venv = _is_valid_venv_path(venv_path) - if self._should_rebuild_isolated_venv(venv_path, project_path): - self._reset_isolated_venv(venv_path) - had_valid_venv = False - self._ensure_isolated_venv(venv_path) - _write_agent_compat_shims(venv_path) - - test_env = self._build_agent_env_for_pip(project_path) - # 隔离 venv 的 PYTHONPATH 指向项目根目录 - test_env["PYTHONPATH"] = str(project_path) - - pip_ok = self._check_pip_health( - python_exe, cwd=str(project_path), env=test_env - ) - if not pip_ok: - self.send_log("[Python环境] 隔离 venv pip 异常,尝试 ensurepip 修复...") - if not self._try_ensurepip( - python_exe, cwd=str(project_path), env=test_env - ): - raise RuntimeError( - f"隔离 venv pip 无法自动修复: {python_exe}" - ) - - if had_valid_venv and self._is_isolated_venv_manifest_current( - venv_path, - project_path, - ): - self.send_log("[Python环境] 隔离 venv 依赖清单未变化,跳过 pip install") - return - - # 隔离 venv 安装 MaaFW 项目自己的 requirements.txt - if not self._ensure_agent_packages( - python_exe, - runtime_kind="isolated_venv", - project_path=project_path, - cwd=str(project_path), - env=test_env, - ): - raise RuntimeError(f"隔离 venv 依赖安装失败: {python_exe}") - self._write_isolated_venv_manifest(venv_path, project_path) - - def _is_isolated_venv_manifest_current( - self, - venv_path: Path, - project_path: Path, - ) -> bool: - manifest_path = venv_path / AGENT_ENV_MANIFEST_NAME - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except Exception: - return False - - expected = _build_agent_env_manifest(project_path) - return ( - manifest.get("projectPath") == expected["projectPath"] - and manifest.get("interfaceHash") == expected["interfaceHash"] - and manifest.get("requirementsHash") == expected["requirementsHash"] - ) - - def _should_rebuild_isolated_venv( - self, - venv_path: Path, - project_path: Path, - ) -> bool: - if venv_path.exists() and not _is_valid_venv_path(venv_path): - self.send_log("[Python环境] 隔离 venv 不完整,将重建") - return True - - if not _is_valid_venv_path(venv_path): - return False - - manifest_path = venv_path / AGENT_ENV_MANIFEST_NAME - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except FileNotFoundError: - self.send_log("[Python环境] 隔离 venv 缺少依赖清单,将重建") - return True - except Exception as exc: - self.send_log(f"[Python环境] 隔离 venv 依赖清单异常,将重建: {exc}") - return True - - expected = _build_agent_env_manifest(project_path) - if manifest.get("projectPath") != expected["projectPath"]: - self.send_log("[Python环境] 隔离 venv 项目路径已变化,将重建") - return True - if manifest.get("interfaceHash") != expected["interfaceHash"]: - self.send_log("[Python环境] MaaFW 项目 interface 已变化,将重建隔离 venv") - return True - if manifest.get("requirementsHash") != expected["requirementsHash"]: - self.send_log("[Python环境] MaaFW 项目 requirements 已变化,将重建隔离 venv") - return True - return False - - def _reset_isolated_venv(self, venv_path: Path) -> None: - if venv_path.parent.name != "maafw_agent_venvs" or not venv_path.name.startswith( - "maafw_venv_" - ): - raise RuntimeError(f"拒绝重建非托管隔离 venv: {venv_path}") - shutil.rmtree(venv_path, ignore_errors=True) - self.send_log(f"[Python环境] 已清理旧隔离 venv: {venv_path}") - - def _write_isolated_venv_manifest( - self, - venv_path: Path, - project_path: Path, - ) -> None: - manifest_path = venv_path / AGENT_ENV_MANIFEST_NAME - manifest_path.write_text( - json.dumps( - _build_agent_env_manifest(project_path), - ensure_ascii=False, - indent=2, - ), - encoding="utf-8", - ) - - def _ensure_isolated_venv(self, venv_path: Path) -> None: - """创建或复用项目专属隔离 venv。 - - 使用便携包基础 Python 或 AUTO-MAS 的 sys.executable 引导创建 venv(仅用于 venv 创建), - agent 实际运行在隔离 venv 中,不会污染 AUTO-MAS 自身 .venv。 - """ - if _is_valid_venv_path(venv_path): - self.send_log(f"[Python环境] 隔离 venv 已存在: {venv_path}") - return - - if venv_path.exists(): - self._reset_isolated_venv(venv_path) - - venv_path.parent.mkdir(parents=True, exist_ok=True) - bootstrap_python = _venv_bootstrap_python() - self.send_log( - f"[Python环境] 创建隔离 venv: {venv_path} " - f"(引导 Python: {bootstrap_python})" - ) - try: - result = subprocess.run( - [ - bootstrap_python, - "-m", - "venv", - str(venv_path), - ], - capture_output=True, - timeout=PIP_INSTALL_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() - raise RuntimeError( - f"创建隔离 venv 失败 (exit={result.returncode}): {detail[:500]}" - ) - if not _is_valid_venv_path(venv_path): - raise RuntimeError(f"创建隔离 venv 后结构不完整: {venv_path}") - self.send_log(f"[Python环境] 隔离 venv 创建成功: {venv_path}") - except subprocess.TimeoutExpired: - raise RuntimeError( - f"创建隔离 venv 超时 ({PIP_INSTALL_TIMEOUT}s): {venv_path}" - ) - - def _build_agent_env_for_pip(self, project_path: Path) -> dict[str, str]: - """构建与 agent 运行时一致的环境变量(清理 MAS 环境变量),用于 pip 检测。 - - 与 _build_agent_env 不同的是,此方法不依赖 agent_plan,用于 pip 检测阶段。 - """ - env = os.environ.copy() - env.pop("VIRTUAL_ENV", None) - env.pop("PYTHONHOME", None) - env.pop("PYTHONUSERBASE", None) - env.pop("PIP_TARGET", None) - env.pop("PIP_PREFIX", None) - env.pop("PIP_USER", None) - env["PYTHONPATH"] = str(project_path) - return env - - def _check_pip_health(self, python_exe: str, *, cwd: str | None = None, env: dict[str, str] | None = None) -> bool: - """检测 pip 是否能正常执行 install 命令(模拟 agent 真实使用场景)。 - - 使用 python -c 尝试加载 pip._internal.commands.install, - 因为 pip --version 不会加载 install 子模块,无法检测到 backports.zstd 冲突。 - """ - try: - # 先做简单的 --version 检测 - result = subprocess.run( - [python_exe, "-m", "pip", "--version"], - capture_output=True, - timeout=PIP_HEALTH_CHECK_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - cwd=cwd, - env=env, - ) - if result.returncode != 0: - error_detail = (result.stderr or result.stdout or "").strip() - self.send_log(f"[Python环境] pip --version 失败 (exit={result.returncode}): {error_detail[:500]}") - return False - - # 关键检测:尝试加载 install 子命令(这是真正会触发 backports.zstd 崩溃的地方) - result2 = subprocess.run( - [ - python_exe, "-c", - "from pip._internal.commands.install import InstallCommand; print('install command OK')", - ], - capture_output=True, - timeout=PIP_HEALTH_CHECK_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - cwd=cwd, - env=env, - ) - if result2.returncode == 0: - version_info = result.stdout.strip() - self.send_log(f"[Python环境] pip 健康: {version_info}") - return True - - error_detail = (result2.stderr or result2.stdout or "").strip() - zstd_err = "backports.zstd" in error_detail or "ZstdError" in error_detail - if zstd_err: - self.send_log("[Python环境] pip install 子命令加载失败(backports.zstd 冲突)") - else: - self.send_log(f"[Python环境] pip install 检测失败 (exit={result2.returncode}): {error_detail[:500]}") - return False - except subprocess.TimeoutExpired: - self.send_log(f"[Python环境] pip 检测超时 ({PIP_HEALTH_CHECK_TIMEOUT}s)") - return False - except Exception as exc: - self.send_log(f"[Python环境] pip 检测异常: {exc}") - return False - - def _try_ensurepip(self, python_exe: str, *, cwd: str | None = None, env: dict[str, str] | None = None) -> bool: - """尝试 python -m ensurepip --upgrade 修复 pip。""" - self.send_log("[Python环境] 修复策略 A (ensurepip)...") - try: - result = subprocess.run( - [python_exe, "-m", "ensurepip", "--upgrade"], - capture_output=True, - timeout=PIP_INSTALL_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - cwd=cwd, - env=env, - ) - if result.returncode == 0 and self._check_pip_health(python_exe, cwd=cwd, env=env): - self.send_log("[Python环境] ensurepip 修复成功") - return True - detail = (result.stderr or result.stdout or "").strip() - self.send_log(f"[Python环境] ensurepip 未成功: {detail[:300]}") - except subprocess.TimeoutExpired: - self.send_log(f"[Python环境] ensurepip 超时 ({PIP_INSTALL_TIMEOUT}s)") - except Exception as exc: - self.send_log(f"[Python环境] ensurepip 执行异常: {exc}") - return False - - def _ensure_agent_packages( - self, - python_exe: str, - *, - runtime_kind: str, - project_path: Path | None = None, - cwd: str | None = None, - env: dict[str, str] | None = None, - ) -> bool: - """按 runtime_kind 预安装 agent 依赖,绝不无版本升级 maafw。 - - - project_python: 用户自带环境,不自动安装依赖 - - isolated_venv: 安装 MaaFW 项目自己的 requirements.txt - """ - if runtime_kind == "project_python": - self.send_log("[Python环境] 项目自带 Python 不自动安装依赖") - return True - - if runtime_kind == "isolated_venv": - if project_path is None: - raise RuntimeError("隔离 venv 依赖安装缺少 MaaFW 项目路径") - packages = _load_project_agent_requirements(project_path) - self.send_log( - f"[Python环境] 隔离 venv 安装项目依赖: {', '.join(packages)}" - ) - return self._pip_install( - python_exe, - packages, - cwd=cwd, - env=env, - ) - - self.send_log( - f"[Python环境] runtime_kind={runtime_kind},跳过依赖安装" - ) - return True - - def _pip_install( - self, - python_exe: str, - packages: list[str], - *, - cwd: str | None = None, - env: dict[str, str] | None = None, - ) -> bool: - """执行 pip install(不带 --upgrade),返回是否成功。""" - try: - result = subprocess.run( - [ - python_exe, - "-m", - "pip", - "install", - "--quiet", - *packages, - ], - capture_output=True, - timeout=PIP_INSTALL_TIMEOUT, - text=True, - encoding="utf-8", - errors="replace", - cwd=cwd, - env=env, - ) - if result.returncode == 0: - self.send_log( - f"[Python环境] pip install 完成: {', '.join(packages)}" - ) - return True - detail = (result.stderr or result.stdout or "").strip() - self.send_log( - f"[Python环境] pip install 未成功(将由 agent 自举尝试): " - f"{detail[:300]}" - ) - except subprocess.TimeoutExpired: - self.send_log( - f"[Python环境] pip install 超时 ({PIP_INSTALL_TIMEOUT}s)," - f"将由 agent 自举尝试" - ) - except Exception as exc: - self.send_log( - f"[Python环境] pip install 异常: {exc},将由 agent 自举尝试" - ) - return False - - def _run_tasks(self) -> list[str]: - completed_tasks: list[str] = [] - self._completed_tasks = completed_tasks - self._failed_task_errors = [] - for task in self.plan.tasks: - if self._stop_requested.is_set(): - raise RuntimeError("MaaFW 任务已停止") - tasker = self.tasker - if tasker is None: - raise RuntimeError("MaaFW tasker 已释放,无法继续投递任务") - self.send_log(_format_task_config_log(task)) - self.send_log(f"正在运行任务: {task.name}") - self._task_failure_summaries.clear() - try: - if task.pipelineOverride: - job = tasker.post_task(task.entry, task.pipelineOverride) - else: - job = tasker.post_task(task.entry) - self._wait_job(job) - except Exception as exc: - if self._stop_requested.is_set(): - raise RuntimeError("MaaFW 任务已停止") from exc - message = str(exc) - self._failed_task_errors.append((task.name, message)) - self.send_log(f"任务失败,将继续后续任务: {task.name}: {message}") - time.sleep(0.1) - continue - if self._stop_requested.is_set(): - raise RuntimeError("MaaFW 任务已停止") - completed_tasks.append(task.name) - self.send_log(f"任务完成: {task.name}") - time.sleep(0.1) - return completed_tasks - - def _completed_task_names(self) -> list[str]: - completed_tasks = getattr(self, "_completed_tasks", []) - return list(completed_tasks) - - def _wait_job(self, job: Job | JobWithResult) -> None: - job.wait() - if job.failed: - detail = None - if isinstance(job, JobWithResult): - detail = job.get() - raise RuntimeError(self._build_job_failure_message(detail)) - - def _record_task_failure_summary(self, message: str, details: dict[str, Any]) -> None: - if message not in MAAFW_FAILURE_EVENT_MESSAGES: - return - summary = _format_maafw_failure_event(message, details) - if not summary: - return - if summary in self._task_failure_summaries: - self._task_failure_summaries.remove(summary) - self._task_failure_summaries.append(summary) - if len(self._task_failure_summaries) > MAAFW_FAILURE_SUMMARY_LIMIT: - del self._task_failure_summaries[:-MAAFW_FAILURE_SUMMARY_LIMIT] - - def _build_job_failure_message(self, detail: Any | None) -> str: - parts = ["任务执行失败"] - detail_summary = _format_maafw_task_detail(detail) - if detail_summary: - parts.append(detail_summary) - if self._task_failure_summaries: - parts.append( - "失败事件: " + ";".join(self._task_failure_summaries[-3:]) - ) - return ": ".join(parts) - -def prepare_maafw_agent_python_envs( - project_path: str | Path, - interface_model: Any, - *, - send_log: Callable[[str], None] | None = None, -) -> list[Any]: - """Prepare MaaFW agent Python envs without loading resources or starting agents.""" - - resolved_project_path = Path(project_path).resolve() - plugin_result = _prepare_maafw_agent_python_envs_from_plugin( - resolved_project_path, - interface_model, - send_log=send_log, - ) - if plugin_result is not None: - return plugin_result - - agent_plans = build_maafw_agent_command_plans( - resolved_project_path, - interface_model.agent, - ) - plan = MaaFWRunPlan( - path=str(resolved_project_path), - projectName=interface_model.name, - projectLabel=getattr(interface_model, "label", None), - controllerName="", - controllerType="Adb", - resourceName="", - resource=MaaFWResourceBundlePlan(name="", label=None), - agents=agent_plans, - piEnv={}, - tasks=[], - skippedTasks=[], - ) - runner = MaaFWRunner(plan, send_log=send_log) - runner.prepare_agent_python_envs() - return agent_plans - - -def _prepare_maafw_agent_python_envs_from_plugin( - project_path: Path, - interface_model: Any, - *, - send_log: Callable[[str], None] | None, -) -> list[Any] | None: - try: - from automas_maafw_agent_env import MaaFWAgentEnvService - except Exception: - return None - - try: - service = MaaFWAgentEnvService() - result = service.prepare_env( - project_path, - interface_model, - send_log=send_log, - ) - return list(getattr(result, "plans", [])) - except Exception as exc: - raise RuntimeError(str(exc)) from exc - - -class _MaaFWResourceLogSink(ResourceEventSink): - def __init__(self, send_log: Callable[[str], None]) -> None: - super().__init__() - self.send_log = send_log - - def on_resource_loading( - self, - resource: Resource, - noti_type: NotificationType, - detail: ResourceEventSink.ResourceLoadingDetail, - ) -> None: - self.send_log( - f"[MaaFW Resource] {_notification_label(noti_type)}: {detail.path}" - ) - - -class _MaaFWControllerLogSink(ControllerEventSink): - def __init__(self, send_log: Callable[[str], None]) -> None: - super().__init__() - self.send_log = send_log - - def on_controller_action( - self, - controller: Controller, - noti_type: NotificationType, - detail: ControllerEventSink.ControllerActionDetail, - ) -> None: - if noti_type == NotificationType.Failed: - self.send_log( - f"[MaaFW Controller] {_notification_label(noti_type)}: {detail.action}" - ) - - -class _MaaFWTaskerLogSink(TaskerEventSink): - def __init__( - self, - send_log: Callable[[str], None], - record_failure: Callable[[str, dict[str, Any]], None], - ) -> None: - super().__init__() - self.send_log = send_log - self.record_failure = record_failure - - def on_tasker_task( - self, - tasker: Tasker, - noti_type: NotificationType, - detail: TaskerEventSink.TaskerTaskDetail, - ) -> None: - self.send_log( - f"[MaaFW Tasker] {_notification_label(noti_type)}: {detail.entry}" - ) - - def on_raw_notification( - self, - tasker: Tasker, - msg: str, - details: dict[str, Any], - ) -> None: - self.record_failure(msg, details) - - -def _notification_label(noti_type: NotificationType) -> str: - if noti_type == NotificationType.Starting: - return "开始" - if noti_type == NotificationType.Succeeded: - return "成功" - if noti_type == NotificationType.Failed: - return "失败" - return "事件" - - -def _format_task_config_log(task: MaaFWTaskRunPlan) -> str: - option_text = json.dumps( - task.logOptions, - ensure_ascii=False, - separators=(",", ":"), - ) - if len(option_text) > TASK_CONFIG_LOG_VALUE_LIMIT: - option_text = option_text[:TASK_CONFIG_LOG_VALUE_LIMIT] + "..." - - override_text = ", ".join(task.overrideNodes[:12]) or "-" - if len(task.overrideNodes) > 12: - override_text += f", ...(+{len(task.overrideNodes) - 12})" - return ( - "MaaFW 任务配置: " - f"name={task.name}; entry={task.entry}; options={option_text}; " - f"override_nodes={override_text}" - ) - - -def _format_maafw_task_detail(detail: Any | None) -> str: - if detail is None: - return "" - - parts: list[str] = [] - entry = getattr(detail, "entry", None) - if entry: - parts.append(f"entry={entry}") - - task_id = getattr(detail, "task_id", None) - if task_id is not None: - parts.append(f"task_id={task_id}") - - status = _format_maafw_status(getattr(detail, "status", None)) - if status: - parts.append(f"status={status}") - - node_id_list = getattr(detail, "node_id_list", None) - if node_id_list: - tail = list(node_id_list)[-5:] - parts.append(f"last_nodes={tail}") - - return ", ".join(parts) - - -def _format_maafw_status(status: Any | None) -> str: - if status is None: - return "" - - status_value = getattr(status, "_status", status) - name = getattr(status_value, "name", None) - value = getattr(status_value, "value", None) - if name is not None and value is not None: - return f"{name}({value})" - if value is not None: - return str(value) - return str(status_value) - - -def _format_maafw_failure_event(message: str, details: dict[str, Any]) -> str: - parts = [message] - - name = details.get("name") or details.get("entry") - if name: - parts.append(str(name)) - - node_details = details.get("node_details") - if isinstance(node_details, dict): - node_name = node_details.get("name") - node_id = node_details.get("node_id") - if node_name and node_id is not None: - parts.append(f"node={node_name}({node_id})") - elif node_name: - parts.append(f"node={node_name}") - - action_details = details.get("action_details") - if isinstance(action_details, dict): - action_name = action_details.get("name") - if action_name: - parts.append(f"action={action_name}") - - texts = _collect_maafw_detail_texts(details) - if texts: - parts.append("text=" + " / ".join(texts[:3])) - - focus = details.get("focus") - focus_texts = _collect_maafw_focus_texts(focus) - if focus_texts: - parts.append("focus=" + " / ".join(focus_texts[:2])) - - return ", ".join(parts) - - -def _collect_maafw_detail_texts(value: Any) -> list[str]: - texts: list[str] = [] - - def walk(item: Any) -> None: - if len(texts) >= 3: - return - if isinstance(item, dict): - text = item.get("text") - if isinstance(text, str) and text and text not in texts: - texts.append(_short_maafw_text(text)) - for child in item.values(): - walk(child) - elif isinstance(item, list): - for child in item: - walk(child) - - walk(value) - return texts - - -def _collect_maafw_focus_texts(focus: Any) -> list[str]: - if isinstance(focus, dict): - return [ - _short_maafw_text(str(value)) - for value in focus.values() - if value - ] - if isinstance(focus, str) and focus: - return [_short_maafw_text(focus)] - return [] - - -def _short_maafw_text(text: str, limit: int = 80) -> str: - sanitized = " ".join(text.split()) - if len(sanitized) <= limit: - return sanitized - return sanitized[: limit - 3] + "..." diff --git a/plugins/automas_maafw_runner/src/automas_maafw_runner/service.py b/plugins/automas_maafw_runner/src/automas_maafw_runner/service.py deleted file mode 100644 index bc894980..00000000 --- a/plugins/automas_maafw_runner/src/automas_maafw_runner/service.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import json -import subprocess -import sys -import uuid -from pathlib import Path -from typing import Any, Callable - -from automas_maafw_interface.models import MaaFWInterface - -from .environment import MaaFWRunnerEnvironment, prepare_runner_environment -from .models import MaaFWDeviceConfig, MaaFWRunnerJobPayload, MaaFWRunPlan, MaaFWRunResult -from .run_plan import build_maafw_run_plan - - -class MaaFWRunnerService: - """maafw.runner.v1 service.""" - - def build_plan( - self, - project_path: str | Path, - interface: MaaFWInterface | dict[str, Any], - *, - controller_name: str | None = None, - resource_name: str | None = None, - selected_preset: str | None = None, - task_snapshot: dict[str, Any] | None = None, - task_names: list[str] | None = None, - task_options: dict[str, Any] | None = None, - managed_env_root: str | Path | None = None, - ) -> MaaFWRunPlan: - return build_maafw_run_plan( - project_path, - self._coerce_interface(interface), - controller_name=controller_name, - resource_name=resource_name, - selected_preset=selected_preset, - task_snapshot=task_snapshot, - task_names=task_names, - task_options=task_options, - managed_env_root=managed_env_root, - ) - - def create_job_payload( - self, - plan: MaaFWRunPlan | dict[str, Any], - device_config: MaaFWDeviceConfig | dict[str, Any], - ) -> MaaFWRunnerJobPayload: - return MaaFWRunnerJobPayload( - plan=plan if isinstance(plan, MaaFWRunPlan) else MaaFWRunPlan.model_validate(plan), - deviceConfig=( - device_config - if isinstance(device_config, MaaFWDeviceConfig) - else MaaFWDeviceConfig.model_validate(device_config) - ), - ) - - def prepare_environment( - self, - project_path: str | Path, - *, - managed_env_root: str | Path | None = None, - import_paths: list[str | Path] | None = None, - send_log: Callable[[str], None] | None = None, - ) -> MaaFWRunnerEnvironment: - return prepare_runner_environment( - project_path, - managed_env_root=managed_env_root, - import_paths=import_paths or [], - send_log=send_log, - ) - - def write_job_file( - self, - payload: MaaFWRunnerJobPayload, - work_dir: str | Path, - *, - job_name: str | None = None, - ) -> Path: - path = Path(work_dir).resolve() - path.mkdir(parents=True, exist_ok=True) - name = job_name or f"maafw-runner-job-{uuid.uuid4().hex}.json" - job_path = path / name - job_path.write_text( - json.dumps(payload.model_dump(mode="json"), ensure_ascii=False, indent=2), - encoding="utf-8", - ) - return job_path - - def run_worker( - self, - payload: MaaFWRunnerJobPayload, - *, - work_dir: str | Path, - worker_command: list[str] | None = None, - send_log: Callable[[str], None] | None = None, - timeout: float | None = None, - ) -> MaaFWRunResult: - log = send_log or (lambda _: None) - job_path = self.write_job_file(payload, work_dir) - command = worker_command or [sys.executable, "-m", "automas_maafw_runner.worker"] - process = subprocess.Popen( - [*command, str(job_path)], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - ) - result_payload: dict[str, Any] | None = None - try: - assert process.stdout is not None - for line in process.stdout: - line = line.strip() - if not line: - continue - try: - event = json.loads(line) - except json.JSONDecodeError: - log(line) - continue - event_type = event.get("type") - if event_type == "log": - log(str(event.get("message") or "")) - elif event_type == "result" and isinstance(event.get("data"), dict): - result_payload = event["data"] - elif event_type == "error": - log(str(event.get("message") or "")) - process.wait(timeout=timeout) - finally: - if process.poll() is None: - process.terminate() - - if result_payload is not None: - return MaaFWRunResult.model_validate(result_payload) - return MaaFWRunResult( - success=False, - projectName=payload.plan.projectName, - controllerName=payload.plan.controllerName, - resourceName=payload.plan.resourceName, - errorMessage=f"MaaFW runner worker exited without result: {process.returncode}", - ) - - @staticmethod - def _coerce_interface(interface: MaaFWInterface | dict[str, Any]) -> MaaFWInterface: - if isinstance(interface, MaaFWInterface): - return interface - if hasattr(interface, "model_dump"): - return MaaFWInterface.model_validate(interface.model_dump(mode="json", by_alias=True)) - return MaaFWInterface.model_validate(interface) diff --git a/plugins/automas_maafw_runner/src/automas_maafw_runner/worker.py b/plugins/automas_maafw_runner/src/automas_maafw_runner/worker.py deleted file mode 100644 index 0880cb10..00000000 --- a/plugins/automas_maafw_runner/src/automas_maafw_runner/worker.py +++ /dev/null @@ -1,91 +0,0 @@ -# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software -# Copyright 漏 2025-2026 AUTO-MAS Team - -# This file is part of AUTO-MAS. - -# AUTO-MAS is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. - -# AUTO-MAS is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with AUTO-MAS. If not, see . - - -from __future__ import annotations - -import json -import sys -from contextlib import suppress -from pathlib import Path -from typing import Any - -for stream in (sys.stdout, sys.stderr): - with suppress(Exception): - stream.reconfigure(encoding="utf-8", errors="replace") - -if __package__ in (None, ""): - sys.path.insert(0, str(Path(__file__).resolve().parent)) - from environment import prefer_active_venv_site_packages # type: ignore[no-redef] - - # 桌面插件目录也可能安装 MaaFW。先把项目 Runner venv 放到 sys.path 最前, - # 避免共享插件依赖覆盖项目 requirements 声明的 MaaFW 版本。 - prefer_active_venv_site_packages() - from run_plan import MaaFWRunPlan # type: ignore[no-redef] - from runner import MaaFWDeviceConfig, MaaFWRunResult, MaaFWRunner # type: ignore[no-redef] -else: - from .environment import prefer_active_venv_site_packages - - prefer_active_venv_site_packages() - from .run_plan import MaaFWRunPlan - from .runner import MaaFWDeviceConfig, MaaFWRunResult, MaaFWRunner - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False), flush=True) - - -def _emit_log(message: str) -> None: - _emit({"type": "log", "message": message}) - - -def main() -> int: - if len(sys.argv) != 2: - _emit({"type": "error", "message": "runner worker requires one job json path"}) - return 64 - - job_path = Path(sys.argv[1]) - runner: MaaFWRunner | None = None - try: - payload = json.loads(job_path.read_text(encoding="utf-8")) - plan = MaaFWRunPlan.model_validate(payload["plan"]) - device_config = MaaFWDeviceConfig.model_validate(payload["deviceConfig"]) - runner = MaaFWRunner(plan, send_log=_emit_log) - result = runner.run(device_config) - _emit({"type": "result", "data": result.model_dump(mode="json")}) - return 0 if result.success else 2 - except BaseException as exc: # noqa: BLE001 - keep worker boundary explicit. - result = MaaFWRunResult( - success=False, - projectName="", - controllerName="", - resourceName="", - errorMessage=str(exc), - ) - with suppress(Exception): - _emit({"type": "result", "data": result.model_dump(mode="json")}) - _emit({"type": "error", "message": str(exc)}) - return 1 - finally: - if runner is not None: - with suppress(Exception): - runner.shutdown() - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/automas_script_maafw/README.md b/plugins/automas_script_maafw/README.md deleted file mode 100644 index acfd8a92..00000000 --- a/plugins/automas_script_maafw/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# automas-script-maafw - -MaaFW script adapter plugin for AUTO-MAS. - -It registers `ScriptType=MaaFW` through the script adapter registry and stores -new MaaFW scripts in `PluginScriptConfig`. M9A is provided by the -`automas-script-maafw-pack-m9a` project pack, and that pack registers the -user-visible `ScriptType=M9A` entry while reusing this adapter's runtime hooks. diff --git a/plugins/automas_script_maafw/pyproject.toml b/plugins/automas_script_maafw/pyproject.toml deleted file mode 100644 index 90b402f8..00000000 --- a/plugins/automas_script_maafw/pyproject.toml +++ /dev/null @@ -1,28 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "automas-script-maafw" -version = "0.1.1" -description = "MaaFW script adapter plugin for AUTO-MAS" -readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" -dependencies = [ - "automas-maafw-agent-env>=0.1.0", - "automas-maafw-controller-adb>=0.1.0", - "automas-maafw-controller-win32>=0.1.0", - "automas-maafw-interface>=0.1.1", - "automas-maafw-project-update>=0.1.0", - "automas-maafw-runner>=0.1.1", - "pydantic>=2", -] - -[project.entry-points."auto_mas.plugins"] -automas_script_maafw = "automas_script_maafw.plugin:Plugin" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -automas_script_maafw = ["py.typed"] diff --git a/plugins/automas_script_maafw/src/automas_script_maafw/__init__.py b/plugins/automas_script_maafw/src/automas_script_maafw/__init__.py deleted file mode 100644 index bdec2fc8..00000000 --- a/plugins/automas_script_maafw/src/automas_script_maafw/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -__all__: list[str] = [] diff --git a/plugins/automas_script_maafw/src/automas_script_maafw/adapter.py b/plugins/automas_script_maafw/src/automas_script_maafw/adapter.py deleted file mode 100644 index 2a48ae00..00000000 --- a/plugins/automas_script_maafw/src/automas_script_maafw/adapter.py +++ /dev/null @@ -1,278 +0,0 @@ -from __future__ import annotations - -import asyncio -import copy -import json -import uuid -from datetime import datetime -from pathlib import Path -from typing import Any, TypeVar - -from app.core import Config -from app.models.ConfigBase import ConfigBase, JSONValidator, MultipleConfig -from app.models.config import MaaFWConfig, MaaFWUserConfig -from app.models.task import ScriptItem, TaskExecuteBase, UserItem -from app.plugins import ScriptAdapterHooks, ScriptAdapterRuntime -from app.utils import get_logger -from automas_maafw_interface.loader import MaaFWInterfaceLoadError -from automas_maafw_interface.service import MaaFWInterfaceService -from automas_maafw_project_update.service import MaaFWProjectUpdateService - -from .runner_task import MaaFWPluginAutoProxyTask -from .schema import build_source_config - - -LegacyConfigT = TypeVar("LegacyConfigT", bound=ConfigBase) - - -logger = get_logger("MaaFW 插件适配") - - -class MaaFWAdapterHooks(ScriptAdapterHooks): - """MaaFW script adapter backed by PluginScriptConfig.""" - - async def check(self, runtime: ScriptAdapterRuntime) -> str: - if runtime.mode != "AutoProxy": - return "MaaFW 插件当前仅支持 AutoProxy 模式" - - script_data = await runtime.storage.read_script_data() - script_config = await _load_legacy_script_config(script_data) - raw_project_path = str(script_config.get("Info", "Path") or "").strip() - if not raw_project_path: - return "请设置 MaaFW 项目路径" - - project_path = Path(raw_project_path) - if not project_path.exists(): - return "请设置 MaaFW 项目路径" - - try: - interface = MaaFWInterfaceService().load(project_path) - except MaaFWInterfaceLoadError as exc: - return f"无法读取 MaaFW interface,请检查项目路径: {exc}" - - if not interface.controller: - return "MaaFW interface 未声明 controller,请检查项目目录" - if not interface.resource: - return "MaaFW interface 未声明 resource,请检查项目目录" - if not interface.task: - return "MaaFW interface 未声明 task,请检查项目目录" - - emulator_id = script_config.get("Emulator", "Id") - emulator_index = script_config.get("Emulator", "Index") - if emulator_id != "-" and emulator_index in ("", "-"): - return "请在 MaaFW 脚本配置中选择模拟器实例" - return "Pass" - - async def prepare(self, runtime: ScriptAdapterRuntime) -> None: - await runtime.storage.lock() - - script_data = await runtime.storage.read_script_data() - script_config = await _load_legacy_script_config(script_data) - user_config = await _load_legacy_user_config(runtime) - - runtime.script_config = script_config - runtime.user_config = user_config - runtime.extra["maafw_project_update_logs"] = [] - - await self._update_project_before_run(runtime, script_config) - - emulator_manager = None - emulator_id = script_config.get("Emulator", "Id") - if emulator_id != "-": - emulator_manager = await runtime.initialize_emulator_manager(emulator_id) - runtime.emulator_manager = emulator_manager - - runtime.script_info.user_list = [ - UserItem( - user_id=str(uid), - name=config.get("Info", "Name"), - status="等待", - ) - for uid, config in user_config.items() - if config.get("Info", "Status") - and config.get("Info", "RemainedDay") != 0 - ] - self._emit_log( - runtime, - f"MaaFW 插件用户列表加载完成,已筛选用户数: {len(runtime.script_info.user_list)}", - ) - - def run_auto_proxy(self, runtime: ScriptAdapterRuntime) -> TaskExecuteBase: - if runtime.script_config is None or runtime.user_config is None: - raise RuntimeError("MaaFW 插件配置尚未准备") - return MaaFWPluginAutoProxyTask( - runtime.script_info, - runtime.script_config, - runtime.user_config, - runtime.emulator_manager, - list(runtime.extra.get("maafw_project_update_logs") or []), - ) - - async def finalize(self, runtime: ScriptAdapterRuntime) -> None: - previous_status = runtime.script_info.status - try: - if runtime.user_config is not None and runtime.mode == "AutoProxy": - # prepare() 锁定了整棵配置树(含 UserData),写回前必须先解锁, - # 否则 PluginUserConfig.set() 会抛出 "配置已锁定, 无法修改" - await runtime.storage.unlock() - await runtime.storage.save_user_models(runtime.user_config) - finally: - await runtime.storage.unlock() - - if previous_status == "异常" or runtime.check_result not in ("-", "", "Pass"): - runtime.script_info.status = "异常" - return - - error_user = [u.name for u in runtime.script_info.user_list if u.status == "异常"] - over_user = [u.name for u in runtime.script_info.user_list if u.status == "完成"] - if error_user: - runtime.script_info.status = "异常" - elif over_user: - runtime.script_info.status = "完成" - else: - runtime.script_info.status = "跳过" - - async def on_crash(self, runtime: ScriptAdapterRuntime, error: Exception) -> None: - try: - await runtime.storage.unlock() - except Exception: - pass - await super().on_crash(runtime, error) - - async def _update_project_before_run( - self, - runtime: ScriptAdapterRuntime, - script_config: MaaFWConfig, - ) -> None: - if not script_config.get("Update", "IfAutoUpdate"): - self._emit_log(runtime, "MaaFW 项目运行前自动更新已关闭") - return - - project_path = Path(script_config.get("Info", "Path")).resolve() - try: - interface_model = MaaFWInterfaceService().load(project_path) - except MaaFWInterfaceLoadError as exc: - self._emit_log(runtime, f"MaaFW 项目更新跳过,interface 读取失败: {exc}") - return - - script_data = await runtime.storage.read_script_data() - source_config = build_source_config(script_data) - mirror_cdk = ( - script_config.get("Update", "MirrorChyanCDK") - or Config.get("Update", "MirrorChyanCDK") - ) - channel = script_config.get("Update", "Channel") or Config.get("Update", "Channel") - try: - update_result = await MaaFWProjectUpdateService().update_if_needed( - project_path, - interface_model, - mirror_cdk=mirror_cdk, - channel=channel, - proxy=Config.proxy, - send_log=lambda message: self._emit_log(runtime, message), - source_config=source_config, - ) - if update_result.updated: - refreshed_interface = MaaFWInterfaceService().load( - project_path, - force_reload=True, - ) - self._emit_log(runtime, "MaaFW project updated, preparing agent Python env") - agent_prepare_logs: list[str] = [] - try: - await asyncio.to_thread( - _prepare_maafw_agent_python_envs, - project_path, - refreshed_interface, - send_log=agent_prepare_logs.append, - ) - finally: - for log_line in agent_prepare_logs: - self._emit_log(runtime, log_line) - except Exception as exc: - self._emit_log(runtime, f"MaaFW 项目更新失败,继续使用当前目录: {exc}") - - @staticmethod - def _emit_log(runtime: ScriptAdapterRuntime, message: str) -> None: - """把 adapter 关键操作日志同时写入后端日志与 UI 通道。 - - UI 侧由 task_manager 广播 script_info.log(task.log 事件),因此这里 - 除了 logger.info,还要追加到共享缓冲并刷新 script_info.log, - 否则用户在脚本管理页看不到插件适配阶段的进度。 - """ - logger.info(message) - logs = runtime.extra.setdefault("maafw_project_update_logs", []) - if isinstance(logs, list): - logs.extend(_format_update_log_lines(message)) - runtime.script_info.log = "".join(logs[-80:]) - - -async def _load_legacy_script_config(payload: dict[str, Any]) -> MaaFWConfig: - return await _load_legacy_config(MaaFWConfig(), payload) - - -async def _load_legacy_user_config( - runtime: ScriptAdapterRuntime, -) -> MultipleConfig[MaaFWUserConfig]: - collection = MultipleConfig([MaaFWUserConfig]) - source: dict[str, Any] = {"instances": []} - for user_id, payload in await runtime.storage.read_user_data_pairs(): - uid = str(uuid.UUID(user_id)) - source["instances"].append({"uid": uid, "type": "MaaFWUserConfig"}) - source[uid] = _normalize_legacy_json_fields(MaaFWUserConfig(), payload) - await collection.load(source) - return collection - - -async def _load_legacy_config( - config: LegacyConfigT, - payload: dict[str, Any], -) -> LegacyConfigT: - await config.load(_normalize_legacy_json_fields(config, payload)) - return config - - -def _normalize_legacy_json_fields( - config: ConfigBase, - payload: dict[str, Any], -) -> dict[str, Any]: - """Convert plugin form JSON values back to legacy ConfigBase strings.""" - - normalized = copy.deepcopy(payload) - for group, items in config._config_item_index.items(): - group_data = normalized.get(group) - if not isinstance(group_data, dict): - continue - for name, item in items.items(): - validator = item.validator - value = group_data.get(name) - if isinstance(validator, JSONValidator) and isinstance( - value, - validator.type, - ): - group_data[name] = json.dumps(value, ensure_ascii=False) - return normalized - - -def _format_update_log_lines(message: str) -> list[str]: - now = datetime.now().strftime("%H:%M:%S") - return [f"[{now}] {line}\n" for line in str(message).splitlines() or [""]] - - -def _prepare_maafw_agent_python_envs( - project_path: Path, - interface_model: Any, - *, - send_log: Any = None, -) -> None: - from automas_maafw_agent_env.service import MaaFWAgentEnvService - - MaaFWAgentEnvService().prepare_env( - project_path, - interface_model, - send_log=send_log, - ) - - -def build_legacy_script_item(script_item: ScriptItem) -> ScriptItem: - return script_item diff --git a/plugins/automas_script_maafw/src/automas_script_maafw/plugin.py b/plugins/automas_script_maafw/src/automas_script_maafw/plugin.py deleted file mode 100644 index 1a9de1e0..00000000 --- a/plugins/automas_script_maafw/src/automas_script_maafw/plugin.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from app.plugins import ScriptAdapterDefinition, ScriptAdapterPlugin - -from .adapter import MaaFWAdapterHooks -from .registry import MaaFWRegistryService -from .schema import SCRIPT_GROUPS, USER_GROUPS - - -DEFAULT_INSTANCE = { - "name": "MaaFW 脚本适配", - "enabled": True, - "config": {}, -} - -schema = { - "__no_plugin_config__": { - "type": "boolean", - "default": True, - "hidden": True, - "configurable": False, - "title": "No plugin-level configuration", - }, -} - - -class Plugin(ScriptAdapterPlugin): - """MaaFW script adapter plugin.""" - - provides = ["maafw.registry.v1"] - wants = [ - "emulator", - "maafw.interface.v1", - "maafw.project_update.v1", - "maafw.agent_env.v1", - "maafw.runner.v1", - ] - - def __init__(self, ctx): - super().__init__(ctx) - self.registry = MaaFWRegistryService() - - def build_script_adapters(self): - return [ - ScriptAdapterDefinition( - type_key="MaaFW", - display_name="MaaFramework 项目", - hooks_factory=MaaFWAdapterHooks, - script_groups=SCRIPT_GROUPS, - user_groups=USER_GROUPS, - script_class_name="MaaFWPluginConfig", - user_class_name="MaaFWPluginUserConfig", - module="automas_script_maafw.schema", - related_bindings={"EmulatorConfig": "EmulatorConfig"}, - supported_modes=("AutoProxy",), - icon="MaaFW", - editor_kind="plugin:automas_script_maafw", - legacy_config_class_name="MaaFWConfig", - legacy_user_config_class_name="MaaFWUserConfig", - is_builtin=False, - metadata={ - "framework": "maafw", - "source": "automas_script_maafw", - "m9a_standalone": False, - }, - ) - ] - - async def on_start(self) -> None: - self.ctx.set("maafw.registry.v1", self.registry) - await super().on_start() - - async def on_stop(self, reason: str) -> None: - self.ctx.set("maafw.registry.v1", None) - await super().on_stop(reason) diff --git a/plugins/automas_script_maafw/src/automas_script_maafw/py.typed b/plugins/automas_script_maafw/src/automas_script_maafw/py.typed deleted file mode 100644 index 8b137891..00000000 --- a/plugins/automas_script_maafw/src/automas_script_maafw/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/automas_script_maafw/src/automas_script_maafw/registry.py b/plugins/automas_script_maafw/src/automas_script_maafw/registry.py deleted file mode 100644 index 289cfad7..00000000 --- a/plugins/automas_script_maafw/src/automas_script_maafw/registry.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from typing import Any - - -class MaaFWRegistryService: - """Registry for MaaFW controller providers and project packs.""" - - def __init__(self) -> None: - self._controller_providers: dict[str, dict[str, Any]] = {} - self._project_packs: dict[str, dict[str, Any]] = {} - - def register_controller_provider(self, definition: Any) -> None: - payload = _dump_definition(definition) - key = str(payload.get("key") or "").strip() - if not key: - raise ValueError("MaaFW controller provider key cannot be empty") - self._controller_providers[key] = payload - - def unregister_controller_provider(self, key: str) -> None: - self._controller_providers.pop(str(key or "").strip(), None) - - def list_controller_providers(self) -> list[dict[str, Any]]: - return list(self._controller_providers.values()) - - def get_controller_provider(self, key: str) -> dict[str, Any] | None: - return self._controller_providers.get(str(key or "").strip()) - - def register_project_pack(self, definition: Any) -> None: - payload = _dump_definition(definition) - key = str(payload.get("key") or "").strip() - if not key: - raise ValueError("MaaFW project pack key cannot be empty") - self._project_packs[key] = payload - - def unregister_project_pack(self, key: str) -> None: - self._project_packs.pop(str(key or "").strip(), None) - - def list_project_packs(self) -> list[dict[str, Any]]: - return list(self._project_packs.values()) - - def get_project_pack(self, key: str) -> dict[str, Any] | None: - return self._project_packs.get(str(key or "").strip()) - - -def _dump_definition(definition: Any) -> dict[str, Any]: - if hasattr(definition, "model_dump"): - data = definition.model_dump(mode="json") - elif isinstance(definition, dict): - data = definition - else: - data = { - name: getattr(definition, name) - for name in dir(definition) - if not name.startswith("_") and not callable(getattr(definition, name)) - } - if not isinstance(data, dict): - raise TypeError("MaaFW project pack definition must be a mapping") - return dict(data) diff --git a/plugins/automas_script_maafw/src/automas_script_maafw/runner_task.py b/plugins/automas_script_maafw/src/automas_script_maafw/runner_task.py deleted file mode 100644 index 379392e3..00000000 --- a/plugins/automas_script_maafw/src/automas_script_maafw/runner_task.py +++ /dev/null @@ -1,1319 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import os -import shlex -import subprocess -import uuid -from contextlib import suppress -from dataclasses import dataclass -from datetime import datetime, timedelta -from pathlib import Path -from typing import Any - -import psutil - -from app.core import Config -from app.models.ConfigBase import MultipleConfig -from app.models.config import MaaFWConfig, MaaFWUserConfig -from app.models.emulator import DeviceBase, DeviceInfo -from app.models.task import LogRecord, ScriptItem, TaskExecuteBase -from app.plugins.pypi_site import get_plugin_import_paths -from app.services import Notify -from app.task.general.tools import execute_script_task -from app.utils import ProcessInfo, ProcessManager, get_logger -from app.utils.constants import UTC4 -from automas_maafw_interface.models import MaaFWController, MaaFWInterface -from automas_maafw_interface.preview import build_adb_emulator_extra_capabilities -from automas_maafw_interface.service import MaaFWInterfaceService -from automas_maafw_runner.models import ( - MaaFWDeviceConfig, - MaaFWRunPlan, - MaaFWRunResult, - MaaFWSkippedTaskPlan, -) -from automas_maafw_runner.run_plan import MaaFWRunPlanError -from automas_maafw_runner.service import MaaFWRunnerService - - -logger = get_logger("MaaFW 插件自动代理") - -_RUNNING_PROJECT_PATHS: set[str] = set() -_RUNNING_PROJECT_PATHS_LOCK = asyncio.Lock() - - -# MaaFW 的 ADB 截图/输入方法枚举(EmulatorExtras 硬件加速相关)在此镜像为整型常量, -# 而非 `from maa.controller import MaaAdb*Enum`。本模块受导入边界约束 -# (tests/plugins/test_maafw_import_boundaries.py 禁止导入时把 maa 载入 sys.modules), -# 直接 import maa 会加载原生绑定,故改为对齐 plugins/pypi/site-packages/maa/define.py -# 的取值: -# screencap Default = All & ~RawByNetcat & ~MinicapDirect & ~MinicapStream = -57 -# screencap EmulatorExtras = 1 << 6 (64) -# input Default = All & ~EmulatorExtras = -9;input All = ~0 = -1;EmulatorExtras = 1 << 3 (8) -_ADB_SCREENCAP_DEFAULT = -57 -_ADB_SCREENCAP_EMULATOR_EXTRAS = 1 << 6 -_ADB_INPUT_DEFAULT = -9 -_ADB_INPUT_ALL = -1 -_ADB_INPUT_EMULATOR_EXTRAS = 1 << 3 -_WIN32_SCREENCAP_METHODS = { - "GDI": 1, - "FramePool": 1 << 1, - "DXGI_DesktopDup": 1 << 2, - "DXGI_DesktopDup_Window": 1 << 3, - "PrintWindow": 1 << 4, - "ScreenDC": 1 << 5, -} -_WIN32_INPUT_METHODS = { - "Seize": 1, - "SendMessage": 1 << 1, - "PostMessage": 1 << 2, - "LegacyEvent": 1 << 3, - "PostThreadMessage": 1 << 4, - "SendMessageWithCursorPos": 1 << 5, - "PostMessageWithCursorPos": 1 << 6, - "SendMessageWithWindowPos": 1 << 7, - "PostMessageWithWindowPos": 1 << 8, -} -_SUBPROCESS_OUTPUT_ENCODINGS = ("utf-8", "gbk", "shift_jis", "utf-16") -_RUN_OVERVIEW_LOG_VALUE_LIMIT = 1200 - - -@dataclass(frozen=True) -class MaaFWAdbControlProfile: - """描述某模拟器实例的 ADB EmulatorExtras 能力与 controller extras 配置。""" - - emulator_type: str | None - screencap_extra: bool - input_extra: bool - config: dict[str, Any] - - -class MaaFWPluginAutoProxyTask(TaskExecuteBase): - """MaaFW 插件版 AutoProxy 执行器。""" - - def __init__( - self, - script_info: ScriptItem, - script_config: MaaFWConfig, - user_config: MultipleConfig[MaaFWUserConfig], - emulator_manager: DeviceBase | None, - project_update_logs: list[str] | None = None, - ): - super().__init__() - - if script_info.task_info is None: - raise RuntimeError("ScriptItem 未绑定 TaskItem") - - self.task_info = script_info.task_info - self.script_info = script_info - self.script_config = script_config - self.user_config = user_config - self.emulator_manager = emulator_manager - self.project_update_logs = list(project_update_logs or []) - self.cur_user_item = self.script_info.user_list[self.script_info.current_index] - self.cur_user_uid = uuid.UUID(self.cur_user_item.user_id) - self.cur_user_config = self.user_config[self.cur_user_uid] - self.project_path = Path(self.script_config.get("Info", "Path")).resolve() - self.interface_model: MaaFWInterface | None = None - self.base_run_plan: MaaFWRunPlan | None = None - self.run_plan: MaaFWRunPlan | None = None - self.cur_user_log: LogRecord | None = None - self.check_result = "-" - self.curdate = datetime.now(tz=UTC4).strftime("%Y-%m-%d") - self.run_complete = False - self.opened_emulator = False - self.opened_game = False - self.game_process_manager = ProcessManager() - self.project_lock_key: str | None = None - self.runner_process: asyncio.subprocess.Process | None = None - self.pretask_process: asyncio.subprocess.Process | None = None - self._cached_adb_address: str | None = None - self._cached_device_info: DeviceInfo | None = None - self._cached_adb_path: str | None = None - self._cached_adb_profile: MaaFWAdbControlProfile | None = None - - async def check(self) -> str: - proxy_times = ( - self.cur_user_config.get("Data", "ProxyTimes") - if self.cur_user_config.get("Data", "LastProxyDate") == self.curdate - else 0 - ) - if ( - self.script_config.get("Run", "ProxyTimesLimit") != 0 - and proxy_times >= self.script_config.get("Run", "ProxyTimesLimit") - ): - self.cur_user_item.status = "跳过" - return "今日代理次数已达上限,跳过该用户" - - try: - self.interface_model = MaaFWInterfaceService().load(self.project_path) - self.base_run_plan = self._build_run_plan(self.interface_model) - self.run_plan = self._filter_period_once_tasks(self.base_run_plan) - except Exception as exc: - self.cur_user_item.status = "异常" - return f"无法构建 MaaFW 运行计划,请检查项目和任务配置: {exc}" - - if not self.run_plan.tasks: - self.cur_user_item.status = "跳过" - return "MaaFW 周期任务已在本周或本月完成,跳过本次运行" - - if self.run_plan.controllerType == "Adb": - emulator_id = self.script_config.get("Emulator", "Id") - emulator_index = self.script_config.get("Emulator", "Index") - if emulator_id == "-" or emulator_index in ("", "-"): - self.cur_user_item.status = "异常" - return "当前 MaaFW controller 需要 ADB,请在脚本管理页选择模拟器和实例" - elif self.run_plan.controllerType == "Win32": - # 由 MAS 负责启动桌面游戏,因此仅校验 Game.Path 指向真实 exe, - # 窗口由 _ensure_desktop_game_started 启动游戏后再解析,无需此处已存在 - game_path = Path(str(self.script_config.get("Game", "Path") or "").strip()) - if not game_path.is_file(): - self.cur_user_item.status = "异常" - return "当前 MaaFW controller 需要由 MAS 启动游戏,请在脚本管理页选择实际游戏 exe" - - if not await self._try_enter_project_path(): - self.cur_user_item.status = "跳过" - return "同一路径 MaaFW 脚本正在运行,已跳过本次启动" - - return "Pass" - - async def prepare(self) -> None: - start_time = datetime.now() - self.cur_user_item.log_record[start_time] = self.cur_user_log = LogRecord() - if self.project_update_logs: - self.cur_user_log.content.extend(self.project_update_logs) - self.script_info.log = "".join(self.cur_user_log.content[-80:]) - - async def main_task(self) -> None: - self.curdate = datetime.now(tz=UTC4).strftime("%Y-%m-%d") - self.check_result = await self.check() - if self.check_result != "Pass": - if self.cur_user_item.status == "异常": - await Config.send_websocket_message( - id=self.task_info.task_id, - type="Info", - data={ - "Error": f"用户 {self.cur_user_item.name} 检查未通过: {self.check_result}" - }, - ) - self.script_info.log = self.check_result - return - - await self._mark_run_started() - await self.prepare() - self.cur_user_item.status = "运行" - logger.info(f"开始代理 MaaFW 用户: {self.cur_user_uid}") - if self.run_plan is not None: - selected_preset = str( - self.cur_user_config.get("Task", "SelectedPreset") or "" - ).strip() - self._append_log( - _format_run_overview_log( - self.run_plan, - selected_preset=selected_preset, - ) - ) - - try: - await self._run_pretasks() - for index in range(self.script_config.get("Run", "RunTimesLimit")): - if self.run_complete: - break - - self._append_log( - f"用户 {self.cur_user_item.name} - 尝试次数: " - f"{index + 1}/{self.script_config.get('Run', 'RunTimesLimit')}" - ) - if self.cur_user_config.get("Info", "IfScriptBeforeTask"): - await execute_script_task( - Path(self.cur_user_config.get("Info", "ScriptBeforeTask")), - "脚本前任务", - ) - - try: - if self.run_plan is None or self.interface_model is None: - raise RuntimeError("MaaFW 运行计划尚未初始化") - await self._ensure_desktop_game_started() - device_config = await self._build_device_config( - self.run_plan, - self.interface_model, - ) - result = await self._run_maafw(device_config) - except Exception as exc: - message = f"MaaFW 运行异常: {exc}" - self._append_log(message) - if self.cur_user_log is not None: - self.cur_user_log.status = message - await Config.send_websocket_message( - id=self.task_info.task_id, - type="Info", - data={"Error": message}, - ) - continue - finally: - if self.cur_user_config.get("Info", "IfScriptAfterTask"): - await execute_script_task( - Path(self.cur_user_config.get("Info", "ScriptAfterTask")), - "脚本后任务", - ) - - await self._mark_period_tasks_completed(result.completedTasks) - if result.success: - self.run_complete = True - if self.cur_user_log is not None: - self.cur_user_log.status = "Success!" - self._append_log("MaaFW 任务完成: " + ", ".join(result.completedTasks)) - else: - message = result.errorMessage or "MaaFW 任务失败" - if self.cur_user_log is not None: - self.cur_user_log.status = message - self._append_log(message) - self._refresh_run_plan_after_period_update() - if self.run_plan is not None and not self.run_plan.tasks: - self.run_complete = True - self._append_log("MaaFW 剩余周期任务已完成,停止本轮重试") - finally: - await self._shutdown_runner() - await self._close_emulator() - await self._close_game() - await self._release_project_path() - - async def final_task(self) -> None: - await self._shutdown_runner() - if self.check_result != "Pass": - await self._release_project_path() - return - - await self._close_emulator() - await self._close_game() - await self._save_user_logs() - if self.run_complete: - if ( - self.cur_user_config.get("Data", "ProxyTimes") == 0 - and self.cur_user_config.get("Info", "RemainedDay") != -1 - ): - await self.cur_user_config.set( - "Info", - "RemainedDay", - self.cur_user_config.get("Info", "RemainedDay") - 1, - ) - await self.cur_user_config.set( - "Data", - "ProxyTimes", - self.cur_user_config.get("Data", "ProxyTimes") + 1, - ) - await self.cur_user_config.set("Data", "LastProxyStatus", "成功") - self.cur_user_item.status = "完成" - await self._send_success_notify() - else: - await self.cur_user_config.set("Data", "LastProxyStatus", "失败") - if self.cur_user_item.status == "运行": - self.cur_user_item.status = "异常" - - await self._release_project_path() - - async def on_crash(self, e: Exception) -> None: - self.cur_user_item.status = "异常" - logger.exception(f"MaaFW 插件自动代理任务出现异常: {e}") - try: - await Config.send_websocket_message( - id=self.task_info.task_id, - type="Info", - data={"Error": f"MaaFW 插件自动代理任务出现异常: {e}"}, - ) - except Exception: - pass - await self._shutdown_runner() - await self._close_emulator() - await self._close_game() - await self._release_project_path() - - def _build_run_plan(self, interface_model: MaaFWInterface) -> MaaFWRunPlan: - task_snapshot = _load_json_dict(self.cur_user_config.get("Task", "TaskSnapshot")) - selected_preset = str(self.cur_user_config.get("Task", "SelectedPreset") or "").strip() - controller_name = self._select_controller_name(interface_model) - resource_name = self._select_resource_name(interface_model, controller_name) - try: - return MaaFWRunnerService().build_plan( - self.project_path, - interface_model, - controller_name=controller_name, - resource_name=resource_name, - selected_preset=selected_preset if selected_preset and not task_snapshot else None, - task_snapshot=task_snapshot or None, - ) - except Exception as exc: - raise MaaFWRunPlanError(str(exc)) from exc - - def _select_controller_name(self, interface_model: MaaFWInterface) -> str | None: - configured_controller = str( - self.cur_user_config.get("Info", "Controller") - or self.script_config.get("Info", "Controller") - or "" - ).strip() - - wants_adb = self.script_config.get("Emulator", "Id") != "-" - if wants_adb: - if configured_controller: - controller = _find_controller(interface_model, configured_controller) - if controller.type == "Adb": - return controller.name - adb_controller = next( - (controller for controller in interface_model.controller if controller.type == "Adb"), - None, - ) - if adb_controller is not None: - return adb_controller.name - if configured_controller: - return configured_controller - return None - - def _select_resource_name( - self, - interface_model: MaaFWInterface, - controller_name: str | None, - ) -> str | None: - configured_resource = str( - self.cur_user_config.get("Info", "Resource") - or self.script_config.get("Info", "Resource") - or "" - ).strip() - if configured_resource: - return configured_resource - if controller_name is None: - return None - for resource in interface_model.resource: - if not resource.controller or controller_name in resource.controller: - return resource.name - return None - - async def _build_device_config( - self, - plan: MaaFWRunPlan, - interface_model: MaaFWInterface, - ) -> MaaFWDeviceConfig: - if plan.controllerType == "Adb": - address, device_info = await self._resolve_adb_address() - adb_path = await self._resolve_adb_path(address, device_info) - adb_profile = await self._build_adb_control_profile() - return MaaFWDeviceConfig( - type="Adb", - adbPath=adb_path, - address=address, - screencapMethods=self._resolve_adb_screencap_methods(adb_profile), - inputMethods=self._resolve_adb_input_methods(adb_profile), - config=adb_profile.config, - ) - - if plan.controllerType == "Win32": - controller = _find_controller(interface_model, plan.controllerName) - win32_config = controller.win32 - return MaaFWDeviceConfig( - type="Win32", - hWnd=self._resolve_window_handle(controller), - screencapMethod=_resolve_win32_method( - self.script_config.get("Device", "Win32ScreencapMethod"), - win32_config.screencap if win32_config else None, - _WIN32_SCREENCAP_METHODS, - _WIN32_SCREENCAP_METHODS["DXGI_DesktopDup"], - ), - mouseMethod=_resolve_win32_method( - self.script_config.get("Device", "Win32MouseMethod"), - win32_config.mouse if win32_config else None, - _WIN32_INPUT_METHODS, - _WIN32_INPUT_METHODS["Seize"], - ), - keyboardMethod=_resolve_win32_method( - self.script_config.get("Device", "Win32KeyboardMethod"), - win32_config.keyboard if win32_config else None, - _WIN32_INPUT_METHODS, - _WIN32_INPUT_METHODS["Seize"], - ), - ) - - raise RuntimeError(f"当前仅支持 Adb/Win32 controller: {plan.controllerType}") - - async def _resolve_adb_address(self) -> tuple[str, DeviceInfo | None]: - if self._cached_adb_address is not None: - return self._cached_adb_address, self._cached_device_info - if self.emulator_manager is None: - raise RuntimeError("当前 controller 需要 ADB,请在脚本管理页选择模拟器") - - emulator_index = self.script_config.get("Emulator", "Index") - if emulator_index in ("", "-"): - raise RuntimeError("当前 controller 需要 ADB,请在脚本管理页选择模拟器实例") - - self._append_log(f"正在启动模拟器: {emulator_index}") - self.opened_emulator = True - device_info = await self.emulator_manager.open(emulator_index) - if Config.get("Function", "IfSilence"): - with suppress(Exception): - await self.emulator_manager.setVisible(emulator_index, False) - if not device_info.adb_address or device_info.adb_address == "Unknown": - raise RuntimeError("模拟器未返回可用 ADB 地址") - - self._cached_adb_address = device_info.adb_address - self._cached_device_info = device_info - self._append_log(f"模拟器启动完成,ADB 地址: {device_info.adb_address}") - return device_info.adb_address, device_info - - async def _resolve_adb_path( - self, - address: str, - device_info: DeviceInfo | None, - ) -> str | None: - if self._cached_adb_path is not None: - return self._cached_adb_path - configured_path = str(self.script_config.get("Device", "AdbPath") or "").strip() - if configured_path and Path(configured_path).exists(): - self._cached_adb_path = configured_path - return configured_path - - derived_path = self._derive_adb_path_from_emulator_config() - if derived_path is not None and derived_path.exists(): - self._cached_adb_path = str(derived_path) - return self._cached_adb_path - - title = f"({device_info.title})" if device_info else "" - self._append_log( - f"主程序未解析到 ADB 路径{title},将由 MaaFW Runner 按设备地址发现" - ) - return None - - def _derive_adb_path_from_emulator_config(self) -> Path | None: - emulator_id = self.script_config.get("Emulator", "Id") - if emulator_id == "-": - return None - with suppress(Exception): - emulator_config = Config.EmulatorConfig[uuid.UUID(emulator_id)] - emulator_path = Path(emulator_config.get("Info", "Path")) - return emulator_path.parent / "adb.exe" - return None - - async def _build_adb_control_profile(self) -> MaaFWAdbControlProfile: - """解析当前模拟器的 EmulatorExtras 能力,并构造 MuMu/雷电 controller extras 配置。""" - if self._cached_adb_profile is not None: - return self._cached_adb_profile - - emulator_id = self.script_config.get("Emulator", "Id") - emulator_index = self.script_config.get("Emulator", "Index") - if emulator_id == "-" or emulator_index in ("", "-"): - self._cached_adb_profile = MaaFWAdbControlProfile(None, False, False, {}) - return self._cached_adb_profile - - try: - emulator_config = Config.EmulatorConfig[uuid.UUID(emulator_id)] - emulator_type = str(emulator_config.get("Info", "Type") or "") - emulator_path = Path(emulator_config.get("Info", "Path")) - # build_adb_emulator_extra_capabilities 通过 find_spec 探测运行时 maa, - # 不会把 maa 载入 sys.modules,满足导入边界约束;返回 {type: {screencap,input}}。 - capabilities = build_adb_emulator_extra_capabilities() - capability = capabilities.get(emulator_type, {}) - screencap_extra = bool(capability.get("screencap", False)) - input_extra = bool(capability.get("input", False)) - if emulator_type == "ldplayer" and screencap_extra: - config = await self._build_ldplayer_adb_controller_config( - emulator_path, - emulator_index, - ) - self._cached_adb_profile = MaaFWAdbControlProfile( - emulator_type, - screencap_extra, - input_extra, - config, - ) - return self._cached_adb_profile - if emulator_type == "mumu" and (screencap_extra or input_extra): - config = self._build_mumu_adb_controller_config( - emulator_path, - emulator_index, - ) - self._cached_adb_profile = MaaFWAdbControlProfile( - emulator_type, - screencap_extra, - input_extra, - config, - ) - return self._cached_adb_profile - self._cached_adb_profile = MaaFWAdbControlProfile( - emulator_type, - screencap_extra, - input_extra, - {}, - ) - return self._cached_adb_profile - except Exception as exc: - logger.warning(f"构造 MaaFW ADB extra 配置失败,使用默认配置: {exc}") - - self._cached_adb_profile = MaaFWAdbControlProfile(None, False, False, {}) - return self._cached_adb_profile - - def _resolve_adb_screencap_methods(self, profile: MaaFWAdbControlProfile) -> int: - extra_method = _ADB_SCREENCAP_EMULATOR_EXTRAS - if profile.emulator_type in {"ldplayer", "mumu"}: - default_methods = _ADB_SCREENCAP_DEFAULT - if profile.screencap_extra: - return default_methods | extra_method - return _remove_method(default_methods, extra_method, default_methods) - return _remove_method( - int(self.script_config.get("Device", "AdbScreencapMethods")), - extra_method, - _remove_method( - _ADB_SCREENCAP_DEFAULT, - extra_method, - _ADB_SCREENCAP_DEFAULT, - ), - ) - - def _resolve_adb_input_methods(self, profile: MaaFWAdbControlProfile) -> int: - extra_input_method = _ADB_INPUT_EMULATOR_EXTRAS - if profile.emulator_type == "mumu" and profile.input_extra: - return _ADB_INPUT_ALL - if profile.emulator_type in {"ldplayer", "mumu"}: - return _ADB_INPUT_DEFAULT - - configured = int(self.script_config.get("Device", "AdbInputMethods")) - if not profile.input_extra: - return _remove_method( - configured, - extra_input_method, - _ADB_INPUT_DEFAULT, - ) - return configured - - async def _build_ldplayer_adb_controller_config( - self, - emulator_path: Path, - emulator_index: str, - ) -> dict[str, Any]: - emulator_root = emulator_path.parent - index = int(emulator_index) - pid = 0 - - # get_device_info 仅存在于部分模拟器管理器(雷电有,MuMu 无), - # 且 list2 可能超时/异常,故整体兜底为实例索引 + pid=0。 - if self.emulator_manager is not None: - try: - devices = await self.emulator_manager.get_device_info(emulator_index) - device = devices.get(emulator_index) - if device is not None: - index = device.idx - pid = device.pid - except Exception as exc: - logger.warning(f"获取雷电模拟器 extra 信息失败,使用实例索引兜底: {exc}") - - ld_config: dict[str, Any] = { - "enable": True, - "index": index, - "path": str(emulator_root).replace("\\", "/"), - "pid": pid, - } - ld_library = emulator_root / "ldopengl64.dll" - if ld_library.exists(): - ld_config["lib"] = str(ld_library).replace("\\", "/") - - return { - "extras": { - "ld": ld_config, - }, - } - - @staticmethod - def _build_mumu_adb_controller_config( - emulator_path: Path, - emulator_index: str, - ) -> dict[str, Any]: - emulator_root = emulator_path.parent.parent - mumu_config: dict[str, Any] = { - "enable": True, - "index": int(emulator_index), - "path": str(emulator_root).replace("\\", "/"), - } - for library in ( - emulator_root / "nx_main" / "sdk" / "external_renderer_ipc.dll", - emulator_root / "shell" / "sdk" / "external_renderer_ipc.dll", - ): - if library.exists(): - mumu_config["lib"] = str(library).replace("\\", "/") - break - - return { - "extras": { - "mumu": mumu_config, - }, - } - - def _resolve_window_handle(self, controller: MaaFWController) -> int: - configured_hwnd = ( - self.cur_user_config.get("Device", "HWnd") - or self.script_config.get("Device", "HWnd") - ) - parsed_hwnd = _optional_int(configured_hwnd) - if parsed_hwnd: - return parsed_hwnd - matches = _match_controller_windows(controller) - if not matches: - raise RuntimeError("未找到匹配 MaaFW Win32 controller 的窗口") - return int(matches[0].hWnd) - - async def _run_maafw(self, device_config: MaaFWDeviceConfig) -> MaaFWRunResult: - if self.run_plan is None: - raise RuntimeError("MaaFW 运行计划尚未初始化") - timeout = self.script_config.get("Run", "RunTimeLimit") * 60 - try: - return await asyncio.wait_for( - self._run_maafw_worker(device_config), - timeout=timeout, - ) - except asyncio.TimeoutError as exc: - await self._terminate_runner_process() - raise RuntimeError("MaaFW 任务运行超时") from exc - except asyncio.CancelledError: - await self._terminate_runner_process() - raise - - async def _run_maafw_worker( - self, - device_config: MaaFWDeviceConfig, - ) -> MaaFWRunResult: - if self.run_plan is None: - raise RuntimeError("MaaFW 运行计划尚未初始化") - - service = MaaFWRunnerService() - loop = asyncio.get_running_loop() - - def send_runner_log(message: str) -> None: - loop.call_soon_threadsafe(self._append_log, message) - - runner_environment = await asyncio.to_thread( - service.prepare_environment, - self.project_path, - managed_env_root=Path.cwd() / "config" / "maafw_runner_venvs", - import_paths=get_plugin_import_paths(), - send_log=send_runner_log, - ) - runner_plan = self.run_plan - if runner_environment.maafw_version: - runner_plan = self.run_plan.model_copy(deep=True) - runner_plan.piEnv["PI_CLIENT_MAAFW_VERSION"] = ( - f"v{runner_environment.maafw_version.lstrip('v')}" - ) - payload = service.create_job_payload(runner_plan, device_config) - work_dir = Path.cwd() / "runtime" / "maafw_runner_jobs" - job_path = await asyncio.to_thread(service.write_job_file, payload, work_dir) - process = await asyncio.create_subprocess_exec( - str(runner_environment.python_executable), - "-m", - "automas_maafw_runner.worker", - str(job_path), - cwd=str(Path.cwd()), - env=runner_environment.env, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - self.runner_process = process - result_payload: dict[str, Any] | None = None - stderr_lines: list[str] = [] - - async def read_stdout() -> None: - nonlocal result_payload - if process.stdout is None: - return - async for raw_line in process.stdout: - line = raw_line.decode("utf-8", errors="replace").strip() - if not line: - continue - try: - event = json.loads(line) - except json.JSONDecodeError: - self._append_log(line) - continue - - event_type = event.get("type") - if event_type == "log": - self._append_log(str(event.get("message") or "")) - elif event_type == "result" and isinstance(event.get("data"), dict): - result_payload = event["data"] - elif event_type == "error": - self._append_log(str(event.get("message") or "")) - - async def read_stderr() -> None: - if process.stderr is None: - return - async for raw_line in process.stderr: - line = _decode_subprocess_output(raw_line).strip() - if not line: - continue - stderr_lines.append(line) - del stderr_lines[:-20] - self._append_log(line) - - stdout_task = asyncio.create_task(read_stdout()) - stderr_task = asyncio.create_task(read_stderr()) - try: - returncode = await process.wait() - await asyncio.gather(stdout_task, stderr_task) - finally: - if process.returncode is None: - await self._terminate_runner_process() - elif self.runner_process is process: - self.runner_process = None - for task in (stdout_task, stderr_task): - if not task.done(): - task.cancel() - await asyncio.gather(stdout_task, stderr_task, return_exceptions=True) - with suppress(Exception): - job_path.unlink() - - if result_payload is not None: - return MaaFWRunResult.model_validate(result_payload) - detail = "\n".join(stderr_lines[-5:]).strip() - message = f"MaaFW runner worker exited without result: {returncode}" - if detail: - message += f": {detail}" - raise RuntimeError(message) - - async def _shutdown_runner(self) -> None: - await self._terminate_pretask_process() - await self._terminate_runner_process() - - async def _run_pretasks(self) -> None: - if self.run_plan is None: - raise RuntimeError("MaaFW 运行计划尚未初始化") - - env = os.environ.copy() - env.update(self.run_plan.piEnv) - creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 - for pretask in self.run_plan.pretasks: - display_name = pretask.label or pretask.name - self._append_log(f"开始应用运行前设置: {display_name}") - process = await asyncio.create_subprocess_exec( - pretask.executable, - *pretask.args, - cwd=self.run_plan.path, - env=env, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - creationflags=creationflags, - ) - self.pretask_process = process - try: - output, _ = await process.communicate() - finally: - if process.returncode is not None and self.pretask_process is process: - self.pretask_process = None - - detail = _decode_subprocess_output(output).strip() - if detail: - for line in detail.splitlines(): - self._append_log(f"[运行前设置] {line}") - if process.returncode != 0: - error_detail = detail.splitlines()[-1] if detail else "没有输出错误详情" - raise RuntimeError( - f"运行前设置 {display_name} 执行失败(退出码 {process.returncode}): " - f"{error_detail}" - ) - self._append_log(f"运行前设置完成: {display_name}") - - async def _terminate_pretask_process(self) -> None: - process = self.pretask_process - self.pretask_process = None - if process is None or process.returncode is not None: - return - - try: - process.terminate() - await asyncio.wait_for(process.wait(), timeout=5) - except asyncio.TimeoutError: - process.kill() - await process.wait() - except ProcessLookupError: - return - except Exception as exc: - logger.warning(f"MaaFW 运行前设置进程清理失败: {exc}") - - async def _terminate_runner_process(self) -> None: - process = self.runner_process - self.runner_process = None - if process is None or process.returncode is not None: - return - - try: - process.terminate() - await asyncio.wait_for(process.wait(), timeout=5) - except asyncio.TimeoutError: - process.kill() - await process.wait() - except ProcessLookupError: - return - except Exception as exc: - logger.warning(f"MaaFW runner worker 清理失败: {exc}") - - def _filter_period_once_tasks(self, plan: MaaFWRunPlan) -> MaaFWRunPlan: - daily_tasks = set(_load_json_list(self.script_config.get("Run", "DailyOnceTasks"))) - weekly_tasks = set(_load_json_list(self.script_config.get("Run", "WeeklyOnceTasks"))) - monthly_tasks = set(_load_json_list(self.script_config.get("Run", "MonthlyOnceTasks"))) - if not daily_tasks and not weekly_tasks and not monthly_tasks: - return plan - - daily_key, weekly_key, monthly_key = _current_period_keys() - records = self._load_period_task_records() - runnable_tasks = [] - skipped_tasks = [] - for task in plan.tasks: - daily_done = ( - task.name in daily_tasks and records["daily"].get(task.name) == daily_key - ) - weekly_done = task.name in weekly_tasks and records["weekly"].get(task.name) == weekly_key - monthly_done = ( - task.name in monthly_tasks and records["monthly"].get(task.name) == monthly_key - ) - if daily_done or weekly_done or monthly_done: - if daily_done: - reason = "今日已正常完成" - elif monthly_done: - reason = "本月已正常完成" - else: - reason = "本周已正常完成" - skipped_tasks.append( - MaaFWSkippedTaskPlan( - name=task.name, - label=task.label, - entry=task.entry, - reason=reason, - ) - ) - continue - runnable_tasks.append(task) - return plan.model_copy( - update={ - "tasks": runnable_tasks, - "skippedTasks": [*plan.skippedTasks, *skipped_tasks], - }, - ) - - def _refresh_run_plan_after_period_update(self) -> None: - if self.base_run_plan is not None: - self.run_plan = self._filter_period_once_tasks(self.base_run_plan) - - def _load_period_task_records(self) -> dict[str, dict[str, str]]: - raw_records = _load_json_dict(self.cur_user_config.get("Data", "PeriodTaskRecords")) - records: dict[str, dict[str, str]] = {"daily": {}, "weekly": {}, "monthly": {}} - for period in records: - raw_period_records = raw_records.get(period, {}) - if isinstance(raw_period_records, dict): - records[period] = { - str(task_name): str(period_key) - for task_name, period_key in raw_period_records.items() - } - return records - - async def _mark_period_tasks_completed(self, completed_tasks: list[str]) -> None: - if not completed_tasks: - return - daily_tasks = set(_load_json_list(self.script_config.get("Run", "DailyOnceTasks"))) - weekly_tasks = set(_load_json_list(self.script_config.get("Run", "WeeklyOnceTasks"))) - monthly_tasks = set(_load_json_list(self.script_config.get("Run", "MonthlyOnceTasks"))) - if not daily_tasks and not weekly_tasks and not monthly_tasks: - return - - daily_key, weekly_key, monthly_key = _current_period_keys() - records = self._load_period_task_records() - changed = False - for task_name in set(completed_tasks).intersection(daily_tasks): - if records["daily"].get(task_name) != daily_key: - records["daily"][task_name] = daily_key - changed = True - for task_name in set(completed_tasks).intersection(weekly_tasks): - if records["weekly"].get(task_name) != weekly_key: - records["weekly"][task_name] = weekly_key - changed = True - for task_name in set(completed_tasks).intersection(monthly_tasks): - if records["monthly"].get(task_name) != monthly_key: - records["monthly"][task_name] = monthly_key - changed = True - if changed: - await self.cur_user_config.set( - "Data", - "PeriodTaskRecords", - json.dumps(records, ensure_ascii=False), - ) - - async def _mark_run_started(self) -> None: - if self.cur_user_config.get("Data", "LastProxyDate") != self.curdate: - await self.cur_user_config.set("Data", "LastProxyDate", self.curdate) - await self.cur_user_config.set("Data", "ProxyTimes", 0) - await self.cur_user_config.set("Data", "LastProxyStatus", "运行中") - - async def _close_emulator(self) -> None: - if not self.opened_emulator or self.emulator_manager is None: - return - try: - await self.emulator_manager.close(self.script_config.get("Emulator", "Index")) - except Exception as exc: - logger.warning(f"MaaFW 插件清理模拟器失败: {exc}") - finally: - self.opened_emulator = False - - async def _ensure_desktop_game_started(self) -> None: - """Win32 场景下由 MAS 负责启动/激活桌面游戏客户端,供后续窗口解析使用。""" - if self.run_plan is None or self.run_plan.controllerType != "Win32": - return - if self.opened_game: - return - - game_path = Path(str(self.script_config.get("Game", "Path") or "").strip()) - if not game_path.is_file(): - raise RuntimeError("当前 MaaFW controller 需要由 MAS 启动游戏,请在脚本管理页选择实际游戏 exe") - - if self.interface_model is not None and self.run_plan is not None: - controller = _find_controller( - self.interface_model, - self.run_plan.controllerName, - ) - matches = _match_controller_windows(controller) - if matches: - selected = matches[0] - self._append_log( - "检测到游戏客户端窗口: " - f"hWnd={selected.hWnd}, class={selected.className}, " - f"title={selected.windowName}" - ) - await self._activate_desktop_game_window(game_path) - return - - if _is_process_path_running(game_path): - message = f"检测到游戏进程已在运行,跳过由 MAS 重复启动游戏: {game_path.name}" - logger.info(message) - self.script_info.log = message - await self._wait_for_desktop_game_ready(game_path) - await self._activate_desktop_game_window(game_path) - return - - game_arguments = shlex.split(str(self.script_config.get("Game", "Arguments") or "").strip()) - logger.info(f"启动游戏: {game_path} - {self.script_config.get('Game', 'Arguments')}") - await self.game_process_manager.open_process( - game_path, - *game_arguments, - cwd=game_path.parent, - ) - - try: - await self._wait_for_desktop_game_ready(game_path) - except Exception: - with suppress(Exception): - await self.game_process_manager.kill() - raise - - self.opened_game = True - await self._activate_desktop_game_window(game_path) - - async def _wait_for_desktop_game_ready( - self, - game_path: Path, - poll_interval: float = 1.0, - ) -> None: - wait_time = max(0, int(self.script_config.get("Game", "WaitTime") or 0)) - if self.run_plan is None or self.interface_model is None: - raise RuntimeError("MaaFW 运行计划未完成初始化") - - configured_hwnd = ( - self.cur_user_config.get("Device", "HWnd") - or self.script_config.get("Device", "HWnd") - ) - explicit_hwnd = _optional_int(configured_hwnd) - controller = _find_controller(self.interface_model, self.run_plan.controllerName) - - self._append_log( - f"正在等待游戏客户端窗口就绪,最大等待时间 {wait_time}s: {game_path.name}" - ) - if wait_time <= 0: - if not _is_process_path_running(game_path): - raise RuntimeError(f"游戏进程未启动: {game_path.name}") - if explicit_hwnd: - self._append_log(f"已配置窗口句柄,跳过窗口正则等待: {explicit_hwnd}") - return - if not _match_controller_windows(controller): - raise RuntimeError(f"游戏窗口未就绪: {game_path.name}") - return - - waited = 0.0 - process_detected = False - while waited < wait_time: - if not explicit_hwnd: - matches = _match_controller_windows(controller) - if matches: - selected = matches[0] - self._append_log( - "检测到游戏客户端窗口: " - f"hWnd={selected.hWnd}, class={selected.className}, " - f"title={selected.windowName}" - ) - return - - if _is_process_path_running(game_path): - if not process_detected: - self._append_log(f"检测到游戏进程已启动: {game_path.name}") - process_detected = True - - if explicit_hwnd: - self._append_log(f"已配置窗口句柄,跳过窗口正则等待: {explicit_hwnd}") - return - - matches = _match_controller_windows(controller) - if matches: - selected = matches[0] - self._append_log( - "检测到游戏客户端窗口: " - f"hWnd={selected.hWnd}, class={selected.className}, " - f"title={selected.windowName}" - ) - return - - sleep_seconds = min(poll_interval, wait_time - waited) - await asyncio.sleep(sleep_seconds) - waited += sleep_seconds - - if not explicit_hwnd: - matches = _match_controller_windows(controller) - if matches: - selected = matches[0] - self._append_log( - "检测到游戏客户端窗口: " - f"hWnd={selected.hWnd}, class={selected.className}, " - f"title={selected.windowName}" - ) - return - - if _is_process_path_running(game_path): - if explicit_hwnd: - self._append_log(f"已配置窗口句柄,跳过窗口正则等待: {explicit_hwnd}") - return - - matches = _match_controller_windows(controller) - if matches: - selected = matches[0] - self._append_log( - "检测到游戏客户端窗口: " - f"hWnd={selected.hWnd}, class={selected.className}, " - f"title={selected.windowName}" - ) - return - - raise RuntimeError(f"游戏窗口在 {wait_time}s 内未就绪: {game_path.name}") - - raise RuntimeError(f"游戏进程在 {wait_time}s 内未启动: {game_path.name}") - - async def _activate_desktop_game_window(self, game_path: Path) -> None: - try: - await self.game_process_manager.search_process( - ProcessInfo(exe=str(game_path.resolve())), - datetime.now() + timedelta(seconds=5), - ) - except Exception as exc: - logger.warning(f"MaaFW 定位游戏进程窗口失败: {exc}") - self._append_log(f"游戏进程已启动,但定位窗口失败: {exc}") - return - - activate_end_time = datetime.now() + timedelta(seconds=5) - while datetime.now() < activate_end_time: - if await self.game_process_manager.activate_window(): - self._append_log("游戏窗口已置于前台") - return - await asyncio.sleep(0.5) - - self._append_log("游戏窗口前置失败,将继续启动 MaaFW 任务") - - async def _close_game(self) -> None: - if not self.opened_game: - return - if not self.script_config.get("Game", "CloseOnFinish"): - return - - try: - await self.game_process_manager.kill() - except Exception as exc: - logger.warning(f"MaaFW 清理时关闭游戏失败: {exc}") - finally: - self.opened_game = False - - async def _try_enter_project_path(self) -> bool: - project_lock_key = _normalize_project_path(self.project_path) - async with _RUNNING_PROJECT_PATHS_LOCK: - if project_lock_key in _RUNNING_PROJECT_PATHS: - return False - _RUNNING_PROJECT_PATHS.add(project_lock_key) - self.project_lock_key = project_lock_key - return True - - async def _release_project_path(self) -> None: - if self.project_lock_key is None: - return - async with _RUNNING_PROJECT_PATHS_LOCK: - _RUNNING_PROJECT_PATHS.discard(self.project_lock_key) - self.project_lock_key = None - - async def _save_user_logs(self) -> None: - for timestamp, log_item in self.cur_user_item.log_record.items(): - dt = timestamp.replace(tzinfo=datetime.now().astimezone().tzinfo).astimezone(UTC4) - log_path = ( - Path.cwd() - / f"history/{dt.strftime('%Y-%m-%d')}/{self.cur_user_item.name}/{dt.strftime('%H-%M-%S')}.log" - ) - if not log_item.content: - log_item.content = ["未捕获到任何 MaaFW 运行日志"] - if log_item.status == "未开始监看日志": - log_item.status = "MaaFW 任务被中止" - await Config.save_general_log(log_path, log_item.content, log_item.status) - - async def _send_success_notify(self) -> None: - try: - await Notify.push_plyer( - "MaaFW 自动代理任务完成", - f"已完成用户 {self.cur_user_item.name} 的 MaaFW 自动代理任务", - f"已完成 {self.cur_user_item.name} 的 MaaFW 自动代理任务", - 3, - ) - except Exception as exc: - logger.warning(f"MaaFW 插件用户通知发送失败: {exc}") - - def _append_log(self, message: str) -> None: - logger.info(message) - if self.cur_user_log is not None: - self.cur_user_log.content.append(_format_user_log_line(message)) - self.script_info.log = "".join(self.cur_user_log.content[-80:]) - else: - self.script_info.log = str(message) - - -def _find_controller(interface_model: MaaFWInterface, controller_name: str) -> MaaFWController: - controller = next( - (item for item in interface_model.controller if item.name == controller_name), - None, - ) - if controller is None: - raise RuntimeError(f"未找到 controller: {controller_name}") - return controller - - -def _match_controller_windows(controller: MaaFWController) -> list[Any]: - try: - from app.plugins import PluginManager - - service = PluginManager.service.get("maafw.controller.win32") - except Exception: - service = None - if service is None: - from automas_maafw_controller_win32.service import MaaFWWin32ControllerService - - service = MaaFWWin32ControllerService() - return list(service.match_controller_windows(controller)) - - -def _is_process_path_running(executable_path: Path) -> bool: - target_path = executable_path.resolve() - for proc in psutil.process_iter(["exe"]): - with suppress(psutil.NoSuchProcess, psutil.AccessDenied, OSError): - raw_exe = proc.info.get("exe") or proc.exe() - if raw_exe and Path(raw_exe).resolve() == target_path: - return True - return False - - -def _optional_int(value: Any) -> int | None: - if value in (None, "", "-"): - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - -def _resolve_win32_method( - configured_value: Any, - interface_method: str | None, - method_values: dict[str, int], - default: int, -) -> int: - configured = _optional_int(configured_value) or 0 - if configured: - return configured - if interface_method: - return method_values.get(interface_method, default) - return default - - -def _decode_subprocess_output(data: bytes) -> str: - if not data: - return "" - for encoding in _SUBPROCESS_OUTPUT_ENCODINGS: - try: - return data.decode(encoding, errors="strict") - except (UnicodeDecodeError, LookupError): - continue - return data.decode("latin1", errors="replace") - - -def _remove_method(methods: int, method: int, fallback: int) -> int: - """从位掩码中剔除 method 位;若结果为 0(无可用方法)则回退到 fallback。""" - filtered = methods & ~method - return filtered or fallback - - -def _load_json_dict(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return value - if isinstance(value, str) and value.strip(): - with suppress(json.JSONDecodeError): - data = json.loads(value) - if isinstance(data, dict): - return data - return {} - - -def _load_json_list(value: Any) -> list[str]: - if isinstance(value, list): - return [str(item) for item in value if str(item).strip()] - if isinstance(value, str) and value.strip(): - with suppress(json.JSONDecodeError): - data = json.loads(value) - if isinstance(data, list): - return [str(item) for item in data if str(item).strip()] - return [] - - -def _format_run_overview_log( - plan: MaaFWRunPlan, - *, - selected_preset: str, -) -> str: - task_names = " -> ".join(task.name for task in plan.tasks) or "-" - if len(task_names) > _RUN_OVERVIEW_LOG_VALUE_LIMIT: - task_names = task_names[:_RUN_OVERVIEW_LOG_VALUE_LIMIT] + "..." - return ( - "MaaFW 运行总览: " - f"controller={plan.controllerName}; resource={plan.resourceName}; " - f"preset={selected_preset or '自定义'}; " - f"enabled_tasks({len(plan.tasks)})={task_names}" - ) - - -def _current_period_keys(now: datetime | None = None) -> tuple[str, str, str]: - current = now or datetime.now(tz=UTC4) - iso_year, iso_week, _ = current.isocalendar() - return ( - current.strftime("%Y-%m-%d"), - f"{iso_year}-W{iso_week:02d}", - current.strftime("%Y-%m"), - ) - - -def _normalize_project_path(path: Path) -> str: - return str(path.resolve()).casefold() - - -def _format_user_log_line(message: str) -> str: - timestamp = datetime.now().astimezone().strftime("%H:%M:%S") - lines = str(message).splitlines() or [""] - return "".join(f"[{timestamp}] {line}\n" for line in lines) diff --git a/plugins/automas_script_maafw/src/automas_script_maafw/schema.py b/plugins/automas_script_maafw/src/automas_script_maafw/schema.py deleted file mode 100644 index 50795581..00000000 --- a/plugins/automas_script_maafw/src/automas_script_maafw/schema.py +++ /dev/null @@ -1,237 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from app.plugins.fields import PluginField - - -schema = { - "__no_plugin_config__": { - "type": "boolean", - "default": True, - "hidden": True, - "configurable": False, - "title": "No plugin-level configuration", - }, -} - - -SCRIPT_GROUPS = ( - PluginField.group( - "Info", - "基础信息", - [ - PluginField.string("Name", "脚本名称", "新 MaaFW 脚本"), - PluginField.string("ProjectLabel", "项目标签", ""), - PluginField.folder( - "Path", - "MaaFW 项目路径", - "", - placeholder="选择包含 interface.json 的项目目录", - size="large", - required=True, - ), - PluginField.string("Controller", "Controller", ""), - PluginField.string("Resource", "Resource", ""), - ], - ), - PluginField.group( - "Emulator", - "模拟器", - [ - PluginField.related_id( - "Id", - "模拟器", - "-", - related_config="EmulatorConfig", - ), - PluginField.string("Index", "模拟器实例", "-"), - ], - ), - PluginField.group( - "Device", - "控制器", - [ - PluginField.file("AdbPath", "ADB 路径", "", size="large"), - PluginField.string("AdbAddress", "ADB 地址", ""), - PluginField.number( - "AdbScreencapMethods", - "ADB 截图方法", - -57, - min=-999, - max=999999999999, - step=1, - ), - PluginField.number( - "AdbInputMethods", - "ADB 输入方法", - -1, - min=-999, - max=999999999999, - step=1, - ), - PluginField.number("HWnd", "Win32 窗口句柄", 0, min=0, max=999999999999, step=1), - PluginField.number( - "Win32ScreencapMethod", - "Win32 截图方法", - 0, - min=0, - max=999999999999, - step=1, - ), - PluginField.number( - "Win32MouseMethod", - "Win32 鼠标方法", - 0, - min=0, - max=999999999999, - step=1, - ), - PluginField.number( - "Win32KeyboardMethod", - "Win32 键盘方法", - 0, - min=0, - max=999999999999, - step=1, - ), - PluginField.number( - "GamepadType", - "Gamepad 类型", - 0, - min=0, - max=999999999999, - step=1, - ), - PluginField.string("PlayCoverAddress", "PlayCover 地址", ""), - PluginField.string("PlayCoverUuid", "PlayCover UUID", ""), - ], - ), - PluginField.group( - "Game", - "游戏", - [ - PluginField.file("Path", "游戏路径", "", size="large"), - PluginField.string("Arguments", "启动参数", ""), - PluginField.number("WaitTime", "等待窗口时间", 60, min=0, max=9999, step=1), - PluginField.boolean("CloseOnFinish", "结束后关闭游戏", True), - ], - ), - PluginField.group( - "Update", - "项目更新", - [ - PluginField.boolean("IfAutoUpdate", "运行前自动更新", True), - PluginField.select( - "Source", - "更新源", - "MirrorChyan", - ["MirrorChyan", "GitHub"], - ), - PluginField.select("Channel", "更新渠道", "", ["", "stable", "beta"]), - PluginField.string("MirrorChyanCDK", "Mirror 酱 CDK", "", sensitive=True), - PluginField.string("GitHubRepo", "GitHub 仓库", ""), - PluginField.string("GitHubTag", "GitHub Tag", ""), - PluginField.string("GitHubAssetPattern", "GitHub Asset 匹配", ""), - ], - ), - PluginField.group( - "Run", - "运行", - [ - PluginField.number("ProxyTimesLimit", "每日代理次数限制", 0, min=0, max=9999, step=1), - PluginField.number("RunTimesLimit", "失败重试次数", 1, min=1, max=9999, step=1), - PluginField.number("RunTimeLimit", "单次运行超时(分钟)", 30, min=1, max=9999, step=1), - PluginField.json("DailyOnceTasks", "每日一次任务", "[]", json_type="array"), - PluginField.json("WeeklyOnceTasks", "每周一次任务", "[]", json_type="array"), - PluginField.json("MonthlyOnceTasks", "每月一次任务", "[]", json_type="array"), - ], - ), -) - - -USER_GROUPS = ( - PluginField.group( - "Info", - "基础信息", - [ - PluginField.string("Name", "用户名称", "新用户", validator="username"), - PluginField.boolean("Status", "启用用户", True), - PluginField.number("RemainedDay", "剩余天数", -1, min=-1, max=9999, step=1), - PluginField.boolean("IfScriptBeforeTask", "任务前脚本", False), - PluginField.file("ScriptBeforeTask", "任务前脚本路径", "", size="large"), - PluginField.boolean("IfScriptAfterTask", "任务后脚本", False), - PluginField.file("ScriptAfterTask", "任务后脚本路径", "", size="large"), - PluginField.string("Notes", "备注", "无", rows=3, size="large"), - PluginField.string("Account", "账号", ""), - PluginField.string("Password", "密码", "", sensitive=True), - PluginField.string("Controller", "Controller", ""), - PluginField.string("Resource", "Resource", ""), - ], - ), - PluginField.group( - "Task", - "任务", - [ - PluginField.string("SelectedPreset", "Preset", ""), - PluginField.json("TaskSnapshot", "任务快照", "{}", json_type="object"), - ], - ), - PluginField.group( - "Device", - "控制器覆盖", - [ - PluginField.string("AdbAddress", "ADB 地址", ""), - PluginField.number("HWnd", "Win32 窗口句柄", 0, min=0, max=999999999999, step=1), - PluginField.string("PlayCoverAddress", "PlayCover 地址", ""), - PluginField.string("PlayCoverUuid", "PlayCover UUID", ""), - ], - ), - PluginField.group( - "Data", - "运行数据", - [ - PluginField.datetime("LastProxyDate", "上次代理日期", "2000-01-01", format="%Y-%m-%d"), - PluginField.number("ProxyTimes", "今日代理次数", 0, min=0, max=9999, step=1), - PluginField.boolean("IfPassCheck", "检查通过", True), - PluginField.string("LastProxyStatus", "上次运行状态", "未知"), - PluginField.json("PeriodTaskRecords", "周期任务记录", "{}", json_type="object"), - ], - ), - PluginField.group( - "Notify", - "通知", - [ - PluginField.boolean("Enabled", "启用通知", False), - PluginField.boolean("IfSendStatistic", "发送统计信息", False), - PluginField.boolean("IfSendMail", "邮件通知", False), - PluginField.string("ToAddress", "收件地址", ""), - PluginField.boolean("IfServerChan", "启用 Server 酱", False), - PluginField.string("ServerChanKey", "Server 酱密钥", "", sensitive=True), - PluginField.json("CustomWebhooks", "自定义 Webhook", "{}", json_type="object"), - ], - ), -) - - -def build_source_config(script_data: dict[str, Any]) -> dict[str, Any] | None: - update = script_data.get("Update") - if not isinstance(update, dict): - return None - - source = str(update.get("Source") or "").strip() - if source == "GitHub": - return { - "source": "github_release", - "repo": str(update.get("GitHubRepo") or "").strip(), - "tag": str(update.get("GitHubTag") or "").strip(), - "asset_pattern": str(update.get("GitHubAssetPattern") or "").strip(), - "channel": str(update.get("Channel") or "").strip(), - } - if source == "MirrorChyan": - return { - "source": "mirrorchyan", - "channel": str(update.get("Channel") or "").strip(), - "cdk": str(update.get("MirrorChyanCDK") or "").strip(), - } - return None diff --git a/plugins/automas_script_maafw_pack_m9a/README.md b/plugins/automas_script_maafw_pack_m9a/README.md deleted file mode 100644 index f80b3be7..00000000 --- a/plugins/automas_script_maafw_pack_m9a/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# automas-script-maafw-pack-m9a - -M9A project pack declarations for the AUTO-MAS MaaFW plugin. - -The package provides `maafw.pack.m9a.v1`. It does not declare default project -sources, controller, resource, preset, task queues, or task templates. Those are -loaded through the generic MaaFW project/interface flow. diff --git a/plugins/automas_script_maafw_pack_m9a/pyproject.toml b/plugins/automas_script_maafw_pack_m9a/pyproject.toml deleted file mode 100644 index 89f99d8d..00000000 --- a/plugins/automas_script_maafw_pack_m9a/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "automas-script-maafw-pack-m9a" -version = "0.1.0" -description = "M9A project pack declarations for the AUTO-MAS MaaFW plugin" -readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" -dependencies = [ - "automas-script-maafw>=0.1.0", - "pydantic>=2", -] - -[project.entry-points."auto_mas.plugins"] -automas_script_maafw_pack_m9a = "automas_script_maafw_pack_m9a.plugin:Plugin" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -automas_script_maafw_pack_m9a = ["py.typed", "assets/*.png"] diff --git a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/__init__.py b/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/__init__.py deleted file mode 100644 index 3c903125..00000000 --- a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from .models import ( - M9AMigrationDraft, - M9ANotificationContent, - M9APackDefinition, - M9APeriodRule, -) -from .service import M9APackService - -__all__ = [ - "M9AMigrationDraft", - "M9ANotificationContent", - "M9APackDefinition", - "M9APackService", - "M9APeriodRule", -] diff --git a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/assets/m9a.png b/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/assets/m9a.png deleted file mode 100644 index f4f7c304..00000000 Binary files a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/assets/m9a.png and /dev/null differ diff --git a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/migration.py b/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/migration.py deleted file mode 100644 index 71c7a2e6..00000000 --- a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/migration.py +++ /dev/null @@ -1,398 +0,0 @@ -from __future__ import annotations - -import json -from typing import Any - -from automas_maafw_interface.models import MaaFWInterface, MaaFWOption, MaaFWTask -from automas_maafw_interface.service import MaaFWInterfaceService - - -_RESERVED_TASK_NAMES = { - "启动游戏", - "关闭游戏", - "切换账号", - "StartUp", - "CloseDown", - "Close1999", - "SwitchAccount", -} - - -async def migrate_legacy_m9a_config(legacy_script: Any, provider: Any) -> Any: - """Convert a persisted M9AConfig into the generated MaaFW plugin shape.""" - - raw_script = await legacy_script.toDict(if_decrypt=False) - project_path = str(_nested(raw_script, "Info", "Path") or "").strip() - if not project_path: - raise ValueError("旧 M9A 配置缺少项目路径,暂不自动迁移") - - interface = MaaFWInterfaceService().load(project_path) - controller_name = _default_adb_controller(interface) - script_payload = _build_script_payload(raw_script, interface, controller_name) - script_payload["SubConfigsInfo"] = { - "UserData": await _build_user_collection( - legacy_script, - provider.user_config_class, - interface, - controller_name, - ) - } - - migrated = provider.script_config_class() - await migrated.load(script_payload) - return migrated - - -def _build_script_payload( - raw_script: dict[str, Any], - interface: MaaFWInterface, - controller_name: str, -) -> dict[str, Any]: - daily_tasks = ( - _task_names_for_entries(interface, "Psychube") - if bool(_nested(raw_script, "Run", "IfPsychubeDailyOnce")) - else [] - ) - monthly_tasks = ( - _task_names_for_entries(interface, "Limbo", "Lucidscape", "SleepDream") - if bool(_nested(raw_script, "Run", "IfSleepDreamMonthlyOnce")) - else [] - ) - return { - "Info": { - "Name": _nested(raw_script, "Info", "Name"), - "ProjectLabel": interface.label or interface.name, - "Path": _nested(raw_script, "Info", "Path"), - "Controller": controller_name, - "Resource": "", - }, - "Emulator": { - "Id": _nested(raw_script, "Emulator", "Id"), - "Index": _nested(raw_script, "Emulator", "Index"), - }, - "Update": { - "IfAutoUpdate": bool( - _nested(raw_script, "Run", "IfAutoUpdateAfterQueue") - ), - }, - "Run": { - "ProxyTimesLimit": _nested(raw_script, "Run", "ProxyTimesLimit"), - "RunTimesLimit": _nested(raw_script, "Run", "RunTimesLimit"), - "RunTimeLimit": _nested(raw_script, "Run", "RunTimeLimit"), - "DailyOnceTasks": _json(daily_tasks), - "WeeklyOnceTasks": _json([]), - "MonthlyOnceTasks": _json(monthly_tasks), - }, - } - - -async def _build_user_collection( - legacy_script: Any, - user_config_class: type, - interface: MaaFWInterface, - controller_name: str, -) -> dict[str, Any]: - result: dict[str, Any] = {"instances": []} - for uid, old_user in legacy_script.UserData.items(): - raw_user = await old_user.toDict(if_decrypt=False) - result["instances"].append( - {"uid": str(uid), "type": user_config_class.__name__} - ) - result[str(uid)] = _build_user_payload( - raw_user, - interface, - controller_name, - ) - return result - - -def _build_user_payload( - raw_user: dict[str, Any], - interface: MaaFWInterface, - controller_name: str, -) -> dict[str, Any]: - task_snapshot = _build_task_snapshot( - _load_json(_nested(raw_user, "Task", "Queue"), list), - interface, - ) - period_records = { - "daily": _period_records( - interface, - str(_nested(raw_user, "Data", "LastPsychubeDate") or "2000-01-01"), - "Psychube", - ), - "weekly": {}, - "monthly": { - **_period_records( - interface, - str(_nested(raw_user, "Data", "LastLimboMonth") or "2000-01"), - "Limbo", - ), - **_period_records( - interface, - str( - _nested(raw_user, "Data", "LastLucidscapeMonth") - or "2000-01" - ), - "Lucidscape", - ), - }, - } - resource_name = _match_resource_name( - interface, - str(_nested(raw_user, "Info", "Resource") or ""), - controller_name, - ) - payload = { - "Info": { - **_copy_group( - raw_user, - "Info", - ( - "Name", - "Status", - "RemainedDay", - "IfScriptBeforeTask", - "ScriptBeforeTask", - "IfScriptAfterTask", - "ScriptAfterTask", - "Notes", - "Account", - ), - ), - "Controller": controller_name, - "Resource": resource_name, - }, - "Task": { - "SelectedPreset": "", - "TaskSnapshot": _json(task_snapshot), - }, - "Data": { - **_copy_group( - raw_user, - "Data", - ("LastProxyDate", "ProxyTimes", "IfPassCheck"), - ), - "PeriodTaskRecords": _json(period_records), - }, - "Notify": _copy_group( - raw_user, - "Notify", - ( - "Enabled", - "IfSendStatistic", - "IfSendMail", - "ToAddress", - "IfServerChan", - "ServerChanKey", - ), - ), - } - custom_webhooks = ( - raw_user.get("SubConfigsInfo", {}).get("Notify_CustomWebhooks") - if isinstance(raw_user.get("SubConfigsInfo"), dict) - else None - ) - if isinstance(custom_webhooks, dict): - payload["SubConfigsInfo"] = { - "Notify_CustomWebhooks": custom_webhooks, - } - return payload - - -def _build_task_snapshot( - queue: list[Any], - interface: MaaFWInterface, -) -> dict[str, Any]: - task_order: list[str] = [] - task_checked = {task.name: False for task in interface.task} - task_options: dict[str, dict[str, Any]] = {} - for item in queue: - if not isinstance(item, dict): - continue - raw_name = str(item.get("name") or item.get("entry") or "").strip() - if not raw_name or raw_name in _RESERVED_TASK_NAMES: - continue - task = _match_task(interface, raw_name, str(item.get("entry") or "")) - if task is None or task.name in task_order: - continue - task_order.append(task.name) - task_checked[task.name] = True - task_options[task.name] = _convert_task_options( - item.get("options"), - interface, - ) - - task_order.extend(task.name for task in interface.task if task.name not in task_order) - return { - "taskOrder": task_order, - "taskChecked": task_checked, - "taskOptions": task_options, - } - - -def _convert_task_options( - raw_options: Any, - interface: MaaFWInterface, -) -> dict[str, Any]: - converted: dict[str, Any] = {} - if not isinstance(raw_options, list): - return converted - for raw_option in raw_options: - if not isinstance(raw_option, dict): - continue - option_name = str(raw_option.get("name") or "").strip() - option = interface.option.get(option_name) - if not option_name or option is None: - continue - value = _convert_option_value(raw_option, option) - if value is not None: - converted[option_name] = value - converted.update( - _convert_task_options(raw_option.get("sub_options"), interface) - ) - return converted - - -def _convert_option_value(raw_option: dict[str, Any], option: MaaFWOption) -> Any: - if option.type == "input": - value = raw_option.get("input_values", raw_option.get("data")) - if isinstance(value, dict): - return { - str(key): str(item) - for key, item in value.items() - if isinstance(key, str) - } - return None - if option.type == "checkbox": - selected = raw_option.get("selected_cases") - if isinstance(selected, list): - return [str(item) for item in selected] - return [] - - cases = option.cases or [] - selected = raw_option.get("selected_cases") - if isinstance(selected, list) and selected: - return str(selected[0]) - try: - index = int(raw_option.get("index", 0)) - except (TypeError, ValueError): - index = 0 - if 0 <= index < len(cases): - return cases[index].name - return cases[0].name if cases else None - - -def _match_task( - interface: MaaFWInterface, - name: str, - entry: str = "", -) -> MaaFWTask | None: - for candidate in (name, entry): - if not candidate: - continue - task = next( - ( - item - for item in interface.task - if candidate in {item.name, item.entry, item.label} - ), - None, - ) - if task is not None: - return task - return None - - -def _task_names_for_entries( - interface: MaaFWInterface, - *entries: str, -) -> list[str]: - wanted = set(entries) - return [ - task.name - for task in interface.task - if task.entry in wanted or task.name in wanted - ] - - -def _period_records( - interface: MaaFWInterface, - period_key: str, - *entries: str, -) -> dict[str, str]: - return { - task_name: period_key - for task_name in _task_names_for_entries(interface, *entries) - } - - -def _default_adb_controller(interface: MaaFWInterface) -> str: - controller = next( - (item for item in interface.controller if item.type == "Adb"), - None, - ) - return controller.name if controller is not None else "" - - -def _match_resource_name( - interface: MaaFWInterface, - legacy_value: str, - controller_name: str, -) -> str: - if legacy_value: - resource = next( - ( - item - for item in interface.resource - if legacy_value in {item.name, item.label} - ), - None, - ) - if resource is not None: - return resource.name - resource = next( - ( - item - for item in interface.resource - if not item.controller or controller_name in item.controller - ), - None, - ) - return resource.name if resource is not None else "" - - -def _copy_group( - payload: dict[str, Any], - group_name: str, - field_names: tuple[str, ...], -) -> dict[str, Any]: - group = payload.get(group_name) - if not isinstance(group, dict): - return {} - return {name: group[name] for name in field_names if name in group} - - -def _nested(payload: dict[str, Any], group: str, name: str) -> Any: - value = payload.get(group) - return value.get(name) if isinstance(value, dict) else None - - -def _load_json(value: Any, expected_type: type) -> Any: - if isinstance(value, expected_type): - return value - if isinstance(value, str): - try: - parsed = json.loads(value) - except json.JSONDecodeError: - return expected_type() - if isinstance(parsed, expected_type): - return parsed - return expected_type() - - -def _json(value: Any) -> str: - return json.dumps(value, ensure_ascii=False) - - -__all__ = ["migrate_legacy_m9a_config"] diff --git a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/models.py b/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/models.py deleted file mode 100644 index 8d79785e..00000000 --- a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/models.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from typing import Any, Literal - -from pydantic import BaseModel, Field - - -class M9APeriodRule(BaseModel): - task: str - period: Literal["daily", "weekly", "monthly"] - - -class M9APackDefinition(BaseModel): - """M9A project pack 定义,对应文档 6.3 MaaFWProjectPackDefinition。""" - - key: str = "m9a" - display_name: str = "M9A" - project_repo: str | None = None - interface_path: str = "interface.json" - supported_controllers: list[str] = Field(default_factory=lambda: ["adb", "win32"]) - default_controller: str = "adb" - default_resource: str | None = None - default_preset: str | None = None - default_task_queue: list[str] | None = None - period_rules: list[M9APeriodRule] = Field(default_factory=list) - reserved_task_semantics: dict[str, Any] = Field(default_factory=dict) - icon: str | None = "automas_script_maafw_pack_m9a:assets/m9a.png" - notes: str | None = None - # 额外能力声明(非 6.3 标准字段) - framework: str = "maafw" - capabilities: list[str] = Field(default_factory=list) - - -class M9ANotificationContent(BaseModel): - title: str - text: str - html: str | None = None - - -class M9AMigrationDraft(BaseModel): - script: dict[str, Any] - users: list[dict[str, Any]] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) diff --git a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/plugin.py b/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/plugin.py deleted file mode 100644 index 9304a453..00000000 --- a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/plugin.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from app.plugins import ScriptAdapterDefinition, ScriptAdapterPlugin -from automas_script_maafw.adapter import MaaFWAdapterHooks - -from .migration import migrate_legacy_m9a_config -from .schema import M9A_SCRIPT_GROUPS, M9A_USER_GROUPS -from .service import M9APackService - -if TYPE_CHECKING: - from auto_mas_core import PluginContext - - -DEFAULT_INSTANCE = { - "name": "M9A MaaFW Pack", - "enabled": True, - "config": {}, -} - -schema = { - "__no_plugin_config__": { - "type": "boolean", - "default": True, - "hidden": True, - "configurable": False, - "title": "No plugin-level configuration", - }, -} - -SCRIPT_TYPE_BINDINGS = [ - { - "type_key": "M9A", - "display_name": "M9A", - "script_config_class_name": "M9AConfig", - } -] - - -class Plugin(ScriptAdapterPlugin): - provides = ["maafw.pack.m9a.v1"] - wants = [ - "maafw.registry.v1", - "maafw.interface.v1", - "maafw.project_update.v1", - "maafw.runner.v1", - ] - - def __init__(self, ctx: "PluginContext") -> None: - super().__init__(ctx) - self.ctx = ctx - self.service = M9APackService() - - def build_script_adapters(self): - return [ - ScriptAdapterDefinition( - type_key="M9A", - display_name="M9A", - hooks_factory=MaaFWAdapterHooks, - script_groups=M9A_SCRIPT_GROUPS, - user_groups=M9A_USER_GROUPS, - script_class_name="M9APluginConfig", - user_class_name="M9APluginUserConfig", - module="automas_script_maafw.schema", - related_bindings={"EmulatorConfig": "EmulatorConfig"}, - supported_modes=("AutoProxy",), - icon="M9A", - icon_path="automas_script_maafw_pack_m9a:assets/m9a.png", - editor_kind="plugin:automas_script_maafw_pack_m9a", - legacy_config_class_name="M9AConfig", - legacy_user_config_class_name="M9AUserConfig", - is_builtin=False, - metadata={ - "framework": "maafw", - "source": "automas_script_maafw_pack_m9a", - "project_pack": "m9a", - "m9a_standalone": True, - "theme_color": "cyan", - "legacy_config_migrator": migrate_legacy_m9a_config, - "script_edit_hint": { - "text": "遇到配置问题时,可以查看", - "link_text": "M9A 配置指南", - "url": "https://doc.auto-mas.top/docs/script-guide/m9a.html", - "suffix": ",其中整理了项目目录、运行环境和任务配置步骤。", - }, - }, - ) - ] - - async def on_start(self) -> None: - self.ctx.set("maafw.pack.m9a.v1", self.service) - registry = self.ctx.get("maafw.registry.v1") - if registry is not None and hasattr(registry, "register_project_pack"): - registry.register_project_pack(self.service.get_definition()) - await super().on_start() - self.ctx.logger.info("maafw.pack.m9a.v1 ready") - - async def on_stop(self, reason: str) -> None: - registry = self.ctx.get("maafw.registry.v1") - if registry is not None and hasattr(registry, "unregister_project_pack"): - registry.unregister_project_pack("m9a") - self.ctx.set("maafw.pack.m9a.v1", None) - await super().on_stop(reason) - self.ctx.logger.info(f"maafw.pack.m9a.v1 stopped, reason={reason}") diff --git a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/py.typed b/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/py.typed deleted file mode 100644 index 8b137891..00000000 --- a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/schema.py b/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/schema.py deleted file mode 100644 index 0e98d21e..00000000 --- a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/schema.py +++ /dev/null @@ -1,75 +0,0 @@ -"""M9A project-pack schema overrides for the shared MaaFW editor.""" - -from __future__ import annotations - -import json -from dataclasses import replace - -from app.plugins.fields import PluginFieldGroup -from automas_script_maafw.schema import SCRIPT_GROUPS, USER_GROUPS, build_source_config - - -schema = { - "__no_plugin_config__": { - "type": "boolean", - "default": True, - "hidden": True, - "configurable": False, - "title": "No plugin-level configuration", - }, -} - - -M9A_PERIOD_TASK_DEFAULTS = { - "DailyOnceTasks": ["Psychube"], - "WeeklyOnceTasks": [], - "MonthlyOnceTasks": ["SleepDream"], -} - - -def _build_m9a_info_group(group: PluginFieldGroup) -> PluginFieldGroup: - name_field = replace(group.fields[0], default="新M9A脚本") - path_field = replace( - group.fields[2], - label="M9A 项目路径", - placeholder="选择包含 interface.json 的 M9A 项目目录", - ) - return replace( - group, - fields=( - name_field, - group.fields[1], - path_field, - *group.fields[3:], - ), - ) - - -def _build_m9a_run_group(group: PluginFieldGroup) -> PluginFieldGroup: - return replace( - group, - fields=tuple( - replace(field, default=json.dumps(M9A_PERIOD_TASK_DEFAULTS[field.name])) - if field.name in M9A_PERIOD_TASK_DEFAULTS - else field - for field in group.fields - ), - ) - - -def _build_m9a_group(group: PluginFieldGroup) -> PluginFieldGroup: - if group.key == "Info": - return _build_m9a_info_group(group) - if group.key == "Run": - return _build_m9a_run_group(group) - return group - - -M9A_SCRIPT_GROUPS: tuple[PluginFieldGroup, ...] = tuple( - _build_m9a_group(group) for group in SCRIPT_GROUPS -) - -# User config intentionally reuses the MaaFW surface and storage shape. -M9A_USER_GROUPS = USER_GROUPS - -__all__ = ["M9A_SCRIPT_GROUPS", "M9A_USER_GROUPS", "build_source_config"] diff --git a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/service.py b/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/service.py deleted file mode 100644 index d1ef7186..00000000 --- a/plugins/automas_script_maafw_pack_m9a/src/automas_script_maafw_pack_m9a/service.py +++ /dev/null @@ -1,185 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any - -from .models import ( - M9AMigrationDraft, - M9ANotificationContent, - M9APackDefinition, - M9APeriodRule, -) - - -PERIOD_RULES = [ - M9APeriodRule(task="Psychube", period="daily"), - M9APeriodRule(task="SleepDream", period="monthly"), -] - -DEFAULT_TASK_QUEUE = [ - "StartUp", - "Psychube", - "SleepDream", - "Award", - "CloseDown", -] - -RESERVED_TASK_SEMANTICS = { - "Psychube": {"period": "daily", "label": "每日心相"}, - "SleepDream": {"period": "monthly", "label": "深眠浅梦"}, -} - - -class M9APackService: - """maafw.pack.m9a.v1 service.""" - - def get_definition(self) -> M9APackDefinition: - return M9APackDefinition( - key="m9a", - display_name="M9A", - project_repo="MAA1999/M9A", - interface_path="interface.json", - supported_controllers=["adb", "win32"], - default_controller="adb", - default_resource="resource", - default_preset="日常任务", - default_task_queue=DEFAULT_TASK_QUEUE, - period_rules=PERIOD_RULES, - reserved_task_semantics=RESERVED_TASK_SEMANTICS, - icon="automas_script_maafw_pack_m9a:assets/m9a.png", - notes="M9A 专项插件包 — 重返未来:1999 自动化", - framework="maafw", - capabilities=[ - "period_rules", - "notification_translation", - "create_only_migration", - ], - ) - - def translate_notification( - self, - result: Any, - *, - script_name: str = "M9A", - user_name: str = "", - started_at: str | None = None, - ended_at: str | None = None, - ) -> M9ANotificationContent: - payload = _to_mapping(result) - success = bool(payload.get("success")) - completed_tasks = [ - str(item) - for item in payload.get("completedTasks", []) - if str(item).strip() - ] - failed_task = str(payload.get("failedTask") or "").strip() - error_message = str(payload.get("errorMessage") or "").strip() - - subject = user_name or script_name or "M9A" - title = f"{subject} 的 M9A 任务已完成" if success else f"{subject} 的 M9A 任务未完成" - lines = [] - if started_at or ended_at: - lines.append(f"开始时间: {started_at or '-'}") - lines.append(f"结束时间: {ended_at or datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - if completed_tasks: - lines.append("已完成任务: " + ", ".join(completed_tasks)) - else: - lines.append("已完成任务: 无") - if failed_task: - lines.append(f"失败任务: {failed_task}") - if error_message: - lines.append(f"失败原因: {error_message}") - if not error_message and not success: - lines.append("失败原因: MaaFW runner 未返回成功状态") - return M9ANotificationContent(title=title, text="\n".join(lines)) - - def create_migration_draft( - self, - old_script_config: dict[str, Any] | None, - old_user_configs: list[dict[str, Any]] | None = None, - *, - script_name: str = "M9A", - ) -> M9AMigrationDraft: - warnings = [ - "迁移入口只创建新插件配置草稿,不覆盖旧 M9A 配置。", - "项目来源、controller、resource、preset、任务队列和模板必须通过通用 MaaFW 流程现拉。", - ] - script_payload = { - "type": "PluginScriptConfig", - "Meta": {"PluginTypeKey": "M9A"}, - "Info": {"Name": script_name}, - "PluginData": { - "Config": { - "pack": "m9a", - "legacy": _filter_legacy_script_fields(old_script_config or {}), - } - }, - } - - users: list[dict[str, Any]] = [] - for index, old_user in enumerate(old_user_configs or [], start=1): - user_name = _extract_user_name(old_user) or f"M9A 用户 {index}" - users.append( - { - "type": "PluginUserConfig", - "Meta": {"PluginTypeKey": "M9A"}, - "Info": {"Name": user_name}, - "PluginData": { - "Config": { - "pack": "m9a", - "legacy": _filter_legacy_user_fields(old_user), - } - }, - } - ) - - return M9AMigrationDraft(script=script_payload, users=users, warnings=warnings) - - -def _to_mapping(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return value - if hasattr(value, "model_dump"): - data = value.model_dump(mode="json") - return data if isinstance(data, dict) else {} - if hasattr(value, "__dict__"): - return { - key: item - for key, item in vars(value).items() - if not key.startswith("_") - } - return {} - - -def _filter_legacy_script_fields(value: dict[str, Any]) -> dict[str, Any]: - return { - "path": _nested_get(value, "Info", "Path"), - "autoUpdate": _nested_get(value, "Run", "IfAutoUpdateAfterQueue"), - "weeklyTasks": _nested_get(value, "Run", "WeeklyOnceTasks"), - "monthlyTasks": _nested_get(value, "Run", "MonthlyOnceTasks"), - } - - -def _filter_legacy_user_fields(value: dict[str, Any]) -> dict[str, Any]: - return { - "name": _extract_user_name(value), - "taskQueue": _nested_get(value, "Task", "Queue"), - "notifyEnabled": _nested_get(value, "Notify", "Enabled"), - } - - -def _nested_get(value: dict[str, Any], section: str, key: str) -> Any: - section_value = value.get(section) - if isinstance(section_value, dict): - return section_value.get(key) - return None - - -def _extract_user_name(value: dict[str, Any]) -> str: - info = value.get("Info") - if isinstance(info, dict): - name = info.get("Name") - if isinstance(name, str) and name.strip(): - return name.strip() - name = value.get("name") - return name.strip() if isinstance(name, str) else "" diff --git a/pyproject.toml b/pyproject.toml index cc9d310b..9b135da2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,15 +42,6 @@ dev = [ ] plugins = [ "auto-mas-core", - "automas-m9a", - "automas-maafw-agent-env", - "automas-maafw-controller-adb", - "automas-maafw-controller-win32", - "automas-maafw-interface", - "automas-maafw-project-update", - "automas-maafw-runner", - "automas-script-maafw", - "automas-script-maafw-pack-m9a", "automas_plugin_okww_adapter", ] @@ -59,7 +50,16 @@ plugins = [ # 1) 仅声明包名: "automas_script_maa" # 2) 声明固定版本: { name = "automas_script_maa", version = "0.0.5-alpha" } # 3) 声明任意版本约束: { name = "automas_script_maa", specifier = ">=0.0.5-alpha,<0.1.0" } -packages = [] +packages = [ + { name = "automas-maafw-interface", version = "0.1.1" }, + { name = "automas-maafw-agent-env", version = "0.1.0" }, + { name = "automas-maafw-controller-adb", version = "0.1.0" }, + { name = "automas-maafw-controller-win32", version = "0.1.1" }, + { name = "automas-maafw-project-update", version = "0.1.0" }, + { name = "automas-maafw-runner", version = "0.1.1" }, + { name = "automas-script-maafw", version = "0.1.2" }, + { name = "automas-script-maafw-pack-m9a", version = "0.1.1" }, +] [tool.uv] package = false @@ -67,15 +67,6 @@ package = false [tool.uv.workspace] members = [ "plugins/auto_mas_core", - "plugins/automas_m9a", - "plugins/automas_maafw_agent_env", - "plugins/automas_maafw_controller_adb", - "plugins/automas_maafw_controller_win32", - "plugins/automas_maafw_interface", - "plugins/automas_maafw_project_update", - "plugins/automas_maafw_runner", - "plugins/automas_script_maafw", - "plugins/automas_script_maafw_pack_m9a", "plugins/okww_adapter", ] exclude = [ @@ -85,13 +76,4 @@ exclude = [ [tool.uv.sources] "auto-mas-core" = { workspace = true } -"automas-m9a" = { workspace = true } -"automas-maafw-agent-env" = { workspace = true } -"automas-maafw-controller-adb" = { workspace = true } -"automas-maafw-controller-win32" = { workspace = true } -"automas-maafw-interface" = { workspace = true } -"automas-maafw-project-update" = { workspace = true } -"automas-maafw-runner" = { workspace = true } -"automas-script-maafw" = { workspace = true } -"automas-script-maafw-pack-m9a" = { workspace = true } "automas_plugin_okww_adapter" = { workspace = true } diff --git a/res/version.json b/res/version.json index 897baad3..c109419d 100644 --- a/res/version.json +++ b/res/version.json @@ -26,6 +26,7 @@ "游戏签到 新增米游社 Passport 扫码登录,可生成二维码、轮询确认状态并保存 Token 到账号组" ], "程序优化": [ + "MaaFW/M9A专项 插件源码迁移至独立仓库,并通过 PyPI 固定版本安装,避免主仓残留源码覆盖已安装插件", "okww专项 重构配置项,路径/日志/进程追踪从 RootPath 实时派生,移除模板冻结的冗余字段,随版本更新自动同步", "游戏签到 移除 MiyousheEnabled/KuroEnabled/SklandEnabled 冗余开关,有 Token 即参与签到", "游戏签到 森空岛签到按游戏分组返回(明日方舟/终末地),避免一个 Token 重复触发",