diff --git a/app/api/script_types.py b/app/api/script_types.py index 68bb1fb4f..efef7b074 100644 --- a/app/api/script_types.py +++ b/app/api/script_types.py @@ -45,7 +45,7 @@ async def get_script_type_icon(type_key: str) -> Response: """根据脚本类型键返回插件声明的图标资源。 icon_path 格式为 ``package_name:relative/path``,例如 - ``automas_script_maafw_pack_m9a:assets/m9a.png``。 + ``example_script_plugin:assets/icon.png``。 """ try: provider = script_type_registry.get(type_key) diff --git a/app/api/scripts.py b/app/api/scripts.py index a1f149bbb..a282da40e 100644 --- a/app/api/scripts.py +++ b/app/api/scripts.py @@ -99,7 +99,6 @@ def _build_maafw_agent_env_info_items(agent_plans: list[Any]) -> list[MaaFWAgent "MaaConfig": MaaConfig, "SrcConfig": SrcConfig, "MaaEndConfig": MaaEndConfig, - "M9AConfig": M9AConfig, "MaaFWConfig": MaaFWConfig, "GeneralConfig": GeneralConfig, "PluginScriptConfig": PluginScriptConfig, @@ -108,7 +107,6 @@ def _build_maafw_agent_env_info_items(agent_plans: list[Any]) -> list[MaaFWAgent "MaaConfig": MaaUserConfig, "SrcConfig": SrcUserConfig, "MaaEndConfig": MaaEndUserConfig, - "M9AConfig": M9AUserConfig, "MaaFWConfig": MaaFWUserConfig, "GeneralConfig": GeneralUserConfig, "PluginScriptConfig": PluginUserConfig, @@ -157,7 +155,7 @@ def _plugin_provider(type_key: str): def _is_maafw_framework_script(script_config: Any) -> bool: - """判定脚本配置是否属于 MaaFW 框架运行链路(含 M9A 等 pack 形态)。""" + """判定脚本配置是否属于 MaaFW 框架运行链路。""" from app.core.script_types import script_type_registry @@ -181,7 +179,7 @@ def _is_maafw_framework_script(script_config: Any) -> bool: return provider.metadata.get("framework") == "maafw" except Exception: # 注册表未就绪或类名未注册时,回退到已知 legacy 类名。 - return config_class_name in {"MaaFWConfig", "M9AConfig"} + return config_class_name == "MaaFWConfig" async def _resolve_maafw_script_form(script_config: Any) -> dict[str, Any]: @@ -189,7 +187,7 @@ async def _resolve_maafw_script_form(script_config: Any) -> dict[str, Any]: 插件形态脚本统一存为 PluginScriptConfig,真实配置在 PluginData.Config (JSON 字符串),须经 storage_to_form 解码后才有 Info.Path / Update.* 字段; - legacy MaaFWConfig/M9AConfig 直接 toDict 即为表单态。 + legacy MaaFWConfig 直接 toDict 即为表单态。 """ from app.models.plugin_script_config import PluginScriptConfig diff --git a/app/core/config.py b/app/core/config.py index 9e869bc2a..4deeefeef 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -36,6 +36,11 @@ from typing import Literal, Optional, Union, Dict, Any, List import uuid import json +import inspect +from contextlib import asynccontextmanager +from contextvars import ContextVar +from functools import wraps +from collections.abc import AsyncIterator, Callable, Coroutine from app.models.ConfigBase import ConfigBase, JSONValidator from app.models.config import ( @@ -129,6 +134,37 @@ def _save_game_sign_result_snapshot( logger.warning(f"保存游戏签到结果快照失败: {e}") +def _script_config_write( + *, + script_id_argument: str | None = None, + owner_reentrant: bool = False, +) -> Callable: + """串行化一个公开的脚本或用户配置写操作。""" + + def decorate(method: Callable[..., Coroutine[Any, Any, Any]]) -> Callable: + signature = inspect.signature(method) + + @wraps(method) + async def wrapped(self: "AppConfig", *args: Any, **kwargs: Any) -> Any: + script_id: str | None = None + if script_id_argument is not None: + bound = signature.bind(self, *args, **kwargs) + raw_script_id = bound.arguments[script_id_argument] + script_id = ( + None if raw_script_id is None else str(raw_script_id) + ) + + async with self._script_config_write_scope( + script_id, + owner_reentrant=owner_reentrant, + ): + return await method(self, *args, **kwargs) + + return wrapped + + return decorate + + class AppConfig(GlobalConfig): VERSION = "v5.4.0-beta.1" @@ -171,9 +207,222 @@ def __init__(self) -> None: self.temp_task: List[asyncio.Task] = [] self._stage_refreshing = False self._game_sign_result_date = "" + self._script_config_write_lock = asyncio.Lock() + self._script_config_transaction: ContextVar[ + tuple[str, str, asyncio.Task[Any]] | None + ] = ContextVar( + f"script_config_transaction_{id(self)}", + default=None, + ) + self._script_config_write_task: ContextVar[ + asyncio.Task[Any] | None + ] = ContextVar( + f"script_config_write_task_{id(self)}", + default=None, + ) + self._script_execution_reservations: dict[str, str] = {} self._inject_truststore() + @asynccontextmanager + async def script_config_transaction( + self, + script_id: str, + *, + owner: str, + ) -> AsyncIterator[str]: + """为单个脚本持有 owner-aware 配置维护事务。 + + 事务会先等待已在途的公开配置写完成,再阻止新的公开配置写,直到上下文 + 退出。持有事务的任务可以重入同一脚本的 ``update_script`` 和 + ``update_user``,不会产生自锁。 + + Args: + script_id: 要维护的脚本及其全部用户配置。 + owner: 用于诊断和重入校验的非空操作标识。 + + Yields: + 规范化后的 owner 标识。 + + Raises: + KeyError: 取得写锁后目标脚本已不存在。 + RuntimeError: 嵌套事务切换了 owner、脚本或 asyncio task,或尝试 + 从普通写作用域升级为维护事务。 + ValueError: owner 为空。 + """ + + normalized_script_id = str(uuid.UUID(script_id)) + normalized_owner = str(owner).strip() + if not normalized_owner: + raise ValueError("脚本配置维护事务 owner 不能为空") + + task = asyncio.current_task() + if task is None: + raise RuntimeError("脚本配置维护事务必须在 asyncio task 中运行") + + active = self._script_config_transaction.get() + if active is not None: + active_script_id, active_owner, active_task = active + if ( + active_script_id == normalized_script_id + and active_owner == normalized_owner + and active_task is task + ): + yield normalized_owner + return + raise RuntimeError( + "脚本配置维护事务不可跨 owner、script_id 或 asyncio task 重入: " + f"active_owner={active_owner}, active_script_id={active_script_id}" + ) + + active_write_task = self._script_config_write_task.get() + if active_write_task is not None: + raise RuntimeError("普通脚本配置写作用域中不能启动配置维护事务") + + async with self._script_config_write_lock: + script_uid = uuid.UUID(normalized_script_id) + if script_uid not in self.ScriptConfig: + raise KeyError(f"脚本 {normalized_script_id} 不存在") + reservation_owner = self._script_execution_reservations.get( + normalized_script_id + ) + if reservation_owner is not None: + raise RuntimeError( + "脚本已进入任务启动或运行阶段,无法开始配置维护事务: " + f"script_id={normalized_script_id}, owner={reservation_owner}" + ) + script_config = self.ScriptConfig[script_uid] + script_config.begin_maintenance(task) + + token = self._script_config_transaction.set( + (normalized_script_id, normalized_owner, task) + ) + try: + yield normalized_owner + finally: + try: + script_config.end_maintenance(task) + finally: + self._script_config_transaction.reset(token) + + @asynccontextmanager + async def _script_config_write_scope( + self, + script_id: str | None, + *, + owner_reentrant: bool, + ) -> AsyncIterator[None]: + """门控一个普通脚本或用户配置写操作。""" + + normalized_script_id = ( + str(uuid.UUID(script_id)) if script_id is not None else None + ) + active = self._script_config_transaction.get() + if active is not None: + active_script_id, active_owner, active_task = active + if ( + owner_reentrant + and normalized_script_id == active_script_id + and asyncio.current_task() is active_task + ): + yield + return + raise RuntimeError( + "脚本配置维护事务期间不允许执行当前写操作: " + f"owner={active_owner}, script_id={active_script_id}" + ) + + task = asyncio.current_task() + if task is None: + raise RuntimeError("脚本配置写操作必须在 asyncio task 中运行") + active_write_task = self._script_config_write_task.get() + if active_write_task is task: + yield + return + if active_write_task is not None: + raise RuntimeError("普通脚本配置写作用域不可跨 asyncio task 继承") + + async with self._script_config_write_lock: + token = self._script_config_write_task.set(task) + try: + yield + finally: + self._script_config_write_task.reset(token) + + @asynccontextmanager + async def script_config_write_scope( + self, + script_id: str | None, + ) -> AsyncIterator[None]: + """串行执行插件存储写入,并允许同一 task 内重入。""" + + async with self._script_config_write_scope( + script_id, + owner_reentrant=True, + ): + yield + + async def try_reserve_script_execution( + self, + script_id: str, + *, + owner: str, + ) -> bool: + """Atomically reserve a script before resolving its execution provider.""" + + normalized_script_id = str(uuid.UUID(script_id)) + normalized_owner = str(owner).strip() + if not normalized_owner: + raise ValueError("脚本运行 reservation owner 不能为空") + async with self._script_config_write_lock: + script_uid = uuid.UUID(normalized_script_id) + if script_uid not in self.ScriptConfig: + raise KeyError(f"脚本 {normalized_script_id} 不存在") + if self.ScriptConfig[script_uid].is_locked: + return False + if normalized_script_id in self._script_execution_reservations: + return False + self._script_execution_reservations[normalized_script_id] = normalized_owner + return True + + async def release_script_execution( + self, + script_id: str, + *, + owner: str, + ) -> None: + """Release a task execution reservation idempotently.""" + + normalized_script_id = str(uuid.UUID(script_id)) + normalized_owner = str(owner).strip() + async with self._script_config_write_lock: + current_owner = self._script_execution_reservations.get( + normalized_script_id + ) + if current_owner is None: + return + if current_owner != normalized_owner: + raise RuntimeError( + "脚本运行 reservation owner 不匹配: " + f"script_id={normalized_script_id}, " + f"expected={current_owner}, actual={normalized_owner}" + ) + self._script_execution_reservations.pop(normalized_script_id, None) + + @_script_config_write(script_id_argument="script_id") + async def lock_script_config(self, script_id: str) -> ConfigBase: + """在宿主写门内锁定一个运行中的脚本配置。""" + + script_config = self.ScriptConfig[uuid.UUID(script_id)] + await script_config.lock() + return script_config + + @_script_config_write(script_id_argument="script_id") + async def unlock_script_config(self, script_id: str) -> None: + """在宿主写门内解锁一个运行结束的脚本配置。""" + + await self.ScriptConfig[uuid.UUID(script_id)].unlock() + @staticmethod def _inject_truststore() -> None: """等效 truststore.inject_into_ssl(),但避免其内部导入 requests (约 460ms)。 @@ -1053,6 +1302,7 @@ async def _migrate_okww_scripts_to_plugin_storage(self) -> None: shutil.rmtree(default_config_dir, ignore_errors=True) logger.success("旧 Okww 脚本已迁移到插件脚本容器") + @_script_config_write() async def add_script( self, script: str, @@ -1064,6 +1314,7 @@ async def add_script( provider = script_type_registry.get(script) self._require_provider_available(provider, "新增脚本配置") + self._require_provider_creatable(provider) if not provider.is_builtin: from app.models.plugin_script_config import PluginScriptConfig @@ -1149,6 +1400,10 @@ async def get_script(self, script_id: str | None) -> tuple[list, dict]: index = data.pop("instances", []) return list(index), data + @_script_config_write( + script_id_argument="script_id", + owner_reentrant=True, + ) async def update_script( self, script_id: str, data: Dict[str, Any] ) -> None: @@ -1217,6 +1472,7 @@ async def update_script( for name, value in items.items(): await self.ScriptConfig[uid].set(group, name, value) + @_script_config_write(script_id_argument="script_id") async def del_script(self, script_id: str) -> None: """删除脚本配置""" @@ -1224,6 +1480,12 @@ async def del_script(self, script_id: str) -> None: uid = uuid.UUID(script_id) + reservation_owner = self._script_execution_reservations.get(str(uid)) + if reservation_owner is not None: + raise RuntimeError( + "脚本正在启动或运行,无法删除: " + f"script_id={uid}, owner={reservation_owner}" + ) if self.ScriptConfig[uid].is_locked: raise RuntimeError(f"脚本 {script_id} 正在运行, 无法删除") @@ -1237,6 +1499,7 @@ async def del_script(self, script_id: str) -> None: if (Path.cwd() / f"data/{uid}").exists(): shutil.rmtree(Path.cwd() / f"data/{uid}") + @_script_config_write() async def reorder_script(self, index_list: list[str]) -> None: """重新排序脚本""" @@ -1244,6 +1507,7 @@ async def reorder_script(self, index_list: list[str]) -> None: await self.ScriptConfig.setOrder([uuid.UUID(_) for _ in index_list]) + @_script_config_write(script_id_argument="script_id") async def import_script_from_file(self, script_id: str, jsonFile: str) -> None: """从文件加载脚本配置""" @@ -1290,6 +1554,7 @@ async def export_script_to_file(self, script_id: str, jsonFile: str): logger.success(f"{script_id} 配置导出成功") + @_script_config_write(script_id_argument="script_id") async def import_script_from_web(self, script_id: str, url: str): """从「AUTO-MAS 配置分享中心」导入配置""" @@ -1424,6 +1689,10 @@ async def get_user( index = data.pop("instances", []) return list(index), data + @_script_config_write( + script_id_argument="script_id", + owner_reentrant=True, + ) async def add_user(self, script_id: str) -> tuple[uuid.UUID, ConfigBase]: """添加用户配置。""" @@ -1457,6 +1726,10 @@ async def add_user(self, script_id: str) -> tuple[uuid.UUID, ConfigBase]: return await script_config.UserData.add(provider.user_config_class) + @_script_config_write( + script_id_argument="script_id", + owner_reentrant=True, + ) async def update_user( self, script_id: str, user_id: str, data: Dict[str, Any] ) -> None: @@ -1521,6 +1794,7 @@ async def update_user( .set(group, name, value) ) + @_script_config_write(script_id_argument="script_id") async def import_script_config_file( self, script_id: str, user_id: Optional[str] ) -> None: @@ -1544,6 +1818,10 @@ async def import_script_config_file( target_config_dir.mkdir(parents=True, exist_ok=True) shutil.copytree(source_config_dir, target_config_dir, dirs_exist_ok=True) + @_script_config_write( + script_id_argument="script_id", + owner_reentrant=True, + ) async def del_user(self, script_id: str, user_id: str) -> None: """删除用户配置""" @@ -1556,6 +1834,7 @@ async def del_user(self, script_id: str, user_id: str) -> None: if (Path.cwd() / f"data/{script_id}/{user_id}").exists(): shutil.rmtree(Path.cwd() / f"data/{script_id}/{user_id}") + @_script_config_write(script_id_argument="script_id") async def reorder_user(self, script_id: str, index_list: list[str]) -> None: """重新排序用户""" @@ -1581,6 +1860,20 @@ def _provider_is_available(provider: Any) -> bool: return provider.metadata.get("available", True) is not False + @staticmethod + def _provider_is_creatable(provider: Any) -> bool: + """判断脚本类型 provider 是否允许用户创建。""" + + return provider.metadata.get("creatable", True) is not False + + @classmethod + def _require_provider_creatable(cls, provider: Any) -> None: + """阻止用户创建仅用于识别或迁移的脚本类型。""" + + if cls._provider_is_creatable(provider): + return + raise RuntimeError(f"脚本类型 {provider.type_key} 不允许直接创建") + @classmethod def _require_provider_available(cls, provider: Any, action: str) -> None: """阻止对未启用脚本类型执行写操作。""" @@ -1893,8 +2186,12 @@ async def get_script_records(self, script_id: str | None = None) -> list[ScriptR if script_id is None: script_pairs = [(uid, config) for uid, config in self.ScriptConfig.items()] else: - uid = uuid.UUID(script_id) - script_pairs = [(uid, self.ScriptConfig[uid])] + try: + uid = uuid.UUID(script_id) + config = self.ScriptConfig[uid] + except (ValueError, KeyError): + return [] + script_pairs = [(uid, config)] records: list[ScriptRecord] = [] for uid, config in script_pairs: @@ -2018,6 +2315,7 @@ async def get_user_records( return records + @_script_config_write(script_id_argument="script_id") async def set_infrastructure( self, script_id: str, user_id: str, jsonFile: str ) -> None: @@ -2168,6 +2466,7 @@ async def update_plan(self, plan_id: str, data: Dict[str, Dict[str, Any]]) -> No for name, value in items.items(): await self.PlanConfig[plan_uid].set(group, name, value) + @_script_config_write() async def del_plan(self, plan_id: str) -> None: """删除计划表配置""" @@ -2231,6 +2530,7 @@ async def update_emulator( for name, value in items.items(): await self.EmulatorConfig[emulator_uid].set(group, name, value) + @_script_config_write() async def del_emulator(self, emulator_id: str) -> None: """删除模拟器配置""" @@ -2754,6 +3054,7 @@ async def get_webhook( index = data.pop("instances", []) return list(index), data + @_script_config_write(script_id_argument="script_id") async def add_webhook( self, script_id: Optional[str], user_id: Optional[str] ) -> tuple[uuid.UUID, Webhook]: @@ -2778,6 +3079,7 @@ async def add_webhook( ) return uid, config + @_script_config_write(script_id_argument="script_id") async def update_webhook( self, script_id: Optional[str], @@ -2813,6 +3115,7 @@ async def update_webhook( .set(group, name, value) ) + @_script_config_write(script_id_argument="script_id") async def del_webhook( self, script_id: Optional[str], user_id: Optional[str], webhook_id: str ) -> None: @@ -2837,6 +3140,7 @@ async def del_webhook( .Notify_CustomWebhooks.remove(webhook_uid) ) + @_script_config_write(script_id_argument="script_id") async def reorder_webhook( self, script_id: Optional[str], user_id: Optional[str], index_list: list[str] ) -> None: diff --git a/app/core/script_types.py b/app/core/script_types.py index b3d898d9d..dbcd5175a 100644 --- a/app/core/script_types.py +++ b/app/core/script_types.py @@ -67,16 +67,6 @@ "editor_kind": "builtin:maaend", "is_builtin": True, }, - { - "type_key": "M9A", - "display_name": "M9A脚本", - "script_class_name": "M9AConfig", - "user_class_name": "M9AUserConfig", - "supported_modes": ("AutoProxy", "ScriptConfig"), - "icon": "M9A", - "editor_kind": "builtin:m9a", - "is_builtin": False, - }, { "type_key": "MaaFW", "display_name": "MaaFramework 项目", @@ -521,6 +511,7 @@ def build_descriptor(provider: ScriptTypeProvider) -> dict[str, Any]: ), "create_group_declared": provider.metadata.get("create_group") in {"general", "specialized"}, + "creatable": provider.metadata.get("creatable", True) is not False, "docs_url": provider.docs_url, "editor_kind": provider.editor_kind, "supported_modes": list(provider.supported_modes), @@ -801,7 +792,6 @@ def _bind_builtin_script_config_models(global_config: Any) -> None: from app.models.config import ( GeneralConfig as LegacyGeneralConfig, GeneralUserConfig as LegacyGeneralUserConfig, - M9AConfig, MaaConfig, MaaEndConfig, MaaEndUserConfig, @@ -816,7 +806,6 @@ def _bind_builtin_script_config_models(global_config: Any) -> None: MaaConfig, MaaEndConfig, SrcConfig, - M9AConfig, MaaFWConfig, OkwwConfig, ) @@ -828,7 +817,6 @@ def _bind_builtin_script_config_models(global_config: Any) -> None: MaaConfig.related_config["EmulatorConfig"] = global_config.EmulatorConfig MaaEndConfig.related_config["EmulatorConfig"] = global_config.EmulatorConfig SrcConfig.related_config["EmulatorConfig"] = global_config.EmulatorConfig - M9AConfig.related_config["EmulatorConfig"] = global_config.EmulatorConfig MaaFWConfig.related_config["EmulatorConfig"] = global_config.EmulatorConfig OkwwConfig.related_config["EmulatorConfig"] = global_config.EmulatorConfig MaaUserConfig.related_config["PlanConfig"] = global_config.PlanConfig @@ -857,8 +845,6 @@ def _resolve_legacy_config_classes( from app.models.config import ( GeneralConfig, GeneralUserConfig, - M9AConfig, - M9AUserConfig, MaaConfig, MaaEndConfig, MaaEndUserConfig, @@ -871,7 +857,6 @@ def _resolve_legacy_config_classes( script_classes: dict[str, type[ConfigBase]] = { "GeneralConfig": GeneralConfig, - "M9AConfig": M9AConfig, "MaaConfig": MaaConfig, "MaaEndConfig": MaaEndConfig, "MaaFWConfig": MaaFWConfig, @@ -879,7 +864,6 @@ def _resolve_legacy_config_classes( } user_classes: dict[str, type[ConfigBase]] = { "GeneralUserConfig": GeneralUserConfig, - "M9AUserConfig": M9AUserConfig, "MaaEndUserConfig": MaaEndUserConfig, "MaaFWUserConfig": MaaFWUserConfig, "MaaUserConfig": MaaUserConfig, diff --git a/app/core/task_manager.py b/app/core/task_manager.py index c51290423..9f6378931 100644 --- a/app/core/task_manager.py +++ b/app/core/task_manager.py @@ -202,6 +202,7 @@ def __init__( self.is_closing = False self._exit_result = "success" self._exit_error: str | None = None + self._script_execution_reservations: dict[str, str] = {} def _resolve_script_provider(self, script_uid: uuid.UUID): """解析脚本对应的 provider,兼容插件脚本。""" @@ -213,6 +214,21 @@ def _record_error(self, error: str) -> None: self._exit_result = "error" self._exit_error = error + async def _release_script_execution_reservation( + self, + script_id: str, + ) -> None: + owner = self._script_execution_reservations.get(script_id) + if owner is None: + return + await Config.release_script_execution(script_id, owner=owner) + if self._script_execution_reservations.get(script_id) == owner: + self._script_execution_reservations.pop(script_id, None) + + async def _release_all_script_execution_reservations(self) -> None: + for script_id in list(self._script_execution_reservations): + await self._release_script_execution_reservation(script_id) + def cancel(self) -> bool: """记录显式取消结果,覆盖尚未进入脚本执行阶段的任务。""" cancelled = super().cancel() @@ -369,6 +385,47 @@ async def main_task(self): ) continue + reservation_owner = ( + f"task:{self.task_info.task_id}:script:{current_script_uid}" + ) + try: + reserved = await Config.try_reserve_script_execution( + str(current_script_uid), + owner=reservation_owner, + ) + except KeyError: + script_item.status = "异常" + self._record_error(f"脚本 {current_script_uid} 已被删除") + logger.info( + f"跳过任务: {current_script_uid}, 对应脚本在启动前已被删除" + ) + await Publisher.send( + id=self.task_info.task_id, + type=protocol.TASK_NOTICE, + data=WSTaskNoticeData( + level="error", + message=f"任务 {script_item.name} 对应脚本已被删除", + ), + ) + continue + if not reserved: + script_item.status = "跳过" + logger.info( + f"跳过任务: {current_script_uid}, 脚本已被其他任务锁定或预留" + ) + await Publisher.send( + id=self.task_info.task_id, + type=protocol.TASK_NOTICE, + data=WSTaskNoticeData( + level="warning", + message=f"任务 {script_item.name} 已被其他任务调度器锁定", + ), + ) + continue + self._script_execution_reservations[str(current_script_uid)] = ( + reservation_owner + ) + try: provider = self._resolve_script_provider(current_script_uid) except KeyError: @@ -384,6 +441,9 @@ async def main_task(self): type=protocol.TASK_NOTICE, data=WSTaskNoticeData(level="error", message="脚本类型不支持"), ) + await self._release_script_execution_reservation( + str(current_script_uid) + ) continue capability = await Config.get_script_record_capability(current_script_uid) @@ -397,6 +457,9 @@ async def main_task(self): type=protocol.TASK_NOTICE, data=WSTaskNoticeData(level="error", message=reason), ) + await self._release_script_execution_reservation( + str(current_script_uid) + ) continue if self.task_info.mode not in (capability.supported_modes or ()): @@ -415,18 +478,8 @@ async def main_task(self): message=f"脚本类型 {provider.type_key} 不支持任务模式 {self.task_info.mode}", ), ) - continue - - if Config.ScriptConfig[current_script_uid].is_locked: - script_item.status = "跳过" - logger.info(f"跳过任务: {current_script_uid}, 脚本已被其他任务锁定") - await Publisher.send( - id=self.task_info.task_id, - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData( - level="warning", - message=f"任务 {script_item.name} 已被其他任务调度器锁定", - ), + await self._release_script_execution_reservation( + str(current_script_uid) ) continue @@ -543,9 +596,14 @@ async def main_task(self): result=result_event, data=script_event_data, ) + finally: + await self._release_script_execution_reservation( + str(current_script_uid) + ) async def final_task(self) -> None: + await self._release_all_script_execution_reservations() logger.info(f"任务结束: {self.task_info.task_id}") await Publisher.send( @@ -581,6 +639,7 @@ async def final_task(self) -> None: async def on_crash(self, e: Exception) -> None: """处理任务异常并记录退出状态。""" + await self._release_all_script_execution_reservations() if self._exit_result == "success": self._exit_result = "error" self._exit_error = f"{type(e).__name__}: {e}" diff --git a/app/models/ConfigBase.py b/app/models/ConfigBase.py index 707defe41..10214e982 100644 --- a/app/models/ConfigBase.py +++ b/app/models/ConfigBase.py @@ -50,6 +50,13 @@ logger = get_logger("配置基类") +def _current_task() -> asyncio.Task[Any] | None: + try: + return asyncio.current_task() + except RuntimeError: + return None + + class ValidatorBase(ABC): """基础配置验证器""" @@ -724,6 +731,7 @@ def __init__( else None ) self.is_locked = False + self._maintenance_owner: asyncio.Task[Any] | None = None self._slots: list[Callable[[Any], Any]] = [] if not self.validator.validate(self.value): @@ -753,7 +761,7 @@ def setValue(self, value: Any) -> bool: ) == value: return False - if self.is_locked: + if self._write_locked(): raise ValueError(f"配置项 '{self.group}.{self.name}' 已锁定, 无法修改") old_value = self.value @@ -852,6 +860,10 @@ def lock(self): """ 锁定配置项, 锁定后无法修改配置项值 """ + if self._maintenance_owner is not None: + raise ValueError( + f"配置项 '{self.group}.{self.name}' 正在维护, 无法锁定" + ) self.is_locked = True def unlock(self): @@ -860,6 +872,32 @@ def unlock(self): """ self.is_locked = False + def _write_locked(self) -> bool: + owner = self._maintenance_owner + return self.is_locked or ( + owner is not None and owner is not _current_task() + ) + + def _assert_maintenance_available(self, owner: asyncio.Task[Any]) -> None: + if self.is_locked: + raise ValueError( + f"配置项 '{self.group}.{self.name}' 已锁定, 无法维护" + ) + if self._maintenance_owner not in (None, owner): + raise ValueError( + f"配置项 '{self.group}.{self.name}' 已由其他任务维护" + ) + + def _begin_maintenance(self, owner: asyncio.Task[Any]) -> None: + self._maintenance_owner = owner + + def _end_maintenance(self, owner: asyncio.Task[Any]) -> None: + if self._maintenance_owner is not owner: + raise RuntimeError( + f"配置项 '{self.group}.{self.name}' 的维护 owner 不匹配" + ) + self._maintenance_owner = None + class ConfigBase(ABC): """ @@ -877,6 +915,7 @@ class ConfigBase(ABC): def __init__(self): self.file: Path | None = None self.is_locked = False + self._maintenance_owner: asyncio.Task[Any] | None = None self._save_methods: list[Callable[[], Coroutine[Any, Any, None]]] = [] # 配置项索引 @@ -906,7 +945,7 @@ async def connect(self, path: Path): if path.suffix != ".json": raise ValueError("配置文件必须是扩展名为 '.json' 的 JSON 文件") - if self.is_locked: + if self._write_locked(): raise ValueError("配置已锁定, 无法修改") self.file = path @@ -976,7 +1015,9 @@ async def load(self, data: dict) -> bool: 是否因数据规范化/纠错而产生了写入(dirty) """ - if self.is_locked: + if self._maintenance_owner is not None: + raise ValueError("配置维护事务期间不支持整体加载") + if self._write_locked(): raise ValueError("配置已锁定, 无法修改") source_data = deepcopy(data) if isinstance(data, dict) else {} @@ -1057,7 +1098,7 @@ async def set(self, group: str, name: str, value: Any): if not self._config_item_index.get(group, {}).get(name): raise AttributeError(f"配置项 '{group}.{name}' 不存在") - if self.is_locked: + if self._write_locked(): raise ValueError("配置已锁定, 无法修改") is_changed = self._config_item_index[group][name].setValue(value) @@ -1083,7 +1124,7 @@ def bind(self, group: str, name: str, slot: Callable[[Any], Any]): if not self._config_item_index.get(group, {}).get(name): raise AttributeError(f"配置项 '{group}.{name}' 不存在") - if self.is_locked: + if self._write_locked(): raise ValueError("配置已锁定, 无法修改") self._config_item_index[group][name].bind(slot) @@ -1105,7 +1146,7 @@ def unbind(self, group: str, name: str, slot: Callable[[Any], Any]): if not self._config_item_index.get(group, {}).get(name): raise AttributeError(f"配置项 '{group}.{name}' 不存在") - if self.is_locked: + if self._write_locked(): raise ValueError("配置已锁定, 无法修改") self._config_item_index[group][name].unbind(slot) @@ -1128,6 +1169,8 @@ async def lock(self): """ 锁定配置项, 锁定后无法修改配置项值 """ + if self._maintenance_owner is not None: + raise ValueError("配置正在维护, 无法锁定") self.is_locked = True @@ -1150,6 +1193,46 @@ async def unlock(self): for config in self._multiple_config_index.values(): await config.unlock() + def _write_locked(self) -> bool: + owner = self._maintenance_owner + return self.is_locked or ( + owner is not None and owner is not _current_task() + ) + + def _assert_maintenance_available(self, owner: asyncio.Task[Any]) -> None: + if self.is_locked: + raise ValueError("配置已锁定, 无法进入维护事务") + if self._maintenance_owner not in (None, owner): + raise ValueError("配置已由其他任务维护") + for group in self._config_item_index.values(): + for item in group.values(): + item._assert_maintenance_available(owner) + for config in self._multiple_config_index.values(): + config._assert_maintenance_available(owner) + + def begin_maintenance(self, owner: asyncio.Task[Any]) -> None: + """允许 owner 修改配置,并拒绝其他任务及运行任务并发写入。""" + + self._assert_maintenance_available(owner) + self._maintenance_owner = owner + for group in self._config_item_index.values(): + for item in group.values(): + item._begin_maintenance(owner) + for config in self._multiple_config_index.values(): + config._begin_maintenance(owner) + + def end_maintenance(self, owner: asyncio.Task[Any]) -> None: + """结束由 owner 持有的配置维护状态。""" + + if self._maintenance_owner is not owner: + raise RuntimeError("配置维护 owner 不匹配") + for group in self._config_item_index.values(): + for item in group.values(): + item._end_maintenance(owner) + for config in self._multiple_config_index.values(): + config._end_maintenance(owner) + self._maintenance_owner = None + T = TypeVar("T", bound="ConfigBase") @@ -1184,6 +1267,7 @@ def __init__(self, sub_config_type: list[Type[T]]): self.order: list[uuid.UUID] = [] self.data: dict[uuid.UUID, T] = {} self.is_locked = False + self._maintenance_owner: asyncio.Task[Any] | None = None self._save_methods: list[Callable[[], Coroutine[Any, Any, None]]] = [] def __getitem__(self, key: uuid.UUID) -> T: @@ -1221,7 +1305,7 @@ async def connect(self, path: Path): if path.suffix != ".json": raise ValueError("配置文件必须是带有 '.json' 扩展名的 JSON 文件。") - if self.is_locked: + if self._write_locked(): raise ValueError("配置已锁定, 无法修改") self.file = path @@ -1292,7 +1376,9 @@ async def load(self, data: dict) -> bool: 是否因数据规范化/纠错而产生了写入(dirty) """ - if self.is_locked: + if self._maintenance_owner is not None: + raise ValueError("配置维护事务期间不支持重建配置集合") + if self._write_locked(): raise ValueError("配置已锁定, 无法修改") source_data = deepcopy(data) if isinstance(data, dict) else {} @@ -1424,12 +1510,15 @@ async def add(self, config_type: Type[T]) -> tuple[uuid.UUID, T]: if config_type not in self.sub_config_type.values(): raise ValueError(f"配置类型 {config_type.__name__} 不被允许") - if self.is_locked: + if self._write_locked(): raise ValueError("配置已锁定, 无法修改") uid = uuid.uuid4() + new_item = config_type() + if self._maintenance_owner is not None: + new_item.begin_maintenance(self._maintenance_owner) self.order.append(uid) - self.data[uid] = config_type() + self.data[uid] = new_item for save_method in self._save_methods: await self.data[uid].add_save_method(save_method) @@ -1451,15 +1540,18 @@ async def remove(self, uid: uuid.UUID): 要移除的配置项的唯一标识符 """ - if self.is_locked: + if self._write_locked(): raise ValueError("配置已锁定, 无法修改") if uid not in self.data: raise ValueError(f"配置项 '{uid}' 不存在") - if self.data[uid].is_locked: + if self.data[uid]._write_locked(): raise ValueError(f"配置项 '{uid}' 已锁定, 无法移除") + removed = self.data[uid] + if self._maintenance_owner is not None: + removed.end_maintenance(self._maintenance_owner) self.data.pop(uid) self.order.remove(uid) @@ -1478,7 +1570,7 @@ async def setOrder(self, order: list[uuid.UUID]): if set(order) != set(self.data.keys()): raise ValueError("顺序与当前配置项不匹配") - if self.is_locked: + if self._write_locked(): raise ValueError("配置已锁定, 无法修改") self.order = order @@ -1489,6 +1581,8 @@ async def lock(self): """ 锁定配置项, 锁定后无法修改配置项值 """ + if self._maintenance_owner is not None: + raise ValueError("配置正在维护, 无法锁定") self.is_locked = True @@ -1505,6 +1599,32 @@ async def unlock(self): for item in self.values(): await item.unlock() + def _write_locked(self) -> bool: + owner = self._maintenance_owner + return self.is_locked or ( + owner is not None and owner is not _current_task() + ) + + def _assert_maintenance_available(self, owner: asyncio.Task[Any]) -> None: + if self.is_locked: + raise ValueError("配置集合已锁定, 无法进入维护事务") + if self._maintenance_owner not in (None, owner): + raise ValueError("配置集合已由其他任务维护") + for item in self.values(): + item._assert_maintenance_available(owner) + + def _begin_maintenance(self, owner: asyncio.Task[Any]) -> None: + self._maintenance_owner = owner + for item in self.values(): + item.begin_maintenance(owner) + + def _end_maintenance(self, owner: asyncio.Task[Any]) -> None: + if self._maintenance_owner is not owner: + raise RuntimeError("配置集合维护 owner 不匹配") + for item in self.values(): + item.end_maintenance(owner) + self._maintenance_owner = None + def keys(self): """返回配置项的所有唯一标识符""" diff --git a/app/models/config.py b/app/models/config.py index 78c10a6f1..0ea48f2b5 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -1470,221 +1470,6 @@ def __init__(self) -> None: super().__init__() -class M9AUserConfig(ConfigBase): - """M9A用户配置""" - - related_config: dict[str, MultipleConfig] = {} - - def __init__(self) -> None: - - ## Info ------------------------------------------------------------ - ## 用户名称 - self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator()) - ## 是否启用 - self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator()) - ## 剩余天数 - self.Info_RemainedDay = ConfigItem( - "Info", "RemainedDay", -1, RangeValidator(-1, 9999) - ) - ## 任务前执行脚本 - self.Info_IfScriptBeforeTask = ConfigItem( - "Info", "IfScriptBeforeTask", False, BoolValidator() - ) - self.Info_ScriptBeforeTask = ConfigItem( - "Info", "ScriptBeforeTask", "", FileValidator() - ) - ## 任务后执行脚本 - self.Info_IfScriptAfterTask = ConfigItem( - "Info", "IfScriptAfterTask", False, BoolValidator() - ) - self.Info_ScriptAfterTask = ConfigItem( - "Info", "ScriptAfterTask", "", FileValidator() - ) - ## 备注 - self.Info_Notes = ConfigItem("Info", "Notes", "无") - ## 用户标签信息 - self.Info_Tag = ConfigItem( - "Info", "Tag", "[ ]", VirtualConfigValidator(self.getTags) - ) - ## 服务器资源 - self.Info_Resource = ConfigItem("Info", "Resource", "官服") - ## 账号信息(用于切换账号) - self.Info_Account = ConfigItem("Info", "Account", "") - - ## Task ------------------------------------------------------------- - ## 可用任务列表(从 M9A 配置文件读取) - self.Task_AvailableTasks = ConfigItem( - "Task", "AvailableTasks", "[]", JSONValidator(list) - ) - ## 运行任务队列 (用户在可用任务列表中选择) - self.Task_Queue = ConfigItem( - "Task", "Queue", "[]", JSONValidator(list) - ) - - - ## Data ------------------------------------------------------------ - ## 上次代理日期 - self.Data_LastProxyDate = ConfigItem( - "Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d") - ) - ## 上次完成每日心相日期 - self.Data_LastPsychubeDate = ConfigItem( - "Data", "LastPsychubeDate", "2000-01-01", DateTimeValidator("%Y-%m-%d") - ) - ## 上次完成自动深眠月份 - self.Data_LastLimboMonth = ConfigItem( - "Data", "LastLimboMonth", "2000-01", DateTimeValidator("%Y-%m") - ) - ## 上次完成自动醒梦月份 - self.Data_LastLucidscapeMonth = ConfigItem( - "Data", "LastLucidscapeMonth", "2000-01", DateTimeValidator("%Y-%m") - ) - ## 代理次数 - self.Data_ProxyTimes = ConfigItem( - "Data", "ProxyTimes", 0, RangeValidator(0, 9999) - ) - ## 是否通过检查 - self.Data_IfPassCheck = ConfigItem("Data", "IfPassCheck", True, BoolValidator()) - - ## Notify ---------------------------------------------------------- - ## 是否启用通知 - self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator()) - ## 是否发送统计信息 - self.Notify_IfSendStatistic = ConfigItem( - "Notify", "IfSendStatistic", False, BoolValidator() - ) - ## 是否发送邮件 - self.Notify_IfSendMail = ConfigItem( - "Notify", "IfSendMail", False, BoolValidator() - ) - ## 收件地址 - self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "") - ## 是否启用 Server 酱 - self.Notify_IfServerChan = ConfigItem( - "Notify", "IfServerChan", False, BoolValidator() - ) - ## Server 酱密钥 - self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "") - ## 自定义 Webhook 列表 - self.Notify_CustomWebhooks = MultipleConfig([Webhook]) - - super().__init__() - - def getTags(self) -> str: - """生成用户标签列表,返回JSON字符串格式的TagItem列表""" - tags = [] - - # 人工排查状态标签 - if not self.get("Data", "IfPassCheck"): - tags.append({"text": "人工排查未通过", "color": "red"}) - - # 日常代理标签(使用东4区时间) - if ( - datetime.strptime(self.get("Data", "LastProxyDate"), "%Y-%m-%d").date() - == datetime.now(tz=UTC4).date() - ): - tags.append( - { - "text": f"日常:已代理{self.get('Data', 'ProxyTimes')}次", - "color": "green", - } - ) - else: - tags.append({"text": "日常:未代理", "color": "orange"}) - - # 剩余天数标签 - remained_day = self.get("Info", "RemainedDay") - if remained_day == -1: - tag_color = "gold" - elif remained_day == 0: - tag_color = "red" - elif remained_day <= 3: - tag_color = "orange" - elif remained_day <= 7: - tag_color = "yellow" - elif remained_day <= 30: - tag_color = "blue" - else: - tag_color = "green" - tags.append( - { - "text": ( - f"剩余天数:{remained_day}天" - if remained_day >= 0 - else "剩余天数:无期限" - ), - "color": tag_color, - } - ) - # 备注标签 - notes = self.get("Info", "Notes") - tags.append( - { - "text": ( - f"备注:{notes}" if len(notes) <= 20 else f"备注:{notes[:20]}..." - ), - "color": "pink", - } - ) - - return json.dumps(tags, ensure_ascii=False) - - -class M9AConfig(ConfigBase): - """M9A配置""" - - related_config: dict[str, MultipleConfig] = {} - - def __init__(self) -> None: - - ## Info ------------------------------------------------------------ - ## M9A 脚本名称 - self.Info_Name = ConfigItem("Info", "Name", "新 M9A 脚本") - ## M9A 路径 - self.Info_Path = ConfigItem("Info", "Path", "", FolderValidator()) - - ## Emulator -------------------------------------------------------- - ## 模拟器 ID - self.Emulator_Id = ConfigItem( - "Emulator", - "Id", - "-", - MultipleUIDValidator("-", self.related_config, "EmulatorConfig"), - ) - ## 模拟器索引 - self.Emulator_Index = ConfigItem("Emulator", "Index", "-") - - ## Run ------------------------------------------------------------- - ## 代理次数限制 - self.Run_ProxyTimesLimit = ConfigItem( - "Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999) - ) - ## 运行次数限制 - self.Run_RunTimesLimit = ConfigItem( - "Run", "RunTimesLimit", 3, RangeValidator(1, 9999) - ) - ## 运行时间限制(分钟) - self.Run_RunTimeLimit = ConfigItem( - "Run", "RunTimeLimit", 10, RangeValidator(1, 9999) - ) - ## 是否在队列结束后自动更新 - self.Run_IfAutoUpdateAfterQueue = ConfigItem( - "Run", "IfAutoUpdateAfterQueue", False, BoolValidator() - ) - ## 每日心相每日只执行一次 - self.Run_IfPsychubeDailyOnce = ConfigItem( - "Run", "IfPsychubeDailyOnce", False, BoolValidator() - ) - ## 深眠浅梦每月只执行一次 - self.Run_IfSleepDreamMonthlyOnce = ConfigItem( - "Run", "IfSleepDreamMonthlyOnce", False, BoolValidator() - ) - - self.UserData = MultipleConfig([M9AUserConfig]) - - super().__init__() - - class MaaFWUserConfig(ConfigBase): """MaaFW 用户配置""" @@ -3005,7 +2790,6 @@ def __init__(self): MaaConfig, MaaEndConfig, SrcConfig, - M9AConfig, MaaFWConfig, GeneralConfig, OkwwConfig, @@ -3021,7 +2805,6 @@ def __init__(self): MaaConfig.related_config["EmulatorConfig"] = self.EmulatorConfig MaaEndConfig.related_config["EmulatorConfig"] = self.EmulatorConfig SrcConfig.related_config["EmulatorConfig"] = self.EmulatorConfig - M9AConfig.related_config["EmulatorConfig"] = self.EmulatorConfig MaaFWConfig.related_config["EmulatorConfig"] = self.EmulatorConfig GeneralConfig.related_config["EmulatorConfig"] = self.EmulatorConfig OkwwConfig.related_config["EmulatorConfig"] = self.EmulatorConfig @@ -3096,7 +2879,6 @@ def getStage(self) -> str: "MaaPlan": MaaPlanConfig, "SRC": SrcConfig, "MaaEnd": MaaEndConfig, - "M9A": M9AConfig, "MaaFW": MaaFWConfig, "General": GeneralConfig, "Okww": OkwwConfig, diff --git a/app/models/schema.py b/app/models/schema.py index 0eb9d1459..1374f238e 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -404,7 +404,6 @@ class ScriptIndexItem(BaseModel): "GeneralConfig", "SrcConfig", "MaaEndConfig", - "M9AConfig", "MaaFWConfig", "PluginScriptConfig", ] = Field( @@ -419,7 +418,6 @@ class UserIndexItem(BaseModel): "GeneralUserConfig", "SrcUserConfig", "MaaEndUserConfig", - "M9AUserConfig", "MaaFWUserConfig", "PluginUserConfig", ] = Field(..., description="配置类型") @@ -1008,80 +1006,6 @@ class SrcConfig(BaseModel): Run: Optional[SrcConfig_Run] = Field(default=None, description="脚本运行配置") -class M9AUserConfig_Info(BaseModel): - Name: Optional[str] = Field(default=None, description="用户名称") - Status: Optional[bool] = Field(default=None, description="是否启用") - RemainedDay: Optional[int] = Field(default=None, description="剩余天数") - IfScriptBeforeTask: Optional[bool] = Field( - default=None, description="是否在任务前执行脚本" - ) - ScriptBeforeTask: Optional[str] = Field(default=None, description="任务前脚本路径") - IfScriptAfterTask: Optional[bool] = Field( - default=None, description="是否在任务后执行脚本" - ) - ScriptAfterTask: Optional[str] = Field(default=None, description="任务后脚本路径") - Notes: Optional[str] = Field(default=None, description="备注") - Tag: Optional[str] = Field(default=None, description="用户标签信息") - Resource: Optional[str] = Field(default=None, description="服务器资源名称") - Account: Optional[str] = Field(default=None, description="账号信息(用于切换账号,仅官服生效)") - - -class M9AUserConfig_Task(BaseModel): - AvailableTasks: Optional[Union[str, List]] = Field(default=None, description="可用任务列表 JSON 数组字符串或数组") - Queue: Optional[Union[str, List]] = Field(default=None, description="运行任务队列 JSON 数组字符串或数组") - -class M9AUserConfig_Data(BaseModel): - LastProxyDate: Optional[str] = Field(default=None, description="上次代理日期") - LastPsychubeDate: Optional[str] = Field(default=None, description="上次完成每日心相日期,格式 YYYY-MM-DD") - LastLimboMonth: Optional[str] = Field(default=None, description="上次完成自动深眠月份,格式 YYYY-MM") - LastLucidscapeMonth: Optional[str] = Field(default=None, description="上次完成自动醒梦月份,格式 YYYY-MM") - ProxyTimes: Optional[int] = Field(default=None, description="代理次数") - IfPassCheck: Optional[bool] = Field(default=None, description="是否通过检查") - - -class M9AUserConfig_Notify(BaseModel): - Enabled: Optional[bool] = Field(default=None, description="是否启用通知") - IfSendStatistic: Optional[bool] = Field( - default=None, description="是否发送统计信息" - ) - IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件") - ToAddress: Optional[str] = Field(default=None, description="收件地址") - IfServerChan: Optional[bool] = Field(default=None, description="是否启用 Server 酱") - ServerChanKey: Optional[str] = Field(default=None, description="Server 酱密钥") - - -class M9AUserConfig(BaseModel): - Info: Optional[M9AUserConfig_Info] = Field(default=None, description="基础信息") - Task: Optional[M9AUserConfig_Task] = Field(default=None, description="任务配置") - Data: Optional[M9AUserConfig_Data] = Field(default=None, description="用户数据") - Notify: Optional[M9AUserConfig_Notify] = Field(default=None, description="单独通知") - - -class M9AConfig_Info(BaseModel): - Name: Optional[str] = Field(default=None, description="M9A 脚本名称") - Path: Optional[str] = Field(default=None, description="M9A 路径") - - -class M9AConfig_Emulator(BaseModel): - Id: Optional[str] = Field(default=None, description="模拟器 ID") - Index: Optional[str] = Field(default=None, description="模拟器索引") - - -class M9AConfig_Run(BaseModel): - ProxyTimesLimit: Optional[int] = Field(default=None, description="代理次数限制") - RunTimesLimit: Optional[int] = Field(default=None, description="运行次数限制") - RunTimeLimit: Optional[int] = Field(default=None, description="运行时间限制(分钟)") - IfAutoUpdateAfterQueue: Optional[bool] = Field(default=None, description="是否在队列结束后自动更新M9A") - IfPsychubeDailyOnce: Optional[bool] = Field(default=None, description="每日心相每日只执行一次") - IfSleepDreamMonthlyOnce: Optional[bool] = Field(default=None, description="深眠浅梦每月只执行一次") - - -class M9AConfig(BaseModel): - Info: Optional[M9AConfig_Info] = Field(default=None, description="脚本基础信息") - Emulator: Optional[M9AConfig_Emulator] = Field(default=None, description="模拟器配置") - Run: Optional[M9AConfig_Run] = Field(default=None, description="脚本运行配置") - - class MaaFWUserConfig_Info(BaseModel): Name: Optional[str] = Field(default=None, description="用户名称") Status: Optional[bool] = Field(default=None, description="是否启用") @@ -1509,10 +1433,9 @@ class ScriptCreateIn(BaseModel): "General", "Okww", "MaaEnd", - "M9A", "MaaFW", ] = Field( - ..., description="脚本类型: MAA脚本, 通用脚本, OK-WW脚本, SRC脚本, MaaEnd脚本, M9A脚本, MaaFW脚本" + ..., description="脚本类型: MAA脚本, 通用脚本, OK-WW脚本, SRC脚本, MaaEnd脚本, MaaFW脚本" ) scriptId: str | None = Field( default=None, description="直接从该脚本ID复制创建, 仅在复制创建时使用" @@ -1538,7 +1461,6 @@ class ScriptCreateOut(OutBase): SrcConfig, GeneralConfig, MaaEndConfig, - M9AConfig, MaaFWConfig, PluginScriptConfig, ] = Field( @@ -1561,7 +1483,6 @@ class ScriptGetOut(OutBase): SrcConfig, GeneralConfig, MaaEndConfig, - M9AConfig, MaaFWConfig, PluginScriptConfig, ], @@ -1577,7 +1498,6 @@ class ScriptUpdateIn(BaseModel): SrcConfig, GeneralConfig, MaaEndConfig, - M9AConfig, MaaFWConfig, PluginScriptConfig, ] = Field( @@ -1635,7 +1555,6 @@ class UserGetOut(OutBase): SrcUserConfig, GeneralUserConfig, MaaEndUserConfig, - M9AUserConfig, MaaFWUserConfig, PluginUserConfig, ], @@ -1649,7 +1568,6 @@ class UserCreateOut(OutBase): SrcUserConfig, GeneralUserConfig, MaaEndUserConfig, - M9AUserConfig, MaaFWUserConfig, PluginUserConfig, ] = ( @@ -1664,7 +1582,6 @@ class UserUpdateIn(UserInBase): SrcUserConfig, GeneralUserConfig, MaaEndUserConfig, - M9AUserConfig, MaaFWUserConfig, PluginUserConfig, ] = ( diff --git a/app/models/script_api.py b/app/models/script_api.py index fbed871cd..9b02117f4 100644 --- a/app/models/script_api.py +++ b/app/models/script_api.py @@ -21,6 +21,7 @@ class ScriptTypeDescriptor(BaseModel): create_group_declared: bool = Field( default=False, description="脚本类型是否显式声明了创建分组" ) + creatable: bool = Field(default=True, description="是否允许用户创建此脚本类型") docs_url: str | None = Field(default=None, description="文档地址") editor_kind: str = Field(..., description="编辑器类型") supported_modes: list[str] = Field(..., description="支持的任务模式") diff --git a/app/plugins/manager.py b/app/plugins/manager.py index 68d5df662..aa1d1388e 100644 --- a/app/plugins/manager.py +++ b/app/plugins/manager.py @@ -891,6 +891,20 @@ async def _sync_script_types_and_migrate_legacy_configs( self, *, discovered: Dict[str, Any] | None = None, + ) -> None: + """在宿主配置写事务中同步脚本类型并迁移旧配置。""" + + from app.core import Config + + async with Config.script_config_write_scope(None): + await self._sync_script_types_and_migrate_legacy_configs_locked( + discovered=discovered, + ) + + async def _sync_script_types_and_migrate_legacy_configs_locked( + self, + *, + discovered: Dict[str, Any] | None = None, ) -> None: """同步脚本类型映射,并把旧宿主脚本配置迁移到插件当前类。""" @@ -945,6 +959,10 @@ async def _sync_script_types_and_migrate_legacy_configs( script_name = str(script_id) try: + if script.is_locked: + raise RuntimeError( + "脚本正在运行,暂不替换其配置对象" + ) legacy_migrator = provider.metadata.get( "legacy_config_migrator" ) diff --git a/app/plugins/script_adapter.py b/app/plugins/script_adapter.py index 9f22e99f2..19623246c 100644 --- a/app/plugins/script_adapter.py +++ b/app/plugins/script_adapter.py @@ -297,6 +297,7 @@ def storage(self) -> ScriptConfigStore: from .script_config_store import ScriptConfigStore self._storage = ScriptConfigStore( + script_id=str(self.script_uid), provider=self.resolve_provider(), storage_script_config=self.get_storage_script_config(), ) @@ -426,7 +427,9 @@ def get_storage_script_config(self) -> Any: from app.core import Config as RuntimeConfig - if self.storage_script_config is None: + if self._storage is not None: + self.storage_script_config = self._storage.storage_script_config + elif self.storage_script_config is None: self.storage_script_config = RuntimeConfig.ScriptConfig[self.script_uid] return self.storage_script_config diff --git a/app/plugins/script_config_store.py b/app/plugins/script_config_store.py index c92c40db6..46877095a 100644 --- a/app/plugins/script_config_store.py +++ b/app/plugins/script_config_store.py @@ -3,7 +3,9 @@ import copy import json import uuid -from typing import TYPE_CHECKING, Any, Literal, Mapping +from contextlib import asynccontextmanager +from functools import wraps +from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Literal, Mapping from pydantic import BaseModel @@ -22,23 +24,71 @@ ConfigKind = Literal["script", "user"] +def _serialized_write(method: Callable[..., Any]) -> Callable[..., Any]: + """Run one logical store write under the host configuration gate.""" + + @wraps(method) + async def wrapped( + self: "ScriptConfigStore", + *args: Any, + **kwargs: Any, + ) -> Any: + async with self.write_transaction(): + return await method(self, *args, **kwargs) + + return wrapped + + class ScriptConfigStore: """Separate script schema models from host persistence containers.""" def __init__( self, *, + script_id: str | None = None, provider: ScriptTypeProvider, storage_script_config: ConfigBase, ) -> None: + self.script_id = ( + str(uuid.UUID(script_id)) if script_id is not None else None + ) self.provider = provider self.storage_script_config = storage_script_config + @asynccontextmanager + async def write_transaction(self) -> AsyncIterator[None]: + """Serialize a complete storage write batch with host maintenance.""" + + from app.core.config import Config + + async with Config.script_config_write_scope(self.script_id): + if self.script_id is not None: + self.storage_script_config = Config.ScriptConfig[ + uuid.UUID(self.script_id) + ] + yield + async def lock(self) -> None: - await self.storage_script_config.lock() + if self.script_id is None: + raise RuntimeError("脚本配置存储缺少 script_id,无法进入运行锁") + + from app.core.config import Config + + self.storage_script_config = await Config.lock_script_config(self.script_id) async def unlock(self) -> None: - await self.storage_script_config.unlock() + if self.script_id is None: + raise RuntimeError("脚本配置存储缺少 script_id,无法退出运行锁") + + from app.core.config import Config + + await Config.unlock_script_config(self.script_id) + + @property + def is_locked(self) -> bool: + """Return the authoritative host lock state for cancellation cleanup.""" + + return bool(getattr(self.storage_script_config, "is_locked", False)) async def read_script_data(self) -> dict[str, Any]: raw_payload = await self._read_script_storage_payload(if_decrypt=True) @@ -91,9 +141,11 @@ async def load_user_collection(self) -> MultipleConfig[Any]: collection.data[uid] = user_model return collection + @_serialized_write async def save_script_model(self, model: Any) -> None: await self.write_script_data(await self._model_to_form_data(model)) + @_serialized_write async def save_user_model( self, user_uid: uuid.UUID | str, @@ -101,6 +153,7 @@ async def save_user_model( ) -> None: await self.write_user_data(user_uid, await self._model_to_form_data(model)) + @_serialized_write async def save_user_models( self, models: MultipleConfig[Any] | Mapping[uuid.UUID, Any], @@ -116,6 +169,7 @@ async def save_user_models( for user_uid, model in models.items(): await self.save_user_model(user_uid, model) + @_serialized_write async def write_script_data(self, form_payload: Mapping[str, Any]) -> None: payload = copy.deepcopy(dict(form_payload)) if isinstance(self.storage_script_config, PluginScriptConfig): @@ -146,6 +200,7 @@ async def write_script_data(self, form_payload: Mapping[str, Any]) -> None: await self.storage_script_config.load(payload) + @_serialized_write async def write_user_data( self, user_uid: uuid.UUID | str, @@ -180,6 +235,7 @@ async def write_user_data( await storage_user.load(payload) + @_serialized_write async def update_script_data(self, update: Mapping[str, Any]) -> None: if not isinstance(self.storage_script_config, PluginScriptConfig): for group, items in update.items(): @@ -196,6 +252,7 @@ async def update_script_data(self, update: Mapping[str, Any]) -> None: merged = self._deep_merge(current, dict(update)) await self.write_script_data(self._strip_virtual_fields(merged, "script")) + @_serialized_write async def update_user_data( self, user_uid: uuid.UUID | str, diff --git a/app/plugins/uv_backend.py b/app/plugins/uv_backend.py index 33faf587e..5a4ef7aff 100644 --- a/app/plugins/uv_backend.py +++ b/app/plugins/uv_backend.py @@ -23,6 +23,7 @@ ] UV_INSTALL_SCRIPT_URL = "https://astral.sh/uv/install.ps1" +AUTO_MAS_UV_INDEX_URL_ENV = "AUTO_MAS_UV_INDEX_URL" def _embedded_uv_path(app_root: Path | None = None) -> Path: @@ -34,9 +35,16 @@ def _find_uv() -> str | None: """查找 uv 可执行文件路径。 查找顺序: - 1. Electron 安装位置 (environment/python/Scripts/uv.exe) - 2. 系统 PATH + 1. AUTO_MAS_UV_EXE 指定路径 + 2. Electron 安装位置 (environment/python/Scripts/uv.exe) + 3. 系统 PATH """ + configured_uv = os.environ.get("AUTO_MAS_UV_EXE") + if configured_uv: + configured_path = Path(configured_uv) + if configured_path.is_file(): + return str(configured_path) + local_uv = Path.cwd() / "environment" / "python" / "Scripts" / "uv.exe" if local_uv.is_file(): return str(local_uv) @@ -47,6 +55,11 @@ def _set_cached_uv(path: str) -> str: global _uv_path _uv_path = path os.environ["AUTO_MAS_UV_EXE"] = path + if not any( + str(os.environ.get(name) or "").strip() + for name in ("UV_INDEX_URL", "UV_DEFAULT_INDEX", AUTO_MAS_UV_INDEX_URL_ENV) + ): + os.environ[AUTO_MAS_UV_INDEX_URL_ENV] = DEFAULT_INDEX_URLS[0] return path diff --git a/app/task/M9A/AutoProxy.py b/app/task/M9A/AutoProxy.py deleted file mode 100644 index f20a64413..000000000 --- a/app/task/M9A/AutoProxy.py +++ /dev/null @@ -1,1282 +0,0 @@ -# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software -# Copyright © 2024-2025 DLmaster361 -# 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 . - -# Contact: DLmaster_361@163.com - - -import json -import uuid -import asyncio -import re -from pathlib import Path -from datetime import datetime, timedelta - -from app.core import Config -from app.core.ws import Publisher, protocol -from app.models.schema import WSTaskNoticeData -from app.models.task import TaskExecuteBase, ScriptItem, LogRecord -from app.models.ConfigBase import MultipleConfig -from app.models.config import M9AConfig, M9AUserConfig -from app.models.emulator import DeviceInfo, DeviceBase -from app.services import Notify, System -from app.utils import get_logger, LogMonitor, ProcessManager -from app.utils.constants import UTC4,UTC8 -from .tools import push_notification -from app.task.general.tools import execute_script_task -from .tools.notify import M9ALogAnalyzer -from .task_loader import M9ATaskLoader - -logger = get_logger("M9A 自动代理") - -RESERVED_TASK_NAMES = {"启动游戏", "关闭游戏", "切换账号"} -PSYCHUBE_ENTRY = "Psychube" -LIMBO_ENTRY = "Limbo" -LUCIDSCAPE_ENTRY = "Lucidscape" -ENTRY_FALLBACK_NAMES = { - "每日心相(意志解析)": PSYCHUBE_ENTRY, - "每日心相": PSYCHUBE_ENTRY, - "自动深眠": LIMBO_ENTRY, - "自动醒梦": LUCIDSCAPE_ENTRY, -} - - -class AutoProxyTask(TaskExecuteBase): - """自动代理模式""" - - def __init__( - self, - script_info: ScriptItem, - script_config: M9AConfig, - user_config: MultipleConfig[M9AUserConfig], - emulator_manager: DeviceBase, - task_loader: M9ATaskLoader, - ): - 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.m9a_task_loader = task_loader - 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.check_result = "-" - - # 初始化路径 - self.m9a_root_path = Path(self.script_config.get("Info", "Path")) - self.m9a_config_path = self.m9a_root_path / "config" - today_date = datetime.now().strftime("%Y%m%d") - self.m9a_log_path = self.m9a_root_path / f"logs/log-{today_date}.log" - self.m9a_exe_path = self.m9a_root_path / "M9A.exe" - self.m9a_tasks_path = self.m9a_config_path / "instances/default.json" - - self.template_path = self.m9a_root_path / "config/instances/default.json" - - self.is_first_user_for_version_check = False - self.is_virtual_update_user = False - self.run_complete = False - self.skip_proxy_count = False - self.completed_task_entries: set[str] = set() - self.emulator_opened = False - self.m9a_started = False - - async def check(self) -> str: - - if self.is_virtual_update_user: - return "Pass" - - if self.script_config.get( - "Run", "ProxyTimesLimit" - ) != 0 and self.cur_user_config.get( - "Data", "ProxyTimes" - ) >= self.script_config.get( - "Run", "ProxyTimesLimit" - ): - self.cur_user_item.status = "跳过" - return "今日代理次数已达上限, 跳过该用户" - return "Pass" - - async def prepare(self): - self.m9a_process_manager = ProcessManager() - self.m9a_log_monitor = LogMonitor( - (1, 24), - "%Y-%m-%d %H:%M:%S.%f", - self.check_log, - ) - self.wait_event = asyncio.Event() - self.user_start_time = datetime.now() - self.log_start_time = datetime.now() - - - async def main_task(self): - """自动代理模式主逻辑""" - self.task_dict = {} - - # 初始化每日代理状态 - if not self.is_virtual_update_user: - self.curdate = datetime.now(tz=UTC4).strftime("%Y-%m-%d") - 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) - - self.check_result = await self.check() - if self.check_result != "Pass": - if self.cur_user_item.status == "异常": - await Publisher.send( - id=self.task_info.task_id, - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData( - level="error", - message=f"用户 {self.cur_user_item.name} 检查未通过: {self.check_result}", - ), - ) - return - - await self.prepare() - - logger.info(f"开始代理用户: {self.cur_user_uid}") - self.cur_user_item.status = "运行" - self.run_complete = False - retry_limit = 1 if self.is_virtual_update_user else self.script_config.get("Run", "RunTimesLimit") - for i in range(retry_limit): - logger.info( - f"用户 {self.cur_user_item.name} 自动代理模式 - 尝试次数: {i + 1}/{retry_limit}" - ) - self.log_start_time = datetime.now() - self.cur_user_item.log_record[self.log_start_time] = ( - self.cur_user_log - ) = LogRecord() - - if self.is_virtual_update_user: - queue = [] - resource = "官服" - account = "" - else: - queue, queue_error = self._load_user_queue() - resource = self.cur_user_config.get("Info", "Resource") or "官服" - account = self.cur_user_config.get("Info", "Account") or "" - - if not queue: - result_message = queue_error or "未配置任务队列或队列为空" - logger.warning(f"用户 {self.cur_user_uid} {result_message}") - self.cur_user_item.status = "异常" - self.cur_user_item.result = result_message - return - - queue = self._filter_queue_for_run(queue) - if not queue: - logger.info(f"用户 {self.cur_user_uid} 的目标任务均已完成,跳过 M9A 启动") - self.run_complete = True - self.skip_proxy_count = True - self.cur_user_log.content = ["所有目标任务已完成,本次跳过 M9A 启动"] - self.cur_user_log.status = "Success!" - break - - # 执行任务前脚本 - if self.cur_user_config.get("Info", "IfScriptBeforeTask"): - await execute_script_task( - Path(self.cur_user_config.get("Info", "ScriptBeforeTask")), - "脚本前任务", - ) - - try: - if self.is_virtual_update_user: - emulator_info = None - else: - self.script_info.log = "正在启动模拟器" - emulator_info = await self.emulator_manager.open( - self.script_config.get("Emulator", "Index"), - ) - self.emulator_opened = True - except Exception as e: - logger.exception(f"用户: {self.cur_user_uid} - 模拟器启动失败: {e}") - await Publisher.send( - id=self.task_info.task_id, - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData( - level="error", message=f"启动模拟器时出现异常: {e}" - ), - ) - self.cur_user_log.content = [ - "模拟器启动失败, M9A 未实际运行, 无日志记录" - ] - self.cur_user_log.status = "模拟器启动失败" - - try: - await self.emulator_manager.close( - self.script_config.get("Emulator", "Index") - ) - except Exception as e: - logger.exception(f"关闭模拟器失败: {e}") - - await Notify.push_plyer( - "用户自动代理出现异常!", - f"{self.cur_user_item.name}出现异常", - "异常", - 3, - ) - continue - - if Config.get("Function", "IfSilence") and not self.is_virtual_update_user: - try: - await self.emulator_manager.setVisible( - self.script_config.get("Emulator", "Index"), False - ) - except Exception as e: - logger.exception(f"模拟器隐藏失败: {e}") - - logger.info(f"用户 {self.cur_user_uid} 将执行 {len(queue)} 个任务: {queue}") - - # 写入 M9A 配置 - await self.write_m9a_config(queue, emulator_info, resource, account) - - # 启动 M9A - logger.info(f"启动 M9A 进程:{self.m9a_exe_path}") - self.wait_event.clear() - await self.m9a_process_manager.open_process(self.m9a_exe_path) - self.m9a_started = True - # 等待 M9A 处理日志文件与初始化 - logger.info("等待 M9A 初始化...") - await asyncio.sleep(5) - - # 检查 M9A 进程是否还在运行 - if not await self.m9a_process_manager.is_running(): - logger.error("M9A 进程启动后立即退出,可能是 ADB 连接或模拟器问题") - raise RuntimeError("M9A 进程启动失败,请检查模拟器和 ADB 连接") - - logger.info("M9A 进程正常运行中...") - await self.m9a_log_monitor.start_monitor_file( - self.m9a_log_path, self.log_start_time - ) - await self.wait_event.wait() - await self.m9a_log_monitor.stop() - - if not self.is_virtual_update_user: - completed_entries = self._collect_completed_task_entries() - self.completed_task_entries.update(completed_entries) - await self._update_completed_task_state(completed_entries) - - if self.cur_user_log.status == "Success!": - logger.info(f"用户: {self.cur_user_uid} - M9A进程完成代理任务") - self.script_info.log = ( - "检测到 M9A 完成代理任务\n正在等待相关程序结束" - ) - self.run_complete = True - # 执行任务后脚本 - if self.cur_user_config.get("Info", "IfScriptAfterTask"): - await execute_script_task( - Path(self.cur_user_config.get("Info", "ScriptAfterTask")), - "脚本后任务", - ) - break - else: - logger.error( - f"用户: {self.cur_user_uid} - 代理任务异常: {self.cur_user_log.status}" - ) - self.script_info.log = ( - f"{self.cur_user_log.status}\n正在中止相关程序" - ) - - await self.m9a_process_manager.kill() - self.m9a_started = False - if not self.is_virtual_update_user: - try: - await self.emulator_manager.close( - self.script_config.get("Emulator", "Index") - ) - self.emulator_opened = False - except Exception as e: - logger.exception(f"关闭模拟器失败: {e}") - await System.kill_process(self.m9a_exe_path) - self.m9a_started = False - - await Notify.push_plyer( - "用户自动代理出现异常!", - f"{self.cur_user_item.name}出现异常", - "异常", - 3, - ) - - await asyncio.sleep(3) - - # 执行任务后脚本 - if self.cur_user_config.get("Info", "IfScriptAfterTask"): - await execute_script_task( - Path(self.cur_user_config.get("Info", "ScriptAfterTask")), - "脚本后任务", - ) - - def _load_user_queue(self) -> tuple[list, str | None]: - queue = self.cur_user_config.get("Task", "Queue") - logger.info(f"用户 {self.cur_user_uid} 的任务队列(原始): {queue}, 类型: {type(queue)}") - - if isinstance(queue, str): - try: - queue = json.loads(queue) - logger.info(f"任务队列已从 JSON 字符串解析: {queue}") - except Exception as e: - error = f"任务队列 JSON 解析失败: {e}" - logger.error(error) - return [], error - - if not isinstance(queue, list): - error = f"任务队列类型异常: {type(queue).__name__}" - logger.warning(f"用户 {self.cur_user_uid} 的{error}") - return [], error - - return [ - item - for item in queue - if self._get_queue_item_name(item) not in RESERVED_TASK_NAMES - ], None - - @staticmethod - def _get_queue_item_name(queue_item) -> str: - if isinstance(queue_item, str): - return queue_item - if isinstance(queue_item, dict): - return queue_item.get("name", "") - return "" - - def _get_queue_item_entry(self, queue_item) -> str: - if isinstance(queue_item, dict) and queue_item.get("entry"): - return queue_item["entry"] - return self._resolve_task_entry(self._get_queue_item_name(queue_item)) - - def _resolve_task_entry(self, task_name: str) -> str: - task_def = self.m9a_task_loader.get_full_definition(task_name) - if task_def and task_def.get("entry"): - return task_def["entry"] - return ENTRY_FALLBACK_NAMES.get(task_name, task_name) - - def _filter_queue_for_run(self, queue: list) -> list: - today = datetime.now(tz=UTC4).strftime("%Y-%m-%d") - current_month = datetime.now(tz=UTC4).strftime("%Y-%m") - filtered_queue = [] - - for queue_item in queue: - task_name = self._get_queue_item_name(queue_item) - entry = self._get_queue_item_entry(queue_item) - - if entry in self.completed_task_entries: - logger.info(f"跳过上一轮已完成任务: {task_name} ({entry})") - continue - - if ( - entry == PSYCHUBE_ENTRY - and self.script_config.get("Run", "IfPsychubeDailyOnce") - and self.cur_user_config.get("Data", "LastPsychubeDate") == today - ): - logger.info(f"每日心相今日已完成,跳过任务: {task_name}") - continue - - if ( - entry == LIMBO_ENTRY - and self.script_config.get("Run", "IfSleepDreamMonthlyOnce") - and self.cur_user_config.get("Data", "LastLimboMonth") == current_month - ): - logger.info(f"自动深眠本月已完成,跳过任务: {task_name}") - continue - - if ( - entry == LUCIDSCAPE_ENTRY - and self.script_config.get("Run", "IfSleepDreamMonthlyOnce") - and self.cur_user_config.get("Data", "LastLucidscapeMonth") == current_month - ): - logger.info(f"自动醒梦本月已完成,跳过任务: {task_name}") - continue - - filtered_queue.append(queue_item) - - logger.info(f"用户 {self.cur_user_uid} 将执行 {len(filtered_queue)} 个任务: {filtered_queue}") - return filtered_queue - - def _collect_completed_task_entries(self) -> set[str]: - analysis = M9ALogAnalyzer.parse_lines(self.cur_user_log.content) - completed_entries = set() - for task in analysis.get("tasks", []): - if task.get("status") != "完成": - continue - entry = self._resolve_task_entry(task.get("name", "")) - if entry: - completed_entries.add(entry) - return completed_entries - - async def _update_completed_task_state(self, completed_entries: set[str]) -> None: - if not completed_entries: - return - - today = datetime.now(tz=UTC4).strftime("%Y-%m-%d") - current_month = datetime.now(tz=UTC4).strftime("%Y-%m") - - if PSYCHUBE_ENTRY in completed_entries: - await self.cur_user_config.set("Data", "LastPsychubeDate", today) - if LIMBO_ENTRY in completed_entries: - await self.cur_user_config.set("Data", "LastLimboMonth", current_month) - if LUCIDSCAPE_ENTRY in completed_entries: - await self.cur_user_config.set("Data", "LastLucidscapeMonth", current_month) - - async def write_m9a_config(self, queue: list, emulator_info: DeviceInfo, resource: str = "官服", account: str = ""): - """向 M9A 目录写入运行配置文件,并保存 debug 备份""" - logger.info("开始配置 M9A 运行参数") - - if not self.is_virtual_update_user: - await self.m9a_process_manager.kill() - await System.kill_process(self.m9a_exe_path) - - try: - if self.is_virtual_update_user: - config = await self._build_virtual_config() - else: - emulator_id = self.script_config.get("Emulator", "Id") - emulator_index = self.script_config.get("Emulator", "Index") - - config = await self.build_config( - queue=queue, - task_loader=self.m9a_task_loader, - emulator_info=emulator_info, - emulator_id=emulator_id, - script_config=self.script_config, - emulator_index=emulator_index, - emulator_manager=self.emulator_manager, - resource=resource, - account=account - ) - except Exception as e: - logger.error(f"构建 M9A 配置失败: {e}") - raise - - # 保存配置到 M9A 目录 - self.m9a_tasks_path.write_text( - json.dumps(config, ensure_ascii=False, indent=2), - encoding="utf-8" - ) - logger.info(f"已写入 M9A 配置:{self.m9a_tasks_path}") - - # Debug 备份:保存到 data/script_id 目录,按 testN.json 递增,保留最近 5 个 - debug_dir = Path("data") / self.script_info.script_id - debug_dir.mkdir(parents=True, exist_ok=True) - - # 查找现有 test*.json 文件,获取下一个编号 - existing_tests = list(debug_dir.glob("test*.json")) - test_numbers = [] - for test_file in existing_tests: - match = re.search(r"test(\d+)\.json", test_file.name) - if match: - test_numbers.append(int(match.group(1))) - - next_num = max(test_numbers) + 1 if test_numbers else 1 - backup_path = debug_dir / f"test{next_num}.json" - - # 保存备份 - backup_path.write_text( - json.dumps(config, ensure_ascii=False, indent=2), - encoding="utf-8" - ) - logger.info(f"Debug 备份已保存:{backup_path}") - - # 清理旧备份,只保留最近 5 个 - existing_tests = list(debug_dir.glob("test*.json")) - test_files_with_num = [] - for test_file in existing_tests: - match = re.search(r"test(\d+)\.json", test_file.name) - if match: - test_files_with_num.append((int(match.group(1)), test_file)) - - # 按编号排序,删除最旧的 - test_files_with_num.sort(key=lambda x: x[0]) - if len(test_files_with_num) > 5: - files_to_delete = test_files_with_num[:-5] - for num, file_path in files_to_delete: - try: - file_path.unlink() - logger.debug(f"已删除旧备份文件:{file_path}") - except Exception as e: - logger.warning(f"删除旧备份文件失败 {file_path}: {e}") - - - async def check_log(self, log_content: list[str], latest_time: datetime) -> None: - - log = "".join(log_content) - self.cur_user_log.content = log_content - self.script_info.log = log - - if self.is_first_user_for_version_check: - version_keywords = [ - "检测到资源有新版本", - "检测到新版本", - "Found new version", - "New version detected", - ] - if any(kw in log for kw in version_keywords): - if not getattr(self.script_info, '_m9a_has_new_version', False): - self.script_info._m9a_has_new_version = True - logger.info("在首个用户日志中检测到 M9A 新版本提示!") - - version_match = re.search(r'当前资源版本:v([\d.]+)', log) - if version_match and not getattr(self.script_info, '_m9a_current_version', None): - self.script_info._m9a_current_version = version_match.group(1) - - version_match = re.search(r'最新资源版本:v([\d.]+)', log) - if version_match: - self.script_info._m9a_latest_version = version_match.group(1) - - if "任务已全部完成!" in log or "All tasks completed" in log: - if not self.is_virtual_update_user: - self.cur_user_log.status = "Success!" - elif "已放弃本次任务" in log: - self.cur_user_log.status = "M9A 已放弃本次任务" - elif not await self.m9a_process_manager.is_running(): - if "任务已全部完成!" not in log and "All tasks completed" not in log: - self.cur_user_log.status = "M9A 进程已异常结束" - else: - self.cur_user_log.status = "M9A 进程已结束" - elif datetime.now() - latest_time > timedelta( - minutes=self.script_config.get("Run", "RunTimeLimit") - ): - self.cur_user_log.status = "M9A 进程超时" - else: - self.cur_user_log.status = "M9A 正常运行中" - - if self.is_virtual_update_user: - await self._check_virtual_user_log(log) - return - - logger.debug(f"M9A 日志分析结果:{self.cur_user_log.status}") - if self.cur_user_log.status != "M9A 正常运行中": - logger.info(f"M9A 任务结果:{self.cur_user_log.status}") - self.wait_event.set() - - async def _check_virtual_user_log(self, log: str): - - if "获取资源包下载信息失败" in log: - reason_match = re.search(r'原因=(.+?)(?:\n|$)', log) - reason = reason_match.group(1).strip() if reason_match else "未知原因" - logger.warning(f"虚拟用户: M9A 资源更新失败 - {reason}") - self.cur_user_log.status = f"M9A 更新失败: {reason}" - if not hasattr(self.script_info, '_m9a_err_log'): - self.script_info._m9a_err_log = [] - self.script_info._m9a_err_log.append("获取资源包下载信息失败") - self.wait_event.set() - return - - if "文件操作失败" in log and "远程主机强迫关闭了一个现有的连接" in log: - logger.warning("虚拟用户: M9A 更新下载失败 - 网络连接中断") - self.cur_user_log.status = "M9A 更新失败: 网络连接中断" - if not hasattr(self.script_info, '_m9a_err_log'): - self.script_info._m9a_err_log = [] - self.script_info._m9a_err_log.append("网络连接中断") - self.wait_event.set() - return - - if "HTTP 请求失败" in log: - reason_match = re.search(r'原因=(.+?)(?:\n|$)', log) - reason = reason_match.group(1).strip() if reason_match else "HTTP 请求失败" - reason = re.sub(r'[((][^))]*[))]$', '', reason).strip().rstrip('.') - logger.warning(f"虚拟用户: M9A HTTP 请求失败 - {reason}") - self.cur_user_log.status = f"M9A 更新失败: {reason}" - if not hasattr(self.script_info, '_m9a_err_log'): - self.script_info._m9a_err_log = [] - self.script_info._m9a_err_log.append("HTTP 请求失败") - self.wait_event.set() - return - - if "准备重新启动应用" in log or "Preparing to restart" in log: - logger.info("虚拟用户: M9A 准备重启应用更新") - self.script_info._m9a_restart_triggered = True - - if "[ERR]" in log and not getattr(self.script_info, '_m9a_restart_triggered', False): - err_content = log.split("[ERR]", 1)[1].strip() if "[ERR]" in log else "" - if err_content: - err_content = re.sub(r'\[src=[^\]]+\]', '', err_content) - err_content = re.sub(r'\[cfg=[^\]]+\]', '', err_content) - err_content = re.sub(r'\[inst=[^\]]+\]', '', err_content) - err_content = re.sub(r'\[op=[^\]]+\]', '', err_content) - err_content = ' '.join(err_content.split()) - err_content = err_content.strip().rstrip('.') - if err_content: - logger.warning(f"虚拟用户: M9A 运行错误 - {err_content}") - if not hasattr(self.script_info, '_m9a_err_log'): - self.script_info._m9a_err_log = [] - short_err = err_content.split(' at ')[0].strip() - if len(short_err) > 80: - short_err = short_err[:77] + '...' - self.script_info._m9a_err_log.append(short_err) - - elapsed = (datetime.now() - self.log_start_time).total_seconds() - if elapsed > 600: - self.script_info._m9a_timeout = True - err_log = getattr(self.script_info, '_m9a_err_log', []) - err_suffix = f"({err_log[-1]})" if err_log else "" - logger.warning(f"虚拟用户: 更新超时(10分钟){err_suffix}") - self.cur_user_log.status = f"M9A 更新超时" - self.wait_event.set() - return - - if not await self.m9a_process_manager.is_running(): - if getattr(self.script_info, '_m9a_restart_triggered', False): - logger.info("虚拟用户: M9A 更新成功(进程已正常重启退出)") - self.script_info._m9a_update_success = True - self.cur_user_log.status = "Success!" - self.wait_event.set() - return - else: - err_log = getattr(self.script_info, '_m9a_err_log', []) - err_suffix = f"({err_log[-1]})" if err_log else "" - logger.warning(f"虚拟用户: M9A 进程异常退出(未触发重启信号){err_suffix}") - self.cur_user_log.status = f"M9A 进程异常结束{err_suffix}" - self.wait_event.set() - return - - async def final_task(self): - """运行结束后的收尾工作""" - - try: - if hasattr(self, "m9a_log_monitor") and self.m9a_log_monitor is not None: - await self.m9a_log_monitor.stop() - except Exception as e: - logger.warning(f"停止 M9A 日志监控失败: {e}") - - if self.check_result != "Pass": - return - - if self.is_virtual_update_user: - try: - await self.m9a_process_manager.kill() - except Exception as e: - logger.warning(f"结束 M9A 进程失败: {e}") - try: - await System.kill_process(self.m9a_exe_path) - except Exception as e: - logger.warning(f"强制结束 M9A.exe 失败: {e}") - - if self.cur_user_log.status == "Success!": - self.cur_user_item.status = "完成" - logger.success(f"虚拟用户 {self.cur_user_uid} M9A 自动更新完成") - else: - self.cur_user_item.status = "异常" - logger.warning(f"虚拟用户 {self.cur_user_uid} M9A 自动更新异常: {self.cur_user_log.status}") - logger.info("虚拟用户任务结束") - return - - if self.m9a_started: - # 结束 M9A 进程 - try: - await self.m9a_process_manager.kill() - except Exception as e: - logger.warning(f"结束 M9A 进程失败: {e}") - try: - await System.kill_process(self.m9a_exe_path) - except Exception as e: - logger.warning(f"强制结束 M9A.exe 失败: {e}") - - if self.emulator_opened: - # 关闭模拟器 - logger.info("用户任务结束,关闭模拟器") - try: - await self.emulator_manager.close( - self.script_config.get("Emulator", "Index") - ) - except Exception as e: - logger.warning(f"关闭模拟器失败: {e}") - - # 保存历史记录并合并统计信息 - user_logs_list = [] - user_log_records = [] - for t, log_item in sorted(self.cur_user_item.log_record.items(), key=lambda item: item[0]): - - if log_item.status == "M9A 正常运行中": - log_item.status = "任务被用户手动中止" - - dt = t.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" - ) - user_logs_list.append(log_path.with_suffix(".json")) - user_log_records.append( - { - "start_time": t, - "log_path": log_path, - "status": log_item.status, - "content": list(log_item.content), - } - ) - - await Config.save_maa_log(log_path, log_item.content, log_item.status) - - statistics = await Config.merge_statistic_info(user_logs_list) - statistics["user_info"] = self.cur_user_item.name - statistics["start_time"] = self.user_start_time.strftime("%Y-%m-%d %H:%M:%S") - statistics["end_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - statistics["user_result"] = ( - "代理任务全部完成" - if self.run_complete - else self.cur_user_item.result - ) - - # 分析运行日志,获取任务详情 - task_details_text = "" - try: - task_details_text = self._build_attempt_task_details(user_log_records) - except Exception as e: - logger.exception(f"日志分析失败: {e}") - statistics["task_details"] = task_details_text - - # 根据运行结果更新用户状态 - if self.cur_user_item.status == "运行": - if self.run_complete: - # 正常完成 - self.cur_user_item.status = "完成" - - if not self.skip_proxy_count: - # 如果是第一次代理,减少剩余天数 - 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 - ) - - logger.success(f"用户 {self.cur_user_uid} 的自动代理任务已完成") - - # 发送桌面通知 - await Notify.push_plyer( - "成功完成一个自动代理任务!", - f"已完成用户 {self.cur_user_item.name} 的自动代理任务", - f"已完成 {self.cur_user_item.name} 的自动代理任务", - 3, - ) - else: - # 未检测到正常完成标志,置为异常 - self.cur_user_item.status = "异常" - logger.warning(f"用户 {self.cur_user_uid} 的 M9A 任务异常结束: {self.cur_user_log.status}") - logger.error(f"用户 {self.cur_user_uid} 的自动代理任务未完成") - - try: - await push_notification( - "统计信息", - f"{datetime.now().strftime('%m-%d')} |{'√' if self.run_complete else 'X'}| {self.cur_user_item.name} 的自动代理统计报告", - statistics, - self.cur_user_config, - ) - except Exception as e: - logger.exception(f"推送通知时出现异常: {e}") - await Publisher.send( - id=self.task_info.task_id, - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData(level="error", message=f"推送通知时出现异常: {e}"), - ) - - - async def build_config( - self, - queue: list[dict], - task_loader: 'M9ATaskLoader', - emulator_info: DeviceInfo | None = None, - emulator_id: str | None = None, - script_config: M9AConfig | None = None, - emulator_index: str | None = None, - emulator_manager = None, - resource: str = "官服", - account: str = "" - ) -> dict: - config = None - - if self.template_path.exists(): - try: - config = json.loads(self.template_path.read_text(encoding="utf-8")) - config["Resource"] = resource - logger.info(f"使用配置模板:{self.template_path}") - except Exception as e: - logger.warning(f"读取模板 {self.template_path} 失败:{e}") - - if config is None: - logger.warning("无法读取配置模板,使用最小默认配置") - config = { - "Resource": resource, - "CurrentTasks": [], - "TaskItems": [], - "AdbDevice": { - "InfoHandle": {"value": 0}, - "Name": "", - "AdbPath": "", - "AdbSerial": "", - "ScreencapMethods": 0, - "InputMethods": 0, - "Config": "{}", - "AgentPath": "./MaaAgentBinary" - }, - "ResourceOptionItems": {}, - "CurrentControllerName": "ADB", - "Connect.Address": "" - } - - all_tasks = task_loader.get_all_tasks_with_entry() - config["CurrentTasks"] = [ - f"{task['name']}<|||>{task['entry']}" - for task in all_tasks - ] - logger.info(f"M9A CurrentTasks:共 {len(config['CurrentTasks'])} 个任务") - - config["TaskItems"] = [] - - # 自动添加启动游戏(队列首) - startup_def = task_loader.get_full_definition("启动游戏") - if startup_def: - config["TaskItems"].append(self._build_task_item(startup_def, default_check=True)) - - # 如果官服且填写了账号信息,插入切换账号 - if resource == "官服" and account: - switch_account_def = task_loader.get_full_definition("切换账号") - if switch_account_def: - switch_item = self._build_task_item(switch_account_def, default_check=True) - for opt in (switch_item.get("option") or []): - if opt.get("name") == "目标账号(可选)": - opt["data"] = {"账号": account} - config["TaskItems"].append(switch_item) - - skipped_standalone = 0 - - for queue_item in queue: - if isinstance(queue_item, str): - task_name = queue_item - task_options = None - else: - task_name = queue_item.get("name") - task_options = queue_item.get("options") - - task_def = task_loader.get_full_definition(task_name) - if not task_def: - logger.warning(f"未找到任务定义:{task_name},跳过") - continue - - if "standalone" in task_def.get("group", []): - logger.debug(f"跳过 standalone 任务:{task_name}") - skipped_standalone += 1 - continue - - item = self._build_task_item(task_def, default_check=True, user_options=task_options) - config["TaskItems"].append(item) - - # 自动添加关闭游戏(队列尾) - close_def = task_loader.get_full_definition("关闭游戏") - if close_def: - config["TaskItems"].append(self._build_task_item(close_def, default_check=True)) - - logger.info( - f"M9A TaskItems:共 {len(config['TaskItems'])} 个任务项" - f"(已过滤 {skipped_standalone} 个 standalone 任务)" - ) - - if emulator_id and script_config and emulator_index and emulator_manager: - try: - adb_device_config = await self._build_adb_device_config( - emulator_info, emulator_id, script_config, emulator_index, emulator_manager - ) - if adb_device_config: - config["AdbDevice"] = adb_device_config - logger.info("已应用特殊 AdbDevice 配置") - except Exception as e: - logger.warning(f"构建特殊 AdbDevice 配置失败,使用默认配置: {e}") - - if emulator_info and emulator_info.adb_address != "Unknown": - config["Connect.Address"] = emulator_info.adb_address - - config["InstanceName"] = "MAS" - - if "BeforeTask" not in config: - config["BeforeTask"] = "StartupSoftwareAndScript" - if "AfterTask" not in config: - config["AfterTask"] = "CloseEmulatorAndMFA" - - config["AutoConnectAfterRefresh"] = False - config["AutoDetectOnConnectionFailed"] = False - config["AllowAdbHardRestart"] = False - config["AllowAdbRestart"] = False - config["UseFingerprintMatching"] = False - config["RememberAdb"] = True - - logger.info( - f"M9A 配置构建完成:CurrentTasks={len(config['CurrentTasks'])} 个任务, " - f"TaskItems={len(config['TaskItems'])} 个任务项" - ) - return config - - async def _build_virtual_config(self) -> dict: - - config = {} - if self.template_path.exists(): - try: - config = json.loads(self.template_path.read_text(encoding="utf-8")) - except Exception: - pass - - config.update({ - "BeforeTask": "None", - "AfterTask": "None", - "CurrentTasks": [ - "启动游戏<|||>StartUp", - "关闭游戏<|||>Close1999" - ], - "TaskItems": [ - { - "name": "启动游戏", - "entry": "StartUp", - "default_check": False, - "controller": ["ADB"] - }, - { - "name": "关闭游戏", - "entry": "Close1999", - "default_check": False, - "controller": ["ADB"] - } - ], - "Resource": config.get("Resource", "官服"), - "InstanceName": "MAS-Update", - "AutoConnectAfterRefresh": False, - "AutoDetectOnConnectionFailed": False, - "ContinueRunningWhenError": False, - "RememberAdb": False, - "RetryOnDisconnected": False, - "AllowAdbRestart": False, - "AllowAdbHardRestart": False, - "AdbControlScreenCapType": "None", - "AdbControlInputType": "None", - "CurrentControllerName": "ADB", - "UI.LiveView.RefreshRate": 10.0, - "UI.LiveView.EnableLiveView": True, - "AgentTcpMode": True, - }) - - logger.info("虚拟用户 M9A 配置构建完成") - return config - - @staticmethod - def _build_option_list(option_names: list[str], option_definitions: dict) -> list[dict]: - options = [] - for opt_name in option_names: - opt_item = {"name": opt_name, "index": 0} - - opt_def = option_definitions.get(opt_name, {}) - if isinstance(opt_def, dict) and "cases" in opt_def: - cases = opt_def.get("cases", []) - if opt_def.get("type") == "checkbox": - default_case = opt_def.get("default_case", []) - if isinstance(default_case, str): - selected_cases = [default_case] - elif default_case: - selected_cases = list(default_case) - else: - selected_cases = [ - c["name"] for c in cases if "name" in c - ] - opt_item["selected_cases"] = selected_cases - - sub_option_names = [] - for case in cases: - if case.get("name") in selected_cases and "option" in case: - sub_option_names.extend(case["option"]) - if sub_option_names: - sub_opts = AutoProxyTask._build_option_list( - list(dict.fromkeys(sub_option_names)), option_definitions - ) - if sub_opts: - opt_item["sub_options"] = sub_opts - - elif cases and len(cases) > 0: - current_case = cases[0] - if "option" in current_case: - sub_opts = AutoProxyTask._build_option_list( - current_case["option"], option_definitions - ) - if sub_opts: - opt_item["sub_options"] = sub_opts - - if isinstance(opt_def, dict) and opt_def.get("type") == "input" and "inputs" in opt_def: - data = {} - for input_def in opt_def["inputs"]: - input_name = input_def.get("name") - default_value = input_def.get("default") - if input_name and default_value is not None: - data[input_name] = default_value - if data: - opt_item["data"] = data - - options.append(opt_item) - - return options - - @staticmethod - def _build_option_list_from_user(user_options: list[dict], option_definitions: dict) -> list[dict]: - options = [] - for user_opt in user_options: - opt_name = user_opt.get("name") - opt_index = user_opt.get("index", 0) - opt_item = {"name": opt_name, "index": opt_index} - - opt_def = option_definitions.get(opt_name, {}) - if isinstance(opt_def, dict) and "cases" in opt_def: - cases = opt_def.get("cases", []) - if opt_def.get("type") == "checkbox": - user_selected_cases = user_opt.get("selected_cases") - if user_selected_cases is None: - default_case = opt_def.get("default_case", []) - if isinstance(default_case, str): - user_selected_cases = [default_case] - elif default_case: - user_selected_cases = list(default_case) - else: - user_selected_cases = [ - c["name"] for c in cases if "name" in c - ] - opt_item["selected_cases"] = user_selected_cases - - user_sub_opts = user_opt.get("sub_options", []) - if user_sub_opts: - sub_opts = AutoProxyTask._build_option_list_from_user( - user_sub_opts, option_definitions - ) - if sub_opts: - opt_item["sub_options"] = sub_opts - elif user_selected_cases: - sub_option_names = [] - for case in cases: - if case.get("name") in user_selected_cases and "option" in case: - sub_option_names.extend(case["option"]) - sub_opts = AutoProxyTask._build_option_list( - list(dict.fromkeys(sub_option_names)), option_definitions - ) - if sub_opts: - opt_item["sub_options"] = sub_opts - - elif cases and len(cases) > opt_index: - current_case = cases[opt_index] - if "option" in current_case: - user_sub_opts = user_opt.get("sub_options", []) - sub_opts = AutoProxyTask._build_option_list_from_user( - user_sub_opts, option_definitions - ) - if sub_opts: - opt_item["sub_options"] = sub_opts - - user_data = user_opt.get("data") if "data" in user_opt else user_opt.get("input_values") - - if user_data is not None: - opt_item["data"] = user_data - elif isinstance(opt_def, dict) and opt_def.get("type") == "input" and "inputs" in opt_def: - data = {} - for input_def in opt_def["inputs"]: - input_name = input_def.get("name") - default_value = input_def.get("default") - if input_name and default_value is not None: - data[input_name] = default_value - if data: - opt_item["data"] = data - - options.append(opt_item) - - return options - - def _build_task_item(self, task_def: dict, default_check: bool = True, user_options: list | None = None) -> dict: - item = { - "name": task_def["name"], - "entry": task_def["entry"], - "default_check": default_check, - } - - if "group" in task_def: - item["group"] = task_def["group"] - - if "description" in task_def: - item["description"] = task_def["description"] - - if "controller" in task_def: - item["controller"] = task_def["controller"] - - if user_options is not None and "_option_definitions" in task_def: - item["option"] = self._build_option_list_from_user( - user_options, - task_def["_option_definitions"] - ) - elif "option" in task_def and "_option_definitions" in task_def: - item["option"] = self._build_option_list( - task_def["option"], - task_def["_option_definitions"] - ) - - if "pipeline_override" in task_def: - item["pipeline_override"] = task_def["pipeline_override"] - - return item - - async def _build_adb_device_config( - self, - emulator_info: DeviceInfo, - emulator_id: str, - script_config: M9AConfig, - emulator_index: str, - emulator_manager - ) -> dict | None: - try: - emulator_uid = uuid.UUID(emulator_id) - emulator_config = Config.EmulatorConfig[emulator_uid] - - emulator_type = emulator_config.get("Info", "Type") - emulator_path = Path(emulator_config.get("Info", "Path")) - - if emulator_type == "ldplayer": - return await self._build_ldplayer_config( - emulator_info, emulator_path, emulator_index, emulator_manager - ) - elif emulator_type == "mumu": - return self._build_mumu_config( - emulator_info, emulator_path, emulator_index - ) - else: - logger.info(f"不支持的模拟器类型: {emulator_type},使用默认配置") - return None - except Exception as e: - logger.warning(f"构建 AdbDevice 配置时出错: {e}") - return None - - async def _build_ldplayer_config( - self, - emulator_info: DeviceInfo, - emulator_path: Path, - emulator_index: str, - emulator_manager - ) -> dict: - logger.info("构建雷电模拟器 AdbDevice 配置") - - ld_player_device = None - try: - devices = await emulator_manager.get_device_info(emulator_index) - if emulator_index in devices: - ld_player_device = devices[emulator_index] - logger.info(f"成功获取雷电模拟器设备信息: idx={ld_player_device.idx}, pid={ld_player_device.pid}") - except Exception as e: - logger.warning(f"获取雷电模拟器设备信息失败: {e}") - - emulator_root = emulator_path.parent - adb_path = emulator_root / "adb.exe" - - name = ld_player_device.title if ld_player_device else "雷电模拟器-LDPlayer" - idx = ld_player_device.idx if ld_player_device else int(emulator_index) - pid = ld_player_device.pid if ld_player_device else 0 - - ld_extras = { - "enable": True, - "index": idx, - "path": str(emulator_root).replace("\\", "/"), - "pid": pid - } - - config_json = json.dumps({"extras": {"ld": ld_extras}}, ensure_ascii=False) - - return { - "Name": name, - "AdbPath": str(adb_path).replace("\\", "/"), - "AdbSerial": f"emulator-{5554 + idx * 2}", - "ScreencapMethods": 64, - "InputMethods": 18446744073709551607, - "Config": config_json, - "AgentPath": "./MaaAgentBinary" - } - - def _build_mumu_config( - self, - emulator_info: DeviceInfo, - emulator_path: Path, - emulator_index: str - ) -> dict: - logger.info("构建 MuMu 模拟器 AdbDevice 配置") - - shell_dir = emulator_path.parent - emulator_root = shell_dir.parent - adb_path = shell_dir / "adb.exe" - - mumu_extras = { - "enable": True, - "index": int(emulator_index), - "path": str(emulator_root).replace("\\", "/") - } - - config_json = json.dumps({"extras": {"mumu": mumu_extras}}, ensure_ascii=False) - - return { - "Name": "MuMu模拟器", - "AdbPath": str(adb_path).replace("\\", "/"), - "AdbSerial": emulator_info.adb_address, - "ScreencapMethods": 64, - "InputMethods": 18446744073709551607, - "Config": config_json, - "AgentPath": "./MaaAgentBinary" - } - - def _build_attempt_task_details(self, user_log_records: list[dict]) -> str: - """按本轮尝试顺序汇总 M9A 任务详情。""" - if not user_log_records: - return "" - - multiple_attempts = len(user_log_records) > 1 - detail_blocks = [] - - for index, record in enumerate(user_log_records, start=1): - try: - analysis = M9ALogAnalyzer.parse_lines(record["content"]) - detail_text = M9ALogAnalyzer.build_notification_text(analysis) - except Exception as e: - logger.exception(f"解析第 {index} 次 M9A 尝试日志失败: {e}") - detail_text = "" - - if not multiple_attempts: - return detail_text - - start_time = record["start_time"].strftime("%H:%M:%S") - status = record["status"] or "-" - if not detail_text: - detail_text = "未解析到任务详情" - detail_blocks.append(f"第 {index} 次尝试({start_time},{status})\n{detail_text}") - - return "\n\n".join(detail_blocks) - - async def on_crash(self, e: Exception): - self.cur_user_item.status = "异常" - logger.exception(f"自动代理任务出现异常: {e}") - await Publisher.send( - id=self.task_info.task_id, - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData(level="error", message=f"自动代理任务出现异常: {e}"), - ) diff --git a/app/task/M9A/__init__.py b/app/task/M9A/__init__.py deleted file mode 100644 index 641ba85bb..000000000 --- a/app/task/M9A/__init__.py +++ /dev/null @@ -1,24 +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 . - -# Contact: DLmaster_361@163.com - - -from .manager import M9AManager - -__all__ = ["M9AManager"] diff --git a/app/task/M9A/manager.py b/app/task/M9A/manager.py deleted file mode 100644 index 6fafa3798..000000000 --- a/app/task/M9A/manager.py +++ /dev/null @@ -1,467 +0,0 @@ -# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software -# Copyright © 2024-2025 DLmaster361 -# 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 . - -# Contact: DLmaster_361@163.com - - -import uuid -import json -import shutil -import asyncio -from pathlib import Path -from datetime import datetime - -from app.core import Config, EmulatorManager -from app.core.ws import Publisher, protocol -from app.models.schema import WSTaskNoticeData -from app.models.task import TaskExecuteBase, ScriptItem, UserItem -from app.models.ConfigBase import MultipleConfig -from app.models.config import M9AConfig, M9AUserConfig -from app.services import Notify, System -from app.utils import get_logger -from app.utils.constants import TASK_MODE_ZH -from .tools import push_notification, push_version_update -from .AutoProxy import AutoProxyTask -from .task_loader import M9ATaskLoader - - -logger = get_logger("M9A 调度器") - -METHOD_BOOK: dict[str, type[AutoProxyTask]] = { - "AutoProxy": AutoProxyTask -} - - -class M9AManager(TaskExecuteBase): - """M9A 调度器""" - - def __init__(self, script_info: ScriptItem): - 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.check_result = "-" - self.has_new_version = False - self.auto_update_fix_enabled = False - self._virtual_user_old_version = None - self._virtual_user_new_version = None - - async def check(self) -> str: - """校验 M9A 配置是否可用""" - script_id = uuid.UUID(self.script_info.script_id) - script_config = Config.ScriptConfig[script_id] - - if self.task_info.mode not in METHOD_BOOK: - return "不支持的任务模式,请检查任务配置!" - if not isinstance(script_config, M9AConfig): - return "脚本配置类型错误,不是 M9A 脚本类型" - if script_config.get("Emulator", "Id") == "-" or script_config.get( - "Emulator", "Index" - ) in ("", "-"): - return "未完成模拟器配置,请检查脚本配置中的模拟器设置!" - - m9a_exe_path = Path(script_config.get("Info", "Path")) / "M9A.exe" - if not m9a_exe_path.exists(): - return "M9A.exe 文件不存在,请检查 M9A 路径设置!" - - m9a_root = Path(script_config.get("Info", "Path")) - m9a_config_dir = m9a_root / "config" - m9a_instances_dir = m9a_config_dir / "instances" - - root_name_lower = str(m9a_root).lower() - looks_like_mux = "mux" in root_name_lower or any( - "mux" in p.name.lower() for p in m9a_root.iterdir() - ) - - if not m9a_config_dir.exists(): - return "M9A/config 目录不存在,请检查 M9A 路径是否指向完整的 M9A 程序目录。" - - if not m9a_instances_dir.exists(): - if looks_like_mux: - return ( - "检测到当前 M9A 可能为 MuX 框架构建" - "(目录/配置结构与 AUTO-MAS 的 M9A 适配不兼容)。\n" - "请在脚本编辑设置中将 M9A 路径切换为 MFAA 构建版本的 M9A 根目录" - "(应包含 M9A.exe 与 config/instances/)。" - ) - return ( - "M9A/config/instances 目录不存在,无法写入运行配置。\n" - "请确认 M9A 路径正确,或使用 MFAA 构建版本的 M9A。" - ) - - if not any(m9a_config_dir.glob("*.json")): - return "M9A 配置文件不存在或已损坏,请检查 M9A 路径或配置文件情况!" - return "Pass" - - async def _set_m9a_auto_update(self, enabled: bool): - """设置 M9A config.json 中 EnableAutoUpdateResource 的值""" - if not self.m9a_config_path: - return - config_json = self.m9a_config_path / "config.json" - if not config_json.exists(): - return - try: - config = json.loads(config_json.read_text(encoding="utf-8")) - config["EnableAutoUpdateResource"] = enabled - config_json.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8") - status = "开启" if enabled else "关闭" - logger.info(f"已{status} M9A 自动更新开关") - except Exception: - logger.warning("读写 M9A config.json 失败,跳过自动更新控制") - - async def _set_m9a_silent_mode(self): - if not self.m9a_config_path: - return - config_json = self.m9a_config_path / "config.json" - if not config_json.exists(): - return - try: - config = json.loads(config_json.read_text(encoding="utf-8")) - is_silent = Config.get("Function", "IfSilence") - config["AutoMinimize"] = is_silent - config["AutoHide"] = is_silent - config["ShouldMinimizeToTray"] = is_silent - config_json.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8") - status = "开启" if is_silent else "关闭" - logger.info(f"已{status} M9A 静默模式(AutoMinimize={is_silent}, AutoHide={is_silent}, ShouldMinimizeToTray={is_silent})") - except Exception as e: - logger.warning(f"读写 M9A config.json 失败,跳过静默模式配置: {e}") - - async def prepare(self): - """运行前准备""" - - script_id = uuid.UUID(self.script_info.script_id) - await Config.ScriptConfig[script_id].lock() - self.script_config = Config.ScriptConfig[script_id] - self.user_config = MultipleConfig([M9AUserConfig]) - await self.user_config.load(await self.script_config.UserData.toDict()) - logger.success(f"{self.script_info.script_id} 已锁定,M9A 配置提取完成") - - self.m9a_config_path = Path(self.script_config.get("Info", "Path")) / "config" - self.temp_path = Path.cwd() / f"data/{self.script_info.script_id}/Temp" - self.m9a_task_loader = await asyncio.to_thread( - M9ATaskLoader.get_cached, - Path(self.script_config.get("Info", "Path")), - ) - - # 初始化模拟器管理器 - self.emulator_manager = await EmulatorManager.get_emulator_instance( - self.script_config.get("Emulator", "Id") - ) - - # 备份原始配置并清空 instances 目录(仅保留 default.json) - shutil.rmtree(self.temp_path, ignore_errors=True) - self.temp_path.mkdir(parents=True, exist_ok=True) - if self.m9a_config_path.exists(): - shutil.copytree(self.m9a_config_path, self.temp_path, dirs_exist_ok=True) - - instances_dir = self.m9a_config_path / "instances" - if instances_dir.exists(): - for json_file in instances_dir.glob("*.json"): - try: - json_file.unlink() - logger.info(f"已删除原始配置文件:{json_file}") - except Exception as e: - logger.warning(f"删除原始配置文件 {json_file} 失败:{e}") - - # 构建用户列表 - self.script_info.user_list = [ - UserItem( - user_id=str(uid), name=config.get("Info", "Name"), status="等待" - ) - for uid, config in self.user_config.items() - if config.get("Info", "Status") - and config.get("Info", "RemainedDay") != 0 - ] - logger.info( - f"用户列表加载完成, 已筛选用户数: {len(self.script_info.user_list)}" - ) - - m9a_exe = Path(self.script_config.get("Info", "Path")) / "M9A.exe" - await System.kill_process(m9a_exe) - - await self._set_m9a_auto_update(False) - await self._set_m9a_silent_mode() - - self.auto_update_fix_enabled = self.script_config.get("Run", "IfAutoUpdateAfterQueue") - if self.auto_update_fix_enabled: - logger.success("已开启队列结束后自动更新,将在批量任务后统一处理") - else: - logger.info("队列结束后自动更新未开启,跳过自动更新处理") - - async def main_task(self): - - self.check_result = await self.check() - if self.check_result != "Pass": - logger.error(f"未通过配置检查: {self.check_result}") - await Publisher.send( - id=self.task_info.task_id, - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData(level="error", message=self.check_result), - ) - return - - self.begin_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - await self.prepare() - - if not isinstance(self.script_config, M9AConfig): - raise RuntimeError("脚本配置类型错误, 不是 M9A 脚本类型") - - for self.script_info.current_index in range(len(self.script_info.user_list)): - task = METHOD_BOOK[self.task_info.mode]( - self.script_info, - self.script_config, - self.user_config, - self.emulator_manager, - self.m9a_task_loader, - ) - if self.auto_update_fix_enabled and self.script_info.current_index == 0: - task.is_first_user_for_version_check = True - - await self.spawn(task) - - if self.auto_update_fix_enabled and self.script_info.current_index == 0: - self.has_new_version = getattr(self.script_info, '_m9a_has_new_version', False) - if not self.has_new_version: - logger.info("首个用户未检测到 M9A 新版本,批量任务完成后将跳过自动更新") - - if self.auto_update_fix_enabled and self.has_new_version: - logger.info("检测到 M9A 有新版本,将启动虚拟用户执行自动更新") - - self.script_info._m9a_restart_triggered = False - await self._set_m9a_auto_update(True) - - virtual_uid_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, "m9a-update.mas.auto") - virtual_uid = str(virtual_uid_uuid) - - virtual_user = UserItem( - user_id=virtual_uid, - name="M9A自动更新", - status="等待" - ) - self.script_info.user_list.append(virtual_user) - - virtual_user_config_data = M9AUserConfig() - await virtual_user_config_data.set("Info", "Name", "M9A自动更新") - await virtual_user_config_data.set("Info", "Status", True) - await virtual_user_config_data.set("Info", "RemainedDay", 999) - await virtual_user_config_data.set("Notify", "Enabled", False) - virtual_user_config = { - virtual_uid_uuid: virtual_user_config_data - } - - self.script_info.current_index = len(self.script_info.user_list) - 1 - - virtual_task = METHOD_BOOK[self.task_info.mode]( - self.script_info, - self.script_config, - virtual_user_config, - self.emulator_manager, - self.m9a_task_loader, - ) - virtual_task.is_virtual_update_user = True - - await self.spawn(virtual_task) - - self._virtual_user_old_version = getattr(self.script_info, '_m9a_current_version', '未知') - self._virtual_user_new_version = getattr(self.script_info, '_m9a_latest_version', '未知') - - virtual_user_item = self.script_info.user_list[-1] - if virtual_user_item.status == "完成": - await self._refresh_m9a_task_cache_after_update() - logger.success(f"M9A 自动更新完成: v{self._virtual_user_old_version} → v{self._virtual_user_new_version}") - else: - logger.warning(f"虚拟用户未正常完成,状态: {virtual_user_item.status}") - - async def final_task(self): - """运行结束后的收尾工作""" - - if self.check_result != "Pass": - self.script_info.status = "异常" - return self.check_result - - logger.info("M9A 主任务已结束, 开始执行后续操作") - await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].unlock() - logger.success(f"已解锁脚本配置 {self.script_info.script_id}") - - if self.task_info.mode in ["AutoProxy"]: - - await self.emulator_manager.close( - self.script_config.get("Emulator", "Index") - ) - await Config.ScriptConfig[ - uuid.UUID(self.script_info.script_id) - ].UserData.load(await self.user_config.toDict()) - await Config.ScriptConfig.save() - - error_user = [ - u.name for u in self.script_info.user_list if u.status == "异常" - ] - over_user = [ - u.name for u in self.script_info.user_list if u.status == "完成" - ] - wait_user = [ - u.name for u in self.script_info.user_list if u.status == "等待" - ] - - title = f"{datetime.now().strftime('%m-%d')} | {self.script_info.name or '空白'}的{TASK_MODE_ZH[self.task_info.mode]}任务报告" - result = { - "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", - "script_name": self.script_info.name or "空白", - "start_time": self.begin_time, - "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "completed_count": len(over_user), - "uncompleted_count": len(error_user) + len(wait_user), - "result": self.script_info.result, - } - - await Notify.push_plyer( - title.replace("报告", "已完成!"), - f"已完成用户数: {len(over_user)}, 未完成用户数: {len(error_user) + len(wait_user)}", - f"已完成用户数: {len(over_user)}, 未完成用户数: {len(error_user) + len(wait_user)}", - 10, - ) - try: - await push_notification("代理结果", title, result, None) - except Exception as e: - logger.exception(f"推送代理结果时出现异常: {e}") - await Publisher.send( - id=self.task_info.task_id, - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData( - level="error", message=f"推送代理结果时出现异常: {e}" - ), - ) - - # 延迟 2 秒再推版本更新,避免与代理结果通知在同一毫秒内连发, - # 导致企业微信 webhook 因短时间重复消息被去重/折叠而丢失。 - await asyncio.sleep(2) - await self._notify_version_update_result() - - if (self.temp_path).exists(): - shutil.rmtree(self.m9a_config_path, ignore_errors=True) - shutil.copytree(self.temp_path, self.m9a_config_path, dirs_exist_ok=True) - shutil.rmtree(self.temp_path, ignore_errors=True) - - self.script_info.status = "完成" - - async def _refresh_m9a_task_cache_after_update(self): - """资源更新成功后预热 M9A 任务缓存。""" - if not getattr(self.script_info, '_m9a_update_success', False): - return - - try: - m9a_root = Path(self.script_config.get("Info", "Path")) - self.m9a_task_loader = await asyncio.to_thread( - M9ATaskLoader.get_cached, - m9a_root, - force_reload=True, - ) - logger.info("M9A 资源更新后任务缓存已刷新") - except Exception as e: - logger.warning(f"M9A 资源更新后刷新任务缓存失败: {e}") - - async def _notify_version_update_result(self): - - if ( - getattr(self.script_info, '_m9a_update_success', False) - and self._virtual_user_new_version - and self._virtual_user_old_version - ): - update_title = "M9A 资源版本更新" - update_message = ( - f"M9A 资源版本已从 v{self._virtual_user_old_version} " - f"更新至 v{self._virtual_user_new_version}" - ) - try: - await Notify.push_plyer(update_title, update_message, update_message, 10) - except Exception as e: - logger.exception(f"版本更新桌面通知发送失败: {e}") - - update_result = { - "title": update_title, - "script_name": self.script_info.name or "空白", - "start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "completed_count": 1, - "uncompleted_count": 0, - "result": update_message, - } - # 版本更新通知为系统事件,不经过 SendTaskResultTime 过滤器,始终推送 - try: - await push_version_update(update_title, update_result) - logger.info(f"已发送版本更新通知: {update_message}") - except Exception as e: - logger.exception(f"版本更新通知发送失败: {e}") - - elif not getattr(self.script_info, '_m9a_update_success', False) and self._virtual_user_old_version: - err_log = getattr(self.script_info, '_m9a_err_log', []) - virtual_status = "未知错误" - full_reason = err_log[-1] if err_log else "无" - if getattr(self.script_info, '_m9a_timeout', False): - virtual_status = "更新超时" - elif err_log: - last_err = err_log[-1] - if "网络连接中断" in last_err: - virtual_status = "网络连接中断" - elif "HTTP 请求失败" in last_err: - virtual_status = "HTTP 请求失败" - elif "获取资源包下载信息失败" in last_err: - virtual_status = "获取资源包下载信息失败" - elif "进程异常结束" in last_err or "进程异常退出" in last_err: - virtual_status = "进程异常退出" - else: - virtual_status = "未知错误" - - fail_title = f"M9A 资源更新失败 ({datetime.now().strftime('%m-%d')})" - fail_message = f"M9A 资源更新失败({virtual_status})\n当前版本: v{self._virtual_user_old_version}" - try: - await Notify.push_plyer(fail_title, fail_message, fail_message, 10) - except Exception as e: - logger.exception(f"版本更新失败桌面通知发送失败: {e}") - - fail_message = f"更新失败({virtual_status}),当前版本: v{self._virtual_user_old_version}" - fail_result = { - "title": fail_title, - "script_name": self.script_info.name or "空白", - "start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "completed_count": 0, - "uncompleted_count": 1, - "result": fail_message, - } - # 版本更新失败通知为系统事件,不经过 SendTaskResultTime 过滤器,始终推送 - try: - await push_version_update(fail_title, fail_result) - except Exception as e: - logger.exception(f"版本更新失败通知发送失败: {e}") - logger.warning(f"M9A 自动更新失败: {virtual_status}(完整原因: {full_reason})") - - async def on_crash(self, e: Exception): - - self.script_info.status = "异常" - logger.exception(f"M9A任务出现异常: {e}") - await Publisher.send( - id=self.task_info.task_id, - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData(level="error", message=f"M9A任务出现异常: {e}"), - ) diff --git a/app/task/M9A/task_loader.py b/app/task/M9A/task_loader.py deleted file mode 100644 index 75a0e0a7c..000000000 --- a/app/task/M9A/task_loader.py +++ /dev/null @@ -1,587 +0,0 @@ -# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software -# Copyright © 2024-2025 DLmaster361 -# 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 . - -# Contact: DLmaster_361@163.com - - -import hashlib -import json -import time -from copy import deepcopy -from pathlib import Path -from threading import RLock - -import json5 - -from app.utils import get_logger - -logger = get_logger("M9A 任务加载器") - - -class M9ATaskLoader: - """M9A 任务加载器""" - - _disk_cache_version = 1 - _disk_cache_max_age_seconds = 30 * 24 * 60 * 60 - _disk_cache_cleanup_interval_seconds = 24 * 60 * 60 - _loader_cache: dict[Path, tuple[tuple, "M9ATaskLoader"]] = {} - _cache_lock = RLock() - _last_disk_cache_cleanup_at = 0.0 - - def __init__(self, m9a_root_path: Path): - self.root_path = m9a_root_path.resolve() - self.tasks_dir = self.root_path / "resource/tasks" - self._task_cache: dict[str, dict] = {} - self._raw_data_cache: dict[str, dict] = {} - self._dependency_paths: set[Path] = set() - self._scan_select_specs: set[tuple[Path, str]] = set() - self._loaded_from_interface = False - self._load_all_tasks() - - @classmethod - def get_cached(cls, m9a_root_path: Path, force_reload: bool = False) -> "M9ATaskLoader": - """获取按 M9A 根目录缓存的任务加载器。""" - root_path = m9a_root_path.resolve() - with cls._cache_lock: - cache_path = cls._disk_cache_path(root_path) - cls._cleanup_expired_disk_cache(cache_path) - - cached = cls._loader_cache.get(root_path) - if cached and not force_reload: - signature, loader = cached - current_signature = cls._build_signature( - root_path, - loader._dependency_paths, - loader._scan_select_specs, - include_tasks_dir=not loader._loaded_from_interface, - ) - if current_signature == signature: - cls._touch_disk_cache(cache_path) - logger.debug(f"复用 M9A 任务缓存:{root_path}") - return loader - logger.info(f"M9A 任务缓存已失效,重新加载:{root_path}") - - if not force_reload: - loader = cls._load_from_disk_cache(root_path) - if loader is not None: - cls._loader_cache[root_path] = (loader._current_signature(), loader) - return loader - - loader = cls(root_path) - if loader._task_cache: - signature = loader._current_signature() - cls._loader_cache[root_path] = (signature, loader) - loader._save_disk_cache(signature) - else: - cls._loader_cache.pop(root_path, None) - return loader - - @classmethod - def _disk_cache_dir(cls) -> Path: - return Path.cwd() / "data/cache/m9a_task_loader" - - @classmethod - def _disk_cache_path(cls, root_path: Path) -> Path: - cache_key = hashlib.sha256(str(root_path).casefold().encode("utf-8")).hexdigest() - return cls._disk_cache_dir() / f"{cache_key}.json" - - @classmethod - def _cleanup_expired_disk_cache(cls, protected_cache_path: Path) -> None: - """清理 30 天未使用的 M9A 任务磁盘缓存。""" - now = time.time() - if now - cls._last_disk_cache_cleanup_at < cls._disk_cache_cleanup_interval_seconds: - return - - cls._last_disk_cache_cleanup_at = now - cache_dir = cls._disk_cache_dir() - if not cache_dir.is_dir(): - return - - cutoff = now - cls._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"已清理过期 M9A 本地任务缓存:{cache_file}") - except Exception as e: - logger.warning(f"清理 M9A 本地任务缓存失败:{cache_file},{e}") - - @staticmethod - def _touch_disk_cache(cache_path: Path) -> None: - try: - if cache_path.is_file(): - cache_path.touch() - except Exception as e: - logger.debug(f"更新 M9A 本地任务缓存使用时间失败:{e}") - - @staticmethod - def _signature_to_json(signature: tuple) -> list[list]: - return [list(part) for part in signature] - - @staticmethod - def _is_valid_disk_cache_payload(payload: dict) -> bool: - return ( - isinstance(payload.get("task_cache"), dict) - and isinstance(payload.get("raw_data_cache"), dict) - and isinstance(payload.get("dependency_paths"), list) - and isinstance(payload.get("scan_select_specs"), list) - and isinstance(payload.get("signature"), list) - ) - - @classmethod - def _load_from_disk_cache(cls, root_path: Path) -> "M9ATaskLoader | None": - cache_path = cls._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") != cls._disk_cache_version: - return None - if not cls._is_valid_disk_cache_payload(payload): - logger.info(f"M9A 本地任务缓存结构已过期:{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) - } - loaded_from_interface = bool(payload.get("loaded_from_interface")) - current_signature = cls._build_signature( - root_path, - dependency_paths, - scan_select_specs, - include_tasks_dir=not loaded_from_interface, - ) - - if payload.get("signature") != cls._signature_to_json(current_signature): - logger.info(f"M9A 本地任务缓存已失效:{root_path}") - return None - - loader = cls.__new__(cls) - loader.root_path = root_path - loader.tasks_dir = root_path / "resource/tasks" - loader._task_cache = deepcopy(payload.get("task_cache", {})) - loader._raw_data_cache = deepcopy(payload.get("raw_data_cache", {})) - loader._dependency_paths = dependency_paths - loader._scan_select_specs = scan_select_specs - loader._loaded_from_interface = loaded_from_interface - - if not loader._task_cache: - return None - - cls._touch_disk_cache(cache_path) - logger.info(f"读取 M9A 本地任务缓存:{root_path}") - return loader - except Exception as e: - logger.warning(f"读取 M9A 本地任务缓存失败,回退实时解析:{e}") - return None - - def _save_disk_cache(self, signature: tuple | None = None) -> None: - if not self._task_cache: - return - - signature = signature or self._current_signature() - cache_path = self._disk_cache_path(self.root_path) - payload = { - "version": self._disk_cache_version, - "root_path": str(self.root_path), - "loaded_from_interface": self._loaded_from_interface, - "dependency_paths": [str(path) for path in sorted(self._dependency_paths, key=lambda p: str(p))], - "scan_select_specs": [ - [str(path), scan_filter] - for path, scan_filter in sorted(self._scan_select_specs, key=lambda item: (str(item[0]), item[1])) - ], - "signature": self._signature_to_json(signature), - "task_cache": self._task_cache, - "raw_data_cache": self._raw_data_cache, - } - - 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"已写入 M9A 本地任务缓存:{cache_path}") - except Exception as e: - logger.warning(f"写入 M9A 本地任务缓存失败:{e}") - - @staticmethod - 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) - - @staticmethod - def _interface_candidates(root_path: Path) -> list[Path]: - return [ - base_dir / file_name - for base_dir in (root_path, root_path / "assets", root_path / "resource") - for file_name in ("interface.json", "interface.jsonc") - ] - - @classmethod - def _build_signature( - cls, - root_path: Path, - dependency_paths: set[Path], - scan_select_specs: set[tuple[Path, str]], - include_tasks_dir: bool, - ) -> tuple: - signature_parts = [] - - interface_candidates = cls._interface_candidates(root_path) - interface_candidate_set = {path.resolve() for path in interface_candidates} - - for path in interface_candidates: - signature_parts.append(cls._file_signature(path)) - - for path in sorted(dependency_paths, key=lambda p: str(p)): - if path.resolve() not in interface_candidate_set: - signature_parts.append(cls._file_signature(path)) - - if include_tasks_dir: - tasks_dir = root_path / "resource/tasks" - signature_parts.append(cls._file_signature(tasks_dir)) - for json_file in sorted(tasks_dir.glob("*.json")): - signature_parts.append(cls._file_signature(json_file.resolve())) - - 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(cls._file_signature(scan_path)) - try: - scan_files = sorted(scan_path.glob(scan_filter)) - except Exception as e: - signature_parts.append(("scan-error", str(scan_path), scan_filter, type(e).__name__, str(e))) - continue - - for file in scan_files: - if file.is_file(): - signature_parts.append(cls._file_signature(file.resolve())) - - return tuple(signature_parts) - - def _current_signature(self) -> tuple: - return self._build_signature( - self.root_path, - self._dependency_paths, - self._scan_select_specs, - include_tasks_dir=not self._loaded_from_interface, - ) - - def _load_all_tasks(self): - """加载所有任务定义(包括 standalone 任务)""" - if self._load_interface_tasks(): - return - - if not self.tasks_dir.exists(): - logger.error(f"任务目录不存在:{self.tasks_dir}") - return - - for json_file in self.tasks_dir.glob("*.json"): - try: - data = json.loads(json_file.read_text(encoding="utf-8")) - - # 缓存原始数据(包含 option 定义对象) - if "option" in data: - for task in data.get("task", []): - name = task.get("name") - if name: - self._raw_data_cache[name] = data - - # 加载所有任务定义(包括 standalone) - for task in data.get("task", []): - name = task.get("name") - if not name: - continue - - # ✅ 不再过滤 standalone 任务,加载所有任务 - self._task_cache[name] = task - logger.debug(f"加载任务:{name}") - - except Exception as e: - logger.warning(f"读取 {json_file.name} 失败:{e}") - - logger.success(f"M9A 任务加载完成,共 {len(self._task_cache)} 个任务") - - self._add_missing_option_fallback() - - def _load_interface_tasks(self) -> bool: - """优先从新版 M9A interface.json 读取任务定义。""" - interface_path = next( - ( - path - for path in self._interface_candidates(self.root_path) - if path.is_file() - ), - None, - ) - if interface_path is None: - return False - - def resolve_path(base_dir: Path, raw_path: str) -> Path: - relative_path = raw_path.strip().replace("\\", "/") - if not relative_path or Path(relative_path).is_absolute() or ".." in relative_path.split("/"): - raise ValueError(f"路径不允许使用绝对路径或包含 ..:{raw_path}") - return (base_dir / relative_path).resolve() - - def read_interface(path: Path, stack: list[Path]) -> tuple[list, dict]: - resolved_path = path.resolve() - if resolved_path in stack: - raise ValueError(f"检测到 interface 循环导入:{resolved_path}") - - self._dependency_paths.add(resolved_path) - data = json5.loads(path.read_text(encoding="utf-8")) - if not isinstance(data, dict): - raise ValueError(f"interface 必须是 JSON 对象:{path}") - - tasks = data.get("task", []) - options = data.get("option", {}) - tasks = tasks if isinstance(tasks, list) else [] - options = options if isinstance(options, dict) else {} - - for option_data in options.values(): - if not isinstance(option_data, dict) or option_data.get("type") != "scan_select": - continue - - scan_dir = option_data.get("scan_dir") - scan_filter = option_data.get("scan_filter") - if not isinstance(scan_dir, str) or not isinstance(scan_filter, str): - option_data["cases"] = [] - continue - - scan_filter = scan_filter.strip().replace("\\", "/") - if not scan_filter or Path(scan_filter).is_absolute() or ".." in scan_filter.split("/"): - raise ValueError(f"路径不允许使用绝对路径或包含 ..:{scan_filter}") - scan_path = resolve_path(self.root_path, scan_dir) - self._scan_select_specs.add((scan_path, scan_filter)) - option_data["cases"] = [ - {"name": file.relative_to(scan_path).as_posix(), "label": file.name} - for file in sorted(scan_path.glob(scan_filter)) - if file.is_file() - ] - - for import_path in data.get("import", []) or []: - if not isinstance(import_path, str) or not import_path.strip(): - continue - - child_tasks, child_options = read_interface(resolve_path(self.root_path, import_path), [*stack, resolved_path]) - tasks.extend(child_tasks) - options.update(child_options) - - return tasks, options - - try: - tasks, options = read_interface(interface_path, []) - except Exception as e: - logger.warning(f"读取 M9A interface 失败,回退旧任务目录:{e}") - return False - - for task in tasks: - if not isinstance(task, dict): - continue - name = task.get("name") - if not isinstance(name, str) or not name or not task.get("entry"): - continue - - self._task_cache[name] = task - self._raw_data_cache[name] = {"option": options} - logger.debug(f"加载 interface 任务:{name}") - - if not self._task_cache: - return False - - self._loaded_from_interface = True - logger.success(f"M9A interface 任务加载完成,共 {len(self._task_cache)} 个任务") - return True - - def _add_missing_option_fallback(self): - """ - 添加缺失选项的动态兜底逻辑: - 如果某个任务的 task.option 数组里列了某个选项,但该文件的 option 字典里没有定义, - 则从其他有该选项定义的任务中复制过来,包括递归处理子选项 - """ - global_option_defs = {} - - for json_file in self.tasks_dir.glob("*.json"): - try: - data = json.loads(json_file.read_text(encoding="utf-8")) - if "option" in data: - for opt_name, opt_def in data["option"].items(): - if opt_name not in global_option_defs: - global_option_defs[opt_name] = opt_def - except Exception: - continue - - if not global_option_defs: - logger.debug("未找到任何选项定义,跳过兜底逻辑") - return - - def collect_required_options(opt_name: str, collected: set): - if opt_name in collected: - return - if opt_name not in global_option_defs: - return - - collected.add(opt_name) - opt_def = global_option_defs[opt_name] - - if "cases" in opt_def: - for case in opt_def["cases"]: - if "option" in case: - for sub_opt_name in case["option"]: - collect_required_options(sub_opt_name, collected) - - for task_name, raw_data in self._raw_data_cache.items(): - if "option" not in raw_data or "task" not in raw_data: - continue - - task_def_list = raw_data["task"] - referenced_options = set() - - for t in task_def_list: - if "option" in t: - for opt_name in t["option"]: - collect_required_options(opt_name, referenced_options) - - missing_options = [] - for opt_name in referenced_options: - if opt_name not in raw_data["option"] and opt_name in global_option_defs: - missing_options.append(opt_name) - - if missing_options: - logger.info(f"为任务 '{task_name}' 添加缺失选项配置: {missing_options}") - - for opt_name in missing_options: - raw_data["option"][opt_name] = global_option_defs[opt_name].copy() - - def get_available_tasks(self) -> list[dict]: - """ - 获取可用任务列表(排除 standalone 任务) - - 用于前端展示,standalone 任务不会出现在可选列表中 - - Returns: - 任务列表,每个任务包含 name, entry, group, description - """ - return [ - { - "name": t.get("name"), - "entry": t.get("entry"), - "group": t.get("group", []), - "label": t.get("label"), - "description": t.get("description", ""), - } - for t in self._task_cache.values() - if "standalone" not in t.get("group", []) - ] - - def get_full_definition(self, task_name: str) -> dict | None: - """ - 获取任务的完整定义(包含原始 option 定义对象) - - Args: - task_name: 任务名称 - - Returns: - 任务定义字典,包含额外的 _option_definitions 字段 - """ - task_def = self._task_cache.get(task_name) - if not task_def: - return None - - result = deepcopy(task_def) - - # 添加 option 定义对象(用于构建 TaskItems) - if task_name in self._raw_data_cache: - raw_data = self._raw_data_cache[task_name] - if "option" in raw_data: - result["_option_definitions"] = deepcopy(raw_data["option"]) - - return result - - def get_task_definition(self, task_name: str) -> dict | None: - """ - 获取单个任务的定义(兼容旧接口) - - Args: - task_name: 任务名称 - - Returns: - 任务定义字典,如果不存在返回 None - """ - task_def = self._task_cache.get(task_name) - return deepcopy(task_def) if task_def else None - - def get_all_task_names(self) -> list[str]: - """ - 获取所有任务名称列表(包括 standalone) - - Returns: - 任务名称列表 - """ - return list(self._task_cache.keys()) - - def get_all_tasks_with_entry(self) -> list[dict]: - """ - 获取所有任务及其 entry(用于构建 CurrentTasks) - - Returns: - 任务列表,每个任务包含 name 和 entry - """ - return [ - { - "name": name, - "entry": task.get("entry", name) - } - for name, task in self._task_cache.items() - ] - - def reload(self): - """重新加载所有任务(用于热更新)""" - self._task_cache.clear() - self._raw_data_cache.clear() - self._dependency_paths.clear() - self._scan_select_specs.clear() - self._loaded_from_interface = False - self._load_all_tasks() - with self._cache_lock: - if self._task_cache: - signature = self._current_signature() - self._loader_cache[self.root_path] = (signature, self) - self._save_disk_cache(signature) - else: - self._loader_cache.pop(self.root_path, None) diff --git a/app/task/M9A/tools/__init__.py b/app/task/M9A/tools/__init__.py deleted file mode 100644 index 948eb365a..000000000 --- a/app/task/M9A/tools/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software -# Copyright © 2024-2025 DLmaster361 -# Copyright © 2025 MoeSnowyFox -# 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 . - -# Contact: DLmaster_361@163.com - - -from .notify import push_notification, push_version_update - -__all__ = ["push_notification", "push_version_update"] diff --git a/app/task/M9A/tools/notify.py b/app/task/M9A/tools/notify.py deleted file mode 100644 index b7c45b715..000000000 --- a/app/task/M9A/tools/notify.py +++ /dev/null @@ -1,462 +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 . - -# Contact: DLmaster_361@163.com - -import re -from collections.abc import Awaitable -from pathlib import Path - -from app.core import Config -from app.services import Notify -from app.utils import get_logger -from app.models.config import M9AUserConfig - -logger = get_logger("M9A通知工具") - - -class M9ALogAnalyzer: - """M9A 运行日志分析器 - 用于解析 M9A 运行日志,提取任务执行详情(任务名、状态、关卡信息、掉落物品等), - 并提供格式化通知文本的方法。 - """ - - DROP_KEYWORDS = ("掉落统计:", "材料掉落总结:") - """掉落物统计行的关键词""" - - SOURCE_TAGS = ("src=Monitor", "src=Worker", "src=Core") - """支持解析的 M9A 日志来源标记""" - - RARITY_TAGS_RE = re.compile(r"^\[.+?\]$") - """稀有度标签正则,匹配 [黄色] [紫色] 等括号标签,在最终输出中过滤掉""" - - HTML_TAG_RE = re.compile(r"<[^>]+>") - - @staticmethod - def _strip_html(text: str) -> str: - """去除 HTML 标签,如 xxx""" - return M9ALogAnalyzer.HTML_TAG_RE.sub("", text).strip() - - @staticmethod - def _extract_task_start(line: str) -> str | None: - """从"开始任务:XXX"行提取任务名""" - m = re.search(r"开始任务:(.+)$", line) - if m: - return m.group(1).strip() - return None - - @staticmethod - def _extract_record(line: str) -> str | None: - """从 [Record] 行提取记录文本(已去除 HTML 标签)""" - m = re.search(r"\[Record\] (.+)$", line) - if m: - return M9ALogAnalyzer._strip_html(m.group(1).strip()) - return None - - @staticmethod - def _is_task_start(line: str) -> bool: - return "队列任务开始(异步)" in line - - @staticmethod - def _is_task_complete(line: str) -> bool: - return "队列任务完成(异步)" in line - - @staticmethod - def _is_all_done(line: str) -> bool: - return "任务已全部完成!" in line - - @staticmethod - def _is_drop_line(line: str) -> bool: - return any(kw in line for kw in M9ALogAnalyzer.DROP_KEYWORDS) - - @staticmethod - def _is_supported_source(line: str) -> bool: - return any(tag in line for tag in M9ALogAnalyzer.SOURCE_TAGS) - - @staticmethod - def parse_log(log_path: Path) -> dict: - """解析 M9A 运行日志文件 - - Args: - log_path: 日志文件路径 - - Returns: - 解析结果字典,结构如下: - { - "tasks": [ - { - "name": "启动游戏", - "status": "完成" | "失败" | "开始", - "details": ["文本内容", ...], - "extra": { - "stage": "12-5, 难度:Hard", - "count": "1", - "drops": ["物品 x1", ...] - } - }, - ... - ], - "overall_status": "成功" | "失败", - "duration": "00:05:30" - } - """ - try: - return M9ALogAnalyzer.parse_lines( - log_path.read_text(encoding="utf-8").splitlines() - ) - except Exception: - return { - "tasks": [], - "overall_status": "解析失败", - "duration": "", - } - - @staticmethod - def parse_lines(lines: list[str]) -> dict: - tasks = [] - current_task = None - in_drops = False - drops = [] - overall_status = "失败" - duration = "" - - def save_drops(): - nonlocal drops, in_drops - if current_task and drops: - current_task["extra"]["drops"] = drops - drops = [] - in_drops = False - - for i, line in enumerate(lines): - if not M9ALogAnalyzer._is_supported_source(line): - continue - - task_name = M9ALogAnalyzer._extract_task_start(line) - if task_name: - if current_task and current_task["status"] == "开始": - current_task["status"] = "失败" - save_drops() - current_task = { - "name": task_name, - "status": "开始", - "details": [], - "extra": {}, - } - tasks.append(current_task) - in_drops = False - drops = [] - continue - - if M9ALogAnalyzer._is_all_done(line): - overall_status = "成功" - if i + 1 < len(lines): - m = re.search(r"用时 (.+)", lines[i + 1]) - if m: - duration = m.group(1).rstrip(")") - if current_task and current_task["status"] == "开始": - current_task["status"] = "完成" - save_drops() - continue - - if M9ALogAnalyzer._is_task_complete(line): - if current_task and current_task["status"] == "开始": - current_task["status"] = "完成" - save_drops() - continue - - if M9ALogAnalyzer._is_drop_line(line): - in_drops = True - drops.clear() - continue - - if in_drops and current_task: - if "MonitorMarkdown" in line: - idx = line.find("MonitorMarkdown") - raw = line[idx + len("MonitorMarkdown"):].lstrip("] ") - drop_text = M9ALogAnalyzer._strip_html(raw.strip()) - if drop_text and drop_text not in M9ALogAnalyzer.DROP_KEYWORDS: - drops.append(drop_text) - continue - if "MonitorLog" not in line: - continue - - if current_task: - record = M9ALogAnalyzer._extract_record(line) - if record: - current_task["details"].append(record) - if "当前关卡" in record: - current_task["extra"]["stage"] = record.replace( - "当前关卡:", "" - ) - elif "任务结束,总共刷了" in record: - m = re.search(r"总共刷了 (\d+) 次", record) - if m: - current_task["extra"]["count"] = m.group(1) - - if current_task and current_task["status"] == "开始": - current_task["status"] = "失败" - save_drops() - - return { - "tasks": tasks, - "overall_status": overall_status, - "duration": duration, - } - - @staticmethod - def build_notification_text(analysis: dict) -> str: - """根据 parse_log 的分析结果构建可读的通知文本 - - 特殊任务会附加额外信息: - - 切换账号:附加匹配到的目标账号 - - 有关卡、刷图次数、掉落总结的任务:附加对应信息 - - Args: - analysis: parse_log 返回的分析结果字典 - - Returns: - 格式化的通知文本 - """ - lines = [] - for task in analysis["tasks"]: - line = f"{task['name']} - {task['status']}" - - if task["name"] == "切换账号": - account_match = None - for d in task["details"]: - if "匹配到目标账号" in d: - account_match = d - break - if account_match: - line += f"({account_match})" - - parts = [] - stage = task["extra"].get("stage", "") - count = task["extra"].get("count", "") - if stage: - parts.append(stage) - if count: - parts.append(f"刷图{count}次") - if parts: - line += f"({', '.join(parts)})" - - drops = task["extra"].get("drops", []) - drops = [d for d in drops if not M9ALogAnalyzer.RARITY_TAGS_RE.match(d)] - if drops: - lines.append(line) - for d in drops: - lines.append(f" 掉落:{d}") - continue - - lines.append(line) - - if analysis.get("duration"): - lines.append(f"\n总计用时: {analysis['duration']}") - - return "\n".join(lines) - - -# ==================== 通知推送辅助函数 ==================== - - -async def _safe_send_channel(channel_name: str, send_coro: Awaitable[None]) -> None: - """单个通知渠道失败时不影响其他渠道。""" - try: - await send_coro - except Exception as e: - logger.warning(f"{channel_name} 通知发送失败: {e}") - - -async def _send_to_all_global_channels( - title: str, message_text: str, message_html: str -) -> None: - """向所有启用的全局通知渠道推送消息 - - Args: - title: 通知标题 - message_text: 纯文本格式内容 - message_html: HTML 格式内容 - """ - serverchan_message = message_text.replace("\n", "\n\n") - - if Config.get("Notify", "IfSendMail"): - await _safe_send_channel( - "全局邮件", - Notify.send_mail( - "网页", title, message_html, Config.get("Notify", "ToAddress") - ), - ) - - if Config.get("Notify", "IfServerChan"): - await _safe_send_channel( - "全局 ServerChan", - Notify.ServerChanPush( - title, - f"{serverchan_message}\n\nAUTO-MAS 敬上", - Config.get("Notify", "ServerChanKey"), - ), - ) - - custom_webhooks = Config.Notify_CustomWebhooks - for webhook in custom_webhooks.values(): - await _safe_send_channel( - "全局自定义 Webhook", - Notify.WebhookPush(title, f"{message_text}\n\nAUTO-MAS 敬上", webhook), - ) - - if Config.get("Notify", "IfKoishiSupport"): - await _safe_send_channel( - "全局 Koishi", - Notify.send_koishi(f"{title}\n\n{message_text}\n\nAUTO-MAS 敬上"), - ) - - -async def _send_to_user_channels( - title: str, message_text: str, message_html: str, - user_config: M9AUserConfig -) -> None: - """向用户配置的独立通知渠道推送消息 - - 与全局渠道不同,这里额外检查用户的 Enabled 和 IfSendStatistic 配置。 - - Args: - title: 通知标题 - message_text: 纯文本格式内容 - message_html: HTML 格式内容 - user_config: 用户配置对象 - """ - serverchan_message = message_text.replace("\n", "\n\n") - - if user_config.get("Notify", "IfSendMail"): - if not user_config.get("Notify", "ToAddress"): - logger.error("用户邮箱地址为空,无法发送邮件通知") - else: - await _safe_send_channel( - "用户邮件", - Notify.send_mail( - "网页", title, message_html, user_config.get("Notify", "ToAddress") - ), - ) - - if user_config.get("Notify", "IfServerChan"): - if not user_config.get("Notify", "ServerChanKey"): - logger.error("用户 ServerChan 密钥为空,无法发送通知") - else: - await _safe_send_channel( - "用户 ServerChan", - Notify.ServerChanPush( - title, - f"{serverchan_message}\n\nAUTO-MAS 敬上", - user_config.get("Notify", "ServerChanKey"), - ), - ) - - custom_webhooks = user_config.Notify_CustomWebhooks - for webhook in custom_webhooks.values(): - await _safe_send_channel( - "用户自定义 Webhook", - Notify.WebhookPush(title, f"{message_text}\n\nAUTO-MAS 敬上", webhook), - ) - - -# ==================== 对外接口 ==================== - - -async def push_notification( - mode: str, title: str, message: dict, user_config: M9AUserConfig | None -) -> None: - """统一通知推送入口,根据 mode 分支到不同策略 - - Args: - mode: 通知模式 - "代理结果" 或 "统计信息" - title: 通知标题 - message: 通知内容字典,各模式所需字段不同: - - "代理结果": start_time, end_time, completed_count, uncompleted_count, result - - "统计信息": start_time, end_time, user_result, task_details - user_config: 用户配置(统计信息模式用于发送独立通知,可为 None) - """ - logger.info(f"开始推送通知,模式: {mode},标题: {title}") - - if mode == "代理结果": - await _push_proxy_result(title, message) - elif mode == "统计信息": - await _push_statistics(title, message, user_config) - - -async def push_version_update(title: str, message: dict) -> None: - """推送版本更新通知(系统事件,不经过 SendTaskResultTime 过滤器) - - Args: - title: 通知标题 - message: 通知内容字典,需包含以下字段: - title, script_name, start_time, end_time, completed_count, - uncompleted_count, result - """ - logger.info(f"开始推送版本更新通知: {title}") - # 提取纯文本消息(result 字段作为正文,不附加任务统计前缀) - message_text = message["result"] - message_html = Config.notify_env.get_template("general_result.html").render(message) - await _send_to_all_global_channels(title, message_text, message_html) - - -async def _push_proxy_result(title: str, message: dict) -> None: - """推送全局代理结果通知""" - result_time_setting = Config.get("Notify", "SendTaskResultTime") - if result_time_setting != "任何时刻" and ( - result_time_setting != "仅失败时" or message["uncompleted_count"] == 0 - ): - logger.debug("当前 SendTaskResultTime 配置不满足推送条件,跳过") - return - - message_text = ( - f"任务开始时间: {message['start_time']}, 结束时间: {message['end_time']}\n" - f"已完成数: {message['completed_count']}, 未完成数: {message['uncompleted_count']}\n\n" - f"{message['result']}" - ) - - template = Config.notify_env.get_template("general_result.html") - message_html = template.render(message) - - await _send_to_all_global_channels(title, message_text, message_html) - - -async def _push_statistics( - title: str, message: dict, user_config: M9AUserConfig | None -) -> None: - """推送统计信息通知(全局 + 用户独立)""" - task_details = message.get("task_details", "") - detail_str = f"\n{task_details}\n" if task_details else "" - message_text = ( - f"开始时间: {message['start_time']}\n" - f"结束时间: {message['end_time']}\n" - f"M9A脚本执行结果: {message['user_result']}" - f"{detail_str}\n" - ) - - template = Config.notify_env.get_template("m9a_statistics.html") - message_html = template.render(message) - - if Config.get("Notify", "IfSendStatistic"): - await _send_to_all_global_channels(title, message_text, message_html) - - if ( - user_config is not None - and user_config.get("Notify", "Enabled") - and user_config.get("Notify", "IfSendStatistic") - ): - await _send_to_user_channels(title, message_text, message_html, user_config) diff --git a/app/task/MAA/manager.py b/app/task/MAA/manager.py index 104d0a8f3..452d326e9 100644 --- a/app/task/MAA/manager.py +++ b/app/task/MAA/manager.py @@ -120,7 +120,7 @@ async def prepare(self): """运行前准备""" # 锁定脚本配置并加载用户配置 - await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].lock() + await Config.lock_script_config(self.script_info.script_id) self.script_config = Config.ScriptConfig[uuid.UUID(self.script_info.script_id)] self.user_config = MultipleConfig([MaaUserConfig]) await self.user_config.load(await self.script_config.UserData.toDict()) @@ -194,19 +194,25 @@ async def final_task(self): return self.check_result logger.info("MAA 主任务已结束, 开始执行后续操作") - await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].unlock() + + write_back_user_config = False + try: + if self.task_info.mode in ["AutoProxy", "ManualReview"]: + await self.emulator_manager.close( + self.script_config.get("Emulator", "Index") + ) + write_back_user_config = True + finally: + async with Config.script_config_write_scope(self.script_info.script_id): + await Config.unlock_script_config(self.script_info.script_id) + if write_back_user_config: + await Config.ScriptConfig[ + uuid.UUID(self.script_info.script_id) + ].UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() logger.success(f"已解锁脚本配置 {self.script_info.script_id}") if self.task_info.mode in ["AutoProxy", "ManualReview"]: - - await self.emulator_manager.close( - self.script_config.get("Emulator", "Index") - ) - await Config.ScriptConfig[ - uuid.UUID(self.script_info.script_id) - ].UserData.load(await self.user_config.toDict()) - await Config.ScriptConfig.save() - error_user = [ u.name for u in self.script_info.user_list if u.status == "异常" ] diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index f65ca7b8e..fb031e705 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -98,7 +98,7 @@ async def check(self) -> str: async def prepare(self): # 锁定脚本配置并加载用户配置 - await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].lock() + await Config.lock_script_config(self.script_info.script_id) self.script_config = Config.ScriptConfig[uuid.UUID(self.script_info.script_id)] self.user_config = MultipleConfig([MaaEndUserConfig]) await self.user_config.load(await self.script_config.UserData.toDict()) @@ -175,20 +175,26 @@ async def final_task(self): return logger.info("MaaEnd 主任务已结束, 开始执行后续操作") - await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].unlock() + + write_back_user_config = False + try: + if self.task_info.mode in ["AutoProxy", "ManualReview"]: + if self.emulator_manager is not None: + await self.emulator_manager.close( + self.script_config.get("Game", "EmulatorIndex") + ) + write_back_user_config = True + finally: + async with Config.script_config_write_scope(self.script_info.script_id): + await Config.unlock_script_config(self.script_info.script_id) + if write_back_user_config: + await Config.ScriptConfig[ + uuid.UUID(self.script_info.script_id) + ].UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() logger.success(f"已解锁脚本配置 {self.script_info.script_id}") if self.task_info.mode in ["AutoProxy", "ManualReview"]: - - if self.emulator_manager is not None: - await self.emulator_manager.close( - self.script_config.get("Game", "EmulatorIndex") - ) - await Config.ScriptConfig[ - uuid.UUID(self.script_info.script_id) - ].UserData.load(await self.user_config.toDict()) - await Config.ScriptConfig.save() - error_user = [ u.name for u in self.script_info.user_list if u.status == "异常" ] diff --git a/app/task/SRC/manager.py b/app/task/SRC/manager.py index b76598638..4e4dce883 100644 --- a/app/task/SRC/manager.py +++ b/app/task/SRC/manager.py @@ -122,7 +122,7 @@ async def prepare(self): """运行前准备""" # 锁定脚本配置并加载用户配置 - await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].lock() + await Config.lock_script_config(self.script_info.script_id) self.script_config = Config.ScriptConfig[uuid.UUID(self.script_info.script_id)] self.user_config = MultipleConfig([SrcUserConfig]) await self.user_config.load(await self.script_config.UserData.toDict()) @@ -197,19 +197,25 @@ async def final_task(self): return self.check_result logger.info("SRC 主任务已结束, 开始执行后续操作") - await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].unlock() + + write_back_user_config = False + try: + if self.task_info.mode in ["AutoProxy", "ManualReview"]: + await self.emulator_manager.close( + self.script_config.get("Emulator", "Index") + ) + write_back_user_config = True + finally: + async with Config.script_config_write_scope(self.script_info.script_id): + await Config.unlock_script_config(self.script_info.script_id) + if write_back_user_config: + await Config.ScriptConfig[ + uuid.UUID(self.script_info.script_id) + ].UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() logger.success(f"已解锁脚本配置 {self.script_info.script_id}") if self.task_info.mode in ["AutoProxy", "ManualReview"]: - - await self.emulator_manager.close( - self.script_config.get("Emulator", "Index") - ) - await Config.ScriptConfig[ - uuid.UUID(self.script_info.script_id) - ].UserData.load(await self.user_config.toDict()) - await Config.ScriptConfig.save() - error_user = [ u.name for u in self.script_info.user_list if u.status == "异常" ] diff --git a/app/task/__init__.py b/app/task/__init__.py index 62e06a717..305c1c03a 100644 --- a/app/task/__init__.py +++ b/app/task/__init__.py @@ -26,7 +26,6 @@ __all__ = [ "MaaManager", "SrcManager", - "M9AManager", "GeneralManager", "MaaEndManager", ] @@ -34,7 +33,6 @@ _LAZY_EXPORTS = { "MaaManager": ("app.task.MAA.manager", "MaaManager"), "SrcManager": ("app.task.SRC.manager", "SrcManager"), - "M9AManager": ("app.task.M9A.manager", "M9AManager"), "GeneralManager": ("app.task.general.manager", "GeneralManager"), "MaaEndManager": ("app.task.MaaEnd.manager", "MaaEndManager"), } diff --git a/app/task/general/adapter.py b/app/task/general/adapter.py index a582a53c0..c768882a3 100644 --- a/app/task/general/adapter.py +++ b/app/task/general/adapter.py @@ -62,8 +62,9 @@ async def check(self, runtime: ScriptAdapterRuntime) -> str: async def prepare(self, runtime: ScriptAdapterRuntime) -> None: from app.core.emulator_manager import EmulatorManager + await runtime.storage.lock() + runtime.extra["_storage_lock_acquired"] = True storage_script_config = runtime.get_storage_script_config() - await storage_script_config.lock() script_config = await runtime.build_script_model() runtime.script_config = script_config @@ -151,36 +152,48 @@ async def finalize(self, runtime: ScriptAdapterRuntime) -> None: from app.services.notification import Notify from .tools.notify import push_notification + storage_lock_acquired = bool( + runtime.extra.pop("_storage_lock_acquired", False) + or runtime.storage.is_locked + ) if runtime.check_result != "Pass": + if storage_lock_acquired: + async with runtime.storage.write_transaction(): + await runtime.storage.unlock() runtime.script_info.status = "异常" return script_config = runtime.script_config storage_script_config = runtime.get_storage_script_config() if script_config is None: + if storage_lock_acquired: + async with runtime.storage.write_transaction(): + await runtime.storage.unlock() runtime.script_info.status = "异常" return logger.info("通用脚本任务已结束, 开始执行后续操作") - await storage_script_config.unlock() + async with runtime.storage.write_transaction(): + if storage_lock_acquired: + await runtime.storage.unlock() + if runtime.mode == "AutoProxy": + for user_uid, user_config in runtime.user_config.items(): + storage_user_config = storage_script_config.UserData[user_uid] + payload = await user_config.toDict(if_decrypt=False) + payload.pop("SubConfigsInfo", None) + await storage_user_config.set( + "PluginData", + "Config", + json.dumps(payload, ensure_ascii=False), + ) + await storage_user_config.set( + "Info", + "Name", + user_config.get("Info", "Name"), + ) logger.success(f"已解锁脚本配置 {runtime.script_info.script_id}") if runtime.mode == "AutoProxy": - for user_uid, user_config in runtime.user_config.items(): - storage_user_config = storage_script_config.UserData[user_uid] - payload = await user_config.toDict(if_decrypt=False) - payload.pop("SubConfigsInfo", None) - await storage_user_config.set( - "PluginData", - "Config", - json.dumps(payload, ensure_ascii=False), - ) - await storage_user_config.set( - "Info", - "Name", - user_config.get("Info", "Name"), - ) - error_user = [ user.name for user in runtime.script_info.user_list if user.status == "异常" ] diff --git a/app/task/general/manager.py b/app/task/general/manager.py index bae221db8..00df80494 100644 --- a/app/task/general/manager.py +++ b/app/task/general/manager.py @@ -134,7 +134,7 @@ async def prepare(self): """运行前准备""" # 锁定脚本配置并加载用户配置 - await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].lock() + await Config.lock_script_config(self.script_info.script_id) self.script_config = Config.ScriptConfig[uuid.UUID(self.script_info.script_id)] self.user_config = MultipleConfig([GeneralUserConfig]) await self.user_config.load(await self.script_config.UserData.toDict()) @@ -236,16 +236,17 @@ async def final_task(self): return self.check_result logger.info("通用脚本任务已结束, 开始执行后续操作") - await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].unlock() + + async with Config.script_config_write_scope(self.script_info.script_id): + await Config.unlock_script_config(self.script_info.script_id) + if self.task_info.mode == "AutoProxy": + await Config.ScriptConfig[ + uuid.UUID(self.script_info.script_id) + ].UserData.load(await self.user_config.toDict()) + await Config.ScriptConfig.save() logger.success(f"已解锁脚本配置 {self.script_info.script_id}") if self.task_info.mode == "AutoProxy": - - await Config.ScriptConfig[ - uuid.UUID(self.script_info.script_id) - ].UserData.load(await self.user_config.toDict()) - await Config.ScriptConfig.save() - error_user = [ u.name for u in self.script_info.user_list if u.status == "异常" ] diff --git a/app/utils/constants.py b/app/utils/constants.py index f2e486a2a..64d32000d 100644 --- a/app/utils/constants.py +++ b/app/utils/constants.py @@ -40,8 +40,6 @@ "SrcConfig": "SRC", "MaaEndConfig": "MaaEnd", "GeneralConfig": "通用", - "M9AConfig": "M9A", - "M9AUserConfig": "M9A", "MaaFWConfig": "MaaFramework 项目", "MaaFWUserConfig": "MaaFramework 项目", } diff --git a/frontend/electron/services/pluginBootstrapService.ts b/frontend/electron/services/pluginBootstrapService.ts index b58ef7999..9aa772e9a 100644 --- a/frontend/electron/services/pluginBootstrapService.ts +++ b/frontend/electron/services/pluginBootstrapService.ts @@ -135,15 +135,33 @@ export class PluginBootstrapService { if (!forceInstall && !checkResult.needsInstall) { const state = this.loadState() + const failedPackages = Array.isArray(state?.failedPackages) ? state.failedPackages : [] + const warnings = Array.isArray(state?.warnings) ? state.warnings : [] + if (failedPackages.length > 0 || warnings.length > 0) { + const error = + 'Plugin bootstrap state is incomplete; previous package failures or warnings require a retry' + logger.warn(error) + return { + success: false, + skipped: true, + installedPackages: Array.isArray(state?.installedPackages) + ? state.installedPackages + : [], + failedPackages, + warnings, + error, + summary: error, + } + } logger.info( 'Plugin bootstrap state is unchanged and system packages are present, skipping install' ) return { success: true, skipped: true, - installedPackages: state?.installedPackages || [], - failedPackages: state?.failedPackages || [], - warnings: state?.warnings || [], + installedPackages: Array.isArray(state?.installedPackages) ? state.installedPackages : [], + failedPackages, + warnings, summary: 'Plugin bootstrap state is unchanged, skipped', } } @@ -207,11 +225,13 @@ export class PluginBootstrapService { }, }) + const success = failedPackages.length === 0 return { - success: true, + success, installedPackages, failedPackages, warnings, + error: success ? undefined : summary, summary, } } catch (error) { @@ -235,15 +255,18 @@ export class PluginBootstrapService { const currentHash = this.calculateHash(allPackages) const lastState = this.loadState() const lastHash = lastState?.hash - const hasFailedPackages = (lastState?.failedPackages.length || 0) > 0 + const hasFailedPackages = (lastState?.failedPackages?.length || 0) > 0 + const hasWarnings = (lastState?.warnings?.length || 0) > 0 + const arePackagesInstalled = allPackages.every(item => this.isBootstrapPackageInstalled(item)) return { packages, currentHash, lastHash, needsInstall: - !this.areSystemPackagesInstalled() || + !arePackagesInstalled || hasFailedPackages || + hasWarnings || lastHash == null || lastHash !== currentHash, } @@ -468,25 +491,26 @@ export class PluginBootstrapService { private loadDeclaredPackageSpecs(): DeclaredBootstrapPackage[] { if (!fs.existsSync(this.pyprojectPath)) { - logger.warn( - `pyproject.toml does not exist, skipping declared plugin bootstrap packages: ${this.pyprojectPath}` - ) - return [] + const message = `Required plugin bootstrap declaration is missing: ${this.pyprojectPath}` + logger.error(message) + throw new Error(message) } try { const content = fs.readFileSync(this.pyprojectPath, 'utf-8') const sectionBody = this.extractBootstrapSection(content) if (sectionBody == null) { - logger.warn( - `Missing ${PYPROJECT_BOOTSTRAP_SECTION}, skipping declared plugin bootstrap packages` - ) - return [] + const message = `Required ${PYPROJECT_BOOTSTRAP_SECTION} declaration is missing` + logger.error(message) + throw new Error(message) } return this.extractDeclaredPackages(sectionBody) } catch (error) { - logger.warn(`Failed to read pyproject plugin bootstrap packages; using empty list: ${error}`) - return [] + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`Failed to read pyproject plugin bootstrap packages: ${errorMsg}`) + throw error instanceof Error + ? error + : new Error(`Failed to read pyproject plugin bootstrap packages: ${errorMsg}`) } } @@ -506,20 +530,30 @@ export class PluginBootstrapService { private extractDeclaredPackages(sectionBody: string): DeclaredBootstrapPackage[] { const packagesMatch = sectionBody.match(/^\s*packages\s*=\s*\[([\s\S]*?)\]/m) if (!packagesMatch) { - return [] + throw new Error( + `Required ${PYPROJECT_BOOTSTRAP_SECTION}.packages array is missing or malformed` + ) } const arrayBody = packagesMatch[1] const items = this.splitTopLevelArrayItems(arrayBody) + if (items.length === 0) { + throw new Error( + `Required ${PYPROJECT_BOOTSTRAP_SECTION}.packages array must contain at least one package` + ) + } const packages: DeclaredBootstrapPackage[] = [] const seen = new Set() for (const rawItem of items) { const parsed = this.parseDeclaredPackageItem(rawItem) if (parsed == null) { - continue + throw new Error(`Malformed plugin bootstrap package declaration: ${rawItem}`) } const dedupeKey = this.normalizeDistributionName(parsed.name) + if (!dedupeKey) { + throw new Error(`Plugin bootstrap package declaration has an empty name: ${rawItem}`) + } if (seen.has(dedupeKey)) { continue } @@ -527,6 +561,12 @@ export class PluginBootstrapService { packages.push(parsed) } + if (packages.length === 0) { + throw new Error( + `Required ${PYPROJECT_BOOTSTRAP_SECTION}.packages array contains no valid packages` + ) + } + return packages } @@ -605,7 +645,7 @@ export class PluginBootstrapService { (item.startsWith("'") && item.endsWith("'")) ) { const name = this.decodeTomlStringLiteral(item).trim() - if (!name) { + if (!name || !this.isValidDistributionName(name)) { return null } return { @@ -631,16 +671,20 @@ export class PluginBootstrapService { const entries = this.splitTopLevelArrayItems(body) const fields = new Map() + const allowedFields = new Set(['name', 'version', 'specifier']) for (const entry of entries) { const eqIndex = entry.indexOf('=') if (eqIndex <= 0) { - continue + return null } const key = entry.slice(0, eqIndex).trim() const rawValue = entry.slice(eqIndex + 1).trim() - if (!key || !rawValue) { - continue + if (!key || !rawValue || !allowedFields.has(key) || fields.has(key)) { + return null + } + if (!this.isTomlStringLiteral(rawValue)) { + return null } fields.set(key, this.decodeTomlStringLiteral(rawValue)) } @@ -649,11 +693,18 @@ export class PluginBootstrapService { const version = (fields.get('version') || '').trim() const specifier = (fields.get('specifier') || '').trim() - if (!name) { + if (!name || !this.isValidDistributionName(name)) { logger.warn(`Plugin bootstrap package object is missing name, skipped: ${rawTable}`) return null } + if ((fields.has('version') && !version) || (fields.has('specifier') && !specifier)) { + logger.warn( + `Plugin bootstrap package object has an empty version or specifier field, skipped: ${rawTable}` + ) + return null + } + if (version && specifier) { logger.warn( `Plugin bootstrap package declares both version and specifier; using specifier: ${name}` @@ -691,6 +742,18 @@ export class PluginBootstrapService { return value } + private isTomlStringLiteral(value: string): boolean { + const trimmed = value.trim() + return ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) + } + + private isValidDistributionName(name: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name.trim()) + } + private loadState(): PluginBootstrapState | null { try { if (!fs.existsSync(this.stateFilePath)) { @@ -782,9 +845,17 @@ export class PluginBootstrapService { return { success: false, error: result.error } } + const hasPluginEntryPoint = this.hasPluginEntryPoint(declaredPackage.name) + if (hasPluginEntryPoint && !this.isPackageVersionSatisfied(declaredPackage)) { + return { + success: false, + error: this.describeVersionValidationFailure(declaredPackage), + } + } + return { success: true, - hasPluginEntryPoint: this.hasPluginEntryPoint(declaredPackage.name), + hasPluginEntryPoint, } } @@ -851,70 +922,104 @@ export class PluginBootstrapService { }) } - private areSystemPackagesInstalled(): boolean { - return SYSTEM_BOOTSTRAP_PACKAGES.every(item => this.isSystemPackageInstalled(item)) + private isSystemPackageInstalled(systemPackage: DeclaredBootstrapPackage): boolean { + return this.isBootstrapPackageInstalled(systemPackage) } - private isSystemPackageInstalled(systemPackage: DeclaredBootstrapPackage): boolean { - if (!this.hasPluginEntryPoint(systemPackage.name)) { + private isBootstrapPackageInstalled(declaredPackage: DeclaredBootstrapPackage): boolean { + const matchedDistInfos = this.findDistributionInfoDirs(declaredPackage.name) + if (matchedDistInfos.length === 0) { return false } - const minimumVersion = this.parseMinimumVersion(systemPackage.specifier) - if (minimumVersion == null) { - return true - } + const exactVersion = this.getExactVersionRequirement(declaredPackage) + const minimumVersion = this.parseMinimumVersion(declaredPackage.specifier) - const installedVersion = this.getInstalledDistributionVersion(systemPackage.name) - if (installedVersion == null) { + if (exactVersion != null && matchedDistInfos.length !== 1) { return false } - return this.compareVersions(installedVersion, minimumVersion) >= 0 + return matchedDistInfos.some(distInfo => { + const installedVersion = this.getDistributionInfoVersion(distInfo, declaredPackage.name) + if (exactVersion != null && installedVersion !== exactVersion) { + return false + } + if ( + exactVersion == null && + minimumVersion != null && + (installedVersion == null || this.compareVersions(installedVersion, minimumVersion) < 0) + ) { + return false + } + return this.hasPluginEntryPointInDistribution(distInfo) + }) } - private hasPluginEntryPoint(packageName: string): boolean { - const matchedDistInfos = this.findDistributionInfoDirs(packageName) + private isPackageVersionSatisfied(declaredPackage: DeclaredBootstrapPackage): boolean { + const exactVersion = this.getExactVersionRequirement(declaredPackage) + const minimumVersion = this.parseMinimumVersion(declaredPackage.specifier) - for (const distInfo of matchedDistInfos) { - const entryPointsPath = path.join(this.pluginTargetDir, distInfo.name, 'entry_points.txt') - if (!fs.existsSync(entryPointsPath)) { - continue - } - const content = fs.readFileSync(entryPointsPath, 'utf-8') - if (ENTRY_POINT_GROUPS.some(group => content.includes(`[${group}]`))) { - return true - } + if (exactVersion == null && minimumVersion == null) { + return true } - return false + const matchedDistInfos = this.findDistributionInfoDirs(declaredPackage.name) + if (exactVersion != null && matchedDistInfos.length !== 1) { + return false + } + + return matchedDistInfos.some(distInfo => { + const installedVersion = this.getDistributionInfoVersion(distInfo, declaredPackage.name) + if (installedVersion == null) { + return false + } + if (exactVersion != null) { + return installedVersion === exactVersion && this.hasPluginEntryPointInDistribution(distInfo) + } + return ( + this.compareVersions(installedVersion, minimumVersion as string) >= 0 && + this.hasPluginEntryPointInDistribution(distInfo) + ) + }) } - private getInstalledDistributionVersion(packageName: string): string | null { - const distInfo = this.findDistributionInfoDirs(packageName)[0] - if (distInfo == null) { - return null + private getExactVersionRequirement(declaredPackage: DeclaredBootstrapPackage): string | null { + if (!declaredPackage.specifier && declaredPackage.version) { + return declaredPackage.version } - const metadataPath = path.join(this.pluginTargetDir, distInfo.name, 'METADATA') - if (fs.existsSync(metadataPath)) { - const metadata = fs.readFileSync(metadataPath, 'utf-8') - const versionMatch = metadata.match(/^Version:\s*(.+)$/m) - if (versionMatch) { - return versionMatch[1].trim() - } + if (!declaredPackage.specifier) { + return null } - const normalizedPackageName = this.normalizeDistributionName(packageName) - const normalizedDistName = this.normalizeDistributionName( - distInfo.name.replace(/\.dist-info$/i, '') - ) - const prefix = `${normalizedPackageName}_` - if (normalizedDistName.startsWith(prefix)) { - return normalizedDistName.slice(prefix.length) + const match = declaredPackage.specifier.match(/^\s*==\s*([A-Za-z0-9_.!+-]+)\s*$/) + return match?.[1] || null + } + + private describeVersionValidationFailure(declaredPackage: DeclaredBootstrapPackage): string { + const requirement = declaredPackage.specifier || `==${declaredPackage.version}` + return `Installed plugin package ${declaredPackage.name} does not satisfy declared version requirement ${requirement}` + } + + private hasPluginEntryPoint(packageName: string): boolean { + const matchedDistInfos = this.findDistributionInfoDirs(packageName) + + return matchedDistInfos.some(distInfo => this.hasPluginEntryPointInDistribution(distInfo)) + } + + private hasPluginEntryPointInDistribution(distInfo: fs.Dirent): boolean { + const entryPointsPath = path.join(this.pluginTargetDir, distInfo.name, 'entry_points.txt') + if (!fs.existsSync(entryPointsPath)) { + return false } - return null + try { + const content = fs.readFileSync(entryPointsPath, 'utf-8') + return ENTRY_POINT_GROUPS.some(group => content.includes(`[${group}]`)) + } catch (error) { + logger.warn(`Failed to read plugin entry point metadata: ${entryPointsPath}; ${error}`) + return false + } } private findDistributionInfoDirs(packageName: string): fs.Dirent[] { @@ -929,10 +1034,68 @@ export class PluginBootstrapService { return false } const distName = this.normalizeDistributionName(entry.name.replace(/\.dist-info$/i, '')) - return distName === normalizedPackageName || distName.startsWith(`${normalizedPackageName}_`) + if (distName === normalizedPackageName) { + return true + } + + const metadataName = this.getDistributionMetadataName(entry) + if (metadataName != null) { + return this.normalizeDistributionName(metadataName) === normalizedPackageName + } + + const prefix = `${normalizedPackageName}_` + if (!distName.startsWith(prefix)) { + return false + } + + return /^[v]?\d/.test(distName.slice(prefix.length)) }) } + private getDistributionMetadataName(distInfo: fs.Dirent): string | null { + const metadataPath = path.join(this.pluginTargetDir, distInfo.name, 'METADATA') + if (!fs.existsSync(metadataPath)) { + return null + } + + try { + const metadata = fs.readFileSync(metadataPath, 'utf-8') + const nameMatch = metadata.match(/^Name:\s*(.+)$/m) + return nameMatch?.[1]?.trim() || null + } catch (error) { + logger.warn(`Failed to read plugin distribution metadata: ${metadataPath}; ${error}`) + return null + } + } + + private getDistributionInfoVersion(distInfo: fs.Dirent, packageName: string): string | null { + const metadataPath = path.join(this.pluginTargetDir, distInfo.name, 'METADATA') + if (fs.existsSync(metadataPath)) { + try { + const metadata = fs.readFileSync(metadataPath, 'utf-8') + const versionMatch = metadata.match(/^Version:\s*(.+)$/m) + if (versionMatch?.[1]) { + return versionMatch[1].trim() + } + } catch (error) { + logger.warn(`Failed to read plugin distribution metadata: ${metadataPath}; ${error}`) + } + } + + const normalizedPackageName = this.normalizeDistributionName(packageName) + const normalizedDistName = this.normalizeDistributionName( + distInfo.name.replace(/\.dist-info$/i, '') + ) + const prefix = `${normalizedPackageName}_` + if (!normalizedDistName.startsWith(prefix)) { + return null + } + + const suffix = normalizedDistName.slice(prefix.length) + const versionMatch = suffix.match(/^[v]?\d+(?:_\d+)*(?:[a-z]+\d*)?/i) + return versionMatch?.[0]?.replace(/_/g, '.') || null + } + private parseMinimumVersion(specifier?: string): string | null { if (!specifier) { return null diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a086ac578..218605c29 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -65,15 +65,6 @@ export { HistorySearchIn } from './models/HistorySearchIn'; export type { HistorySearchOut } from './models/HistorySearchOut'; export type { HTTPValidationError } from './models/HTTPValidationError'; export type { InfoOut } from './models/InfoOut'; -export type { M9AConfig } from './models/M9AConfig'; -export type { M9AConfig_Emulator } from './models/M9AConfig_Emulator'; -export type { M9AConfig_Info } from './models/M9AConfig_Info'; -export type { M9AConfig_Run } from './models/M9AConfig_Run'; -export type { M9AUserConfig } from './models/M9AUserConfig'; -export type { M9AUserConfig_Data } from './models/M9AUserConfig_Data'; -export type { M9AUserConfig_Info } from './models/M9AUserConfig_Info'; -export type { M9AUserConfig_Notify } from './models/M9AUserConfig_Notify'; -export type { M9AUserConfig_Task } from './models/M9AUserConfig_Task'; export type { MaaConfig } from './models/MaaConfig'; export type { MaaConfig_Emulator } from './models/MaaConfig_Emulator'; export type { MaaConfig_Info } from './models/MaaConfig_Info'; diff --git a/frontend/src/api/models/M9AConfig.ts b/frontend/src/api/models/M9AConfig.ts deleted file mode 100644 index d3fd3cb72..000000000 --- a/frontend/src/api/models/M9AConfig.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { M9AConfig_Emulator } from './M9AConfig_Emulator'; -import type { M9AConfig_Info } from './M9AConfig_Info'; -import type { M9AConfig_Run } from './M9AConfig_Run'; -export type M9AConfig = { - /** - * 脚本基础信息 - */ - Info?: (M9AConfig_Info | null); - /** - * 模拟器配置 - */ - Emulator?: (M9AConfig_Emulator | null); - /** - * 脚本运行配置 - */ - Run?: (M9AConfig_Run | null); -}; - diff --git a/frontend/src/api/models/M9AConfig_Emulator.ts b/frontend/src/api/models/M9AConfig_Emulator.ts deleted file mode 100644 index 21b3b997a..000000000 --- a/frontend/src/api/models/M9AConfig_Emulator.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type M9AConfig_Emulator = { - /** - * 模拟器 ID - */ - Id?: (string | null); - /** - * 模拟器索引 - */ - Index?: (string | null); -}; - diff --git a/frontend/src/api/models/M9AConfig_Info.ts b/frontend/src/api/models/M9AConfig_Info.ts deleted file mode 100644 index 55ad13ab8..000000000 --- a/frontend/src/api/models/M9AConfig_Info.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type M9AConfig_Info = { - /** - * M9A 脚本名称 - */ - Name?: (string | null); - /** - * M9A 路径 - */ - Path?: (string | null); -}; - diff --git a/frontend/src/api/models/M9AConfig_Run.ts b/frontend/src/api/models/M9AConfig_Run.ts deleted file mode 100644 index 4ccb0d635..000000000 --- a/frontend/src/api/models/M9AConfig_Run.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type M9AConfig_Run = { - /** - * 代理次数限制 - */ - ProxyTimesLimit?: (number | null); - /** - * 运行次数限制 - */ - RunTimesLimit?: (number | null); - /** - * 运行时间限制(分钟) - */ - RunTimeLimit?: (number | null); - /** - * 是否在队列结束后自动更新M9A - */ - IfAutoUpdateAfterQueue?: (boolean | null); - /** - * 每日心相每日只执行一次 - */ - IfPsychubeDailyOnce?: (boolean | null); - /** - * 深眠浅梦每月只执行一次 - */ - IfSleepDreamMonthlyOnce?: (boolean | null); -}; - diff --git a/frontend/src/api/models/M9AUserConfig.ts b/frontend/src/api/models/M9AUserConfig.ts deleted file mode 100644 index fd07d3878..000000000 --- a/frontend/src/api/models/M9AUserConfig.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { M9AUserConfig_Data } from './M9AUserConfig_Data'; -import type { M9AUserConfig_Info } from './M9AUserConfig_Info'; -import type { M9AUserConfig_Notify } from './M9AUserConfig_Notify'; -import type { M9AUserConfig_Task } from './M9AUserConfig_Task'; -export type M9AUserConfig = { - /** - * 基础信息 - */ - Info?: (M9AUserConfig_Info | null); - /** - * 任务配置 - */ - Task?: (M9AUserConfig_Task | null); - /** - * 用户数据 - */ - Data?: (M9AUserConfig_Data | null); - /** - * 单独通知 - */ - Notify?: (M9AUserConfig_Notify | null); -}; - diff --git a/frontend/src/api/models/M9AUserConfig_Data.ts b/frontend/src/api/models/M9AUserConfig_Data.ts deleted file mode 100644 index be187dc8d..000000000 --- a/frontend/src/api/models/M9AUserConfig_Data.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type M9AUserConfig_Data = { - /** - * 上次代理日期 - */ - LastProxyDate?: (string | null); - /** - * 上次完成每日心相日期,格式 YYYY-MM-DD - */ - LastPsychubeDate?: (string | null); - /** - * 上次完成自动深眠月份,格式 YYYY-MM - */ - LastLimboMonth?: (string | null); - /** - * 上次完成自动醒梦月份,格式 YYYY-MM - */ - LastLucidscapeMonth?: (string | null); - /** - * 代理次数 - */ - ProxyTimes?: (number | null); - /** - * 是否通过检查 - */ - IfPassCheck?: (boolean | null); -}; - diff --git a/frontend/src/api/models/M9AUserConfig_Info.ts b/frontend/src/api/models/M9AUserConfig_Info.ts deleted file mode 100644 index c8619fe91..000000000 --- a/frontend/src/api/models/M9AUserConfig_Info.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type M9AUserConfig_Info = { - /** - * 用户名称 - */ - Name?: (string | null); - /** - * 是否启用 - */ - Status?: (boolean | null); - /** - * 剩余天数 - */ - RemainedDay?: (number | null); - /** - * 是否在任务前执行脚本 - */ - IfScriptBeforeTask?: (boolean | null); - /** - * 任务前脚本路径 - */ - ScriptBeforeTask?: (string | null); - /** - * 是否在任务后执行脚本 - */ - IfScriptAfterTask?: (boolean | null); - /** - * 任务后脚本路径 - */ - ScriptAfterTask?: (string | null); - /** - * 备注 - */ - Notes?: (string | null); - /** - * 用户标签信息 - */ - Tag?: (string | null); - /** - * 服务器资源名称 - */ - Resource?: (string | null); - /** - * 账号信息(用于切换账号,仅官服生效) - */ - Account?: (string | null); -}; - diff --git a/frontend/src/api/models/M9AUserConfig_Notify.ts b/frontend/src/api/models/M9AUserConfig_Notify.ts deleted file mode 100644 index 1fed60f2e..000000000 --- a/frontend/src/api/models/M9AUserConfig_Notify.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type M9AUserConfig_Notify = { - /** - * 是否启用通知 - */ - Enabled?: (boolean | null); - /** - * 是否发送统计信息 - */ - IfSendStatistic?: (boolean | null); - /** - * 是否发送邮件 - */ - IfSendMail?: (boolean | null); - /** - * 收件地址 - */ - ToAddress?: (string | null); - /** - * 是否启用 Server 酱 - */ - IfServerChan?: (boolean | null); - /** - * Server 酱密钥 - */ - ServerChanKey?: (string | null); -}; - diff --git a/frontend/src/api/models/M9AUserConfig_Task.ts b/frontend/src/api/models/M9AUserConfig_Task.ts deleted file mode 100644 index 1d62d8136..000000000 --- a/frontend/src/api/models/M9AUserConfig_Task.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type M9AUserConfig_Task = { - /** - * 可用任务列表 JSON 数组字符串或数组 - */ - AvailableTasks?: (string | null); - /** - * 运行任务队列 JSON 数组字符串或数组 - */ - Queue?: (string | null); -}; - diff --git a/frontend/src/api/models/ScriptCreateIn.ts b/frontend/src/api/models/ScriptCreateIn.ts index 6399797a5..1525099bf 100644 --- a/frontend/src/api/models/ScriptCreateIn.ts +++ b/frontend/src/api/models/ScriptCreateIn.ts @@ -4,7 +4,7 @@ /* eslint-disable */ export type ScriptCreateIn = { /** - * 脚本类型: MAA脚本, 通用脚本, OK-WW脚本, SRC脚本, MaaEnd脚本, M9A脚本, MaaFW脚本 + * 脚本类型: MAA脚本, 通用脚本, OK-WW脚本, SRC脚本, MaaEnd脚本, MaaFW脚本 */ type: ScriptCreateIn.type; /** @@ -14,7 +14,7 @@ export type ScriptCreateIn = { }; export namespace ScriptCreateIn { /** - * 脚本类型: MAA脚本, 通用脚本, OK-WW脚本, SRC脚本, MaaEnd脚本, M9A脚本, MaaFW脚本 + * 脚本类型: MAA脚本, 通用脚本, OK-WW脚本, SRC脚本, MaaEnd脚本, MaaFW脚本 */ export enum type { MAA = 'MAA', @@ -22,7 +22,6 @@ export namespace ScriptCreateIn { GENERAL = 'General', OKWW = 'Okww', MAA_END = 'MaaEnd', - M9A = 'M9A', MAA_FW = 'MaaFW', } } diff --git a/frontend/src/api/models/ScriptCreateOut.ts b/frontend/src/api/models/ScriptCreateOut.ts index a3302b29f..98f8bd51f 100644 --- a/frontend/src/api/models/ScriptCreateOut.ts +++ b/frontend/src/api/models/ScriptCreateOut.ts @@ -3,7 +3,6 @@ /* tslint:disable */ /* eslint-disable */ import type { GeneralConfig } from './GeneralConfig'; -import type { M9AConfig } from './M9AConfig'; import type { MaaConfig } from './MaaConfig'; import type { MaaEndConfig } from './MaaEndConfig'; import type { MaaFWConfig } from './MaaFWConfig'; @@ -30,6 +29,5 @@ export type ScriptCreateOut = { /** * 脚本配置数据 */ - data: (MaaConfig | SrcConfig | GeneralConfig | OkwwConfig | MaaEndConfig | M9AConfig | MaaFWConfig | PluginScriptConfig); + data: (MaaConfig | SrcConfig | GeneralConfig | OkwwConfig | MaaEndConfig | MaaFWConfig | PluginScriptConfig); }; - diff --git a/frontend/src/api/models/ScriptGetOut.ts b/frontend/src/api/models/ScriptGetOut.ts index 1d6077b6a..90171d82d 100644 --- a/frontend/src/api/models/ScriptGetOut.ts +++ b/frontend/src/api/models/ScriptGetOut.ts @@ -3,7 +3,6 @@ /* tslint:disable */ /* eslint-disable */ import type { GeneralConfig } from './GeneralConfig'; -import type { M9AConfig } from './M9AConfig'; import type { MaaConfig } from './MaaConfig'; import type { MaaEndConfig } from './MaaEndConfig'; import type { MaaFWConfig } from './MaaFWConfig'; @@ -31,6 +30,5 @@ export type ScriptGetOut = { /** * 脚本数据字典, key来自于index列表的uid */ - data: Record; + data: Record; }; - diff --git a/frontend/src/api/models/ScriptIndexItem.ts b/frontend/src/api/models/ScriptIndexItem.ts index 4767c74ab..d6dcb97e7 100644 --- a/frontend/src/api/models/ScriptIndexItem.ts +++ b/frontend/src/api/models/ScriptIndexItem.ts @@ -22,7 +22,6 @@ export namespace ScriptIndexItem { OKWW_CONFIG = 'OkwwConfig', SRC_CONFIG = 'SrcConfig', MAA_END_CONFIG = 'MaaEndConfig', - M9ACONFIG = 'M9AConfig', MAA_FWCONFIG = 'MaaFWConfig', PLUGIN_SCRIPT_CONFIG = 'PluginScriptConfig', } diff --git a/frontend/src/api/models/ScriptTypeDescriptor.ts b/frontend/src/api/models/ScriptTypeDescriptor.ts index 9633e372d..490ef4bd2 100644 --- a/frontend/src/api/models/ScriptTypeDescriptor.ts +++ b/frontend/src/api/models/ScriptTypeDescriptor.ts @@ -34,6 +34,10 @@ export type ScriptTypeDescriptor = { * 脚本类型是否显式声明了创建分组 */ create_group_declared?: boolean; + /** + * 是否允许用户创建此脚本类型 + */ + creatable?: boolean; /** * 文档地址 */ diff --git a/frontend/src/api/models/ScriptUpdateIn.ts b/frontend/src/api/models/ScriptUpdateIn.ts index 81d74a2ed..8645aebac 100644 --- a/frontend/src/api/models/ScriptUpdateIn.ts +++ b/frontend/src/api/models/ScriptUpdateIn.ts @@ -3,7 +3,6 @@ /* tslint:disable */ /* eslint-disable */ import type { GeneralConfig } from './GeneralConfig'; -import type { M9AConfig } from './M9AConfig'; import type { MaaConfig } from './MaaConfig'; import type { MaaEndConfig } from './MaaEndConfig'; import type { MaaFWConfig } from './MaaFWConfig'; @@ -18,6 +17,5 @@ export type ScriptUpdateIn = { /** * 脚本更新数据 */ - data: (MaaConfig | SrcConfig | GeneralConfig | OkwwConfig | MaaEndConfig | M9AConfig | MaaFWConfig | PluginScriptConfig); + data: (MaaConfig | SrcConfig | GeneralConfig | OkwwConfig | MaaEndConfig | MaaFWConfig | PluginScriptConfig); }; - diff --git a/frontend/src/api/models/UserCreateOut.ts b/frontend/src/api/models/UserCreateOut.ts index 06f895ea1..dcb623f1c 100644 --- a/frontend/src/api/models/UserCreateOut.ts +++ b/frontend/src/api/models/UserCreateOut.ts @@ -3,7 +3,6 @@ /* tslint:disable */ /* eslint-disable */ import type { GeneralUserConfig } from './GeneralUserConfig'; -import type { M9AUserConfig } from './M9AUserConfig'; import type { MaaEndUserConfig } from './MaaEndUserConfig'; import type { MaaFWUserConfig } from './MaaFWUserConfig'; import type { MaaUserConfig } from './MaaUserConfig'; @@ -30,6 +29,5 @@ export type UserCreateOut = { /** * 用户配置数据 */ - data: (MaaUserConfig | SrcUserConfig | GeneralUserConfig | OkwwUserConfig | MaaEndUserConfig | M9AUserConfig | MaaFWUserConfig | PluginUserConfig); + data: (MaaUserConfig | SrcUserConfig | GeneralUserConfig | OkwwUserConfig | MaaEndUserConfig | MaaFWUserConfig | PluginUserConfig); }; - diff --git a/frontend/src/api/models/UserGetOut.ts b/frontend/src/api/models/UserGetOut.ts index 7d02cc073..9fca8cc59 100644 --- a/frontend/src/api/models/UserGetOut.ts +++ b/frontend/src/api/models/UserGetOut.ts @@ -3,7 +3,6 @@ /* tslint:disable */ /* eslint-disable */ import type { GeneralUserConfig } from './GeneralUserConfig'; -import type { M9AUserConfig } from './M9AUserConfig'; import type { MaaEndUserConfig } from './MaaEndUserConfig'; import type { MaaFWUserConfig } from './MaaFWUserConfig'; import type { MaaUserConfig } from './MaaUserConfig'; @@ -31,6 +30,5 @@ export type UserGetOut = { /** * 用户数据字典, key来自于index列表的uid */ - data: Record; + data: Record; }; - diff --git a/frontend/src/api/models/UserIndexItem.ts b/frontend/src/api/models/UserIndexItem.ts index e742ce1d3..a7900a7b2 100644 --- a/frontend/src/api/models/UserIndexItem.ts +++ b/frontend/src/api/models/UserIndexItem.ts @@ -22,7 +22,6 @@ export namespace UserIndexItem { OKWW_USER_CONFIG = 'OkwwUserConfig', SRC_USER_CONFIG = 'SrcUserConfig', MAA_END_USER_CONFIG = 'MaaEndUserConfig', - M9AUSER_CONFIG = 'M9AUserConfig', MAA_FWUSER_CONFIG = 'MaaFWUserConfig', PLUGIN_USER_CONFIG = 'PluginUserConfig', } diff --git a/frontend/src/api/models/UserUpdateIn.ts b/frontend/src/api/models/UserUpdateIn.ts index e29a70820..647aeb501 100644 --- a/frontend/src/api/models/UserUpdateIn.ts +++ b/frontend/src/api/models/UserUpdateIn.ts @@ -3,7 +3,6 @@ /* tslint:disable */ /* eslint-disable */ import type { GeneralUserConfig } from './GeneralUserConfig'; -import type { M9AUserConfig } from './M9AUserConfig'; import type { MaaEndUserConfig } from './MaaEndUserConfig'; import type { MaaFWUserConfig } from './MaaFWUserConfig'; import type { MaaUserConfig } from './MaaUserConfig'; @@ -22,6 +21,5 @@ export type UserUpdateIn = { /** * 用户更新数据 */ - data: (MaaUserConfig | SrcUserConfig | GeneralUserConfig | OkwwUserConfig | MaaEndUserConfig | M9AUserConfig | MaaFWUserConfig | PluginUserConfig); + data: (MaaUserConfig | SrcUserConfig | GeneralUserConfig | OkwwUserConfig | MaaEndUserConfig | MaaFWUserConfig | PluginUserConfig); }; - diff --git a/frontend/src/api/services/Service.ts b/frontend/src/api/services/Service.ts index 881a0c100..566c471b3 100644 --- a/frontend/src/api/services/Service.ts +++ b/frontend/src/api/services/Service.ts @@ -1177,7 +1177,7 @@ export class Service { * 根据脚本类型键返回插件声明的图标资源。 * * icon_path 格式为 ``package_name:relative/path``,例如 - * ``automas_script_maafw_pack_m9a:assets/m9a.png``。 + * ``example_script_plugin:assets/icon.png``。 * @param typeKey * @returns any Successful Response * @throws ApiError diff --git a/frontend/src/assets/M9A.png b/frontend/src/assets/M9A.png deleted file mode 100644 index f4f7c304d..000000000 Binary files a/frontend/src/assets/M9A.png and /dev/null differ diff --git a/frontend/src/assets/satellite-icons/M9A.png b/frontend/src/assets/satellite-icons/M9A.png deleted file mode 100644 index f4f7c304d..000000000 Binary files a/frontend/src/assets/satellite-icons/M9A.png and /dev/null differ diff --git a/frontend/src/components/MaaFWConfigurationReusePanel.vue b/frontend/src/components/MaaFWConfigurationReusePanel.vue new file mode 100644 index 000000000..a5c382571 --- /dev/null +++ b/frontend/src/components/MaaFWConfigurationReusePanel.vue @@ -0,0 +1,563 @@ + + + + + diff --git a/frontend/src/composables/satellite-config.ts b/frontend/src/composables/satellite-config.ts index 3bebf9eea..539c0ae94 100644 --- a/frontend/src/composables/satellite-config.ts +++ b/frontend/src/composables/satellite-config.ts @@ -38,6 +38,9 @@ function getDeclaredIconUrl(descriptor: ScriptTypeDescriptor): string { } return iconUrl } + if (descriptor.is_builtin === false) { + return '' + } return getLocalIconUrl(legacyBuiltinIconFiles[descriptor.type_key] ?? '') } diff --git a/frontend/src/composables/useMaaFWApi.ts b/frontend/src/composables/useMaaFWApi.ts index 835725681..898aa435e 100644 --- a/frontend/src/composables/useMaaFWApi.ts +++ b/frontend/src/composables/useMaaFWApi.ts @@ -1,14 +1,13 @@ import { ref } from 'vue' import { message } from 'ant-design-vue' +import axios from 'axios' import { MaaFwService, OpenAPI, type MaaFWInterfacePreviewData as ApiMaaFWInterfacePreviewData, - type MaaFWProjectUpdateOut as ApiMaaFWProjectUpdateOut, type MaaFWTaskSnapshot as ApiMaaFWTaskSnapshot, type MaaFWWindowPreviewData as ApiMaaFWWindowPreviewData, } from '@/api' -import { request as apiRequest } from '@/api/core/request' import type { MaaFWControlCapabilitiesInfo, MaaFWAgentEnvPrepareData, @@ -21,6 +20,65 @@ import type { const logger = window.electronAPI.getLogger('MaaFW接口') +type PluginRouteEnvelope = { + code: number + status: string + message: string + data: T | null +} + +type MaaFWPluginProjectUpdateData = { + checked: boolean + updated: boolean + updateAvailable?: boolean + installable?: boolean + currentVersion: string + latestVersion?: string | null + source?: string | null + providerErrorCode?: number | null + logs?: string[] +} + +type MaaFWPluginAgentEnvPrepareData = { + path: string + agentCount?: number + agents?: MaaFWAgentEnvPrepareData['agents'] + logs?: string[] + status?: string + message?: string +} + +const MAAFW_PLUGIN_ROUTE_PREFIX = '/maafw' + +const resolvePluginRouteUrl = (path: string): string => { + const normalizedPath = path.startsWith('/') ? path : `/${path}` + const baseUrl = (OpenAPI.BASE || '').replace(/\/+$/, '') + return `${baseUrl}/plugin${normalizedPath}` +} + +type PluginHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' + +const requestPluginRoute = async ( + path: string, + payload: Record = {}, + method: PluginHttpMethod = 'POST' +): Promise> => { + try { + const response = await axios.request>({ + method, + url: resolvePluginRouteUrl(path), + params: method === 'GET' ? payload : undefined, + data: method === 'GET' ? undefined : payload, + }) + return response.data + } catch (error) { + if (axios.isAxiosError>(error) && error.response?.data) { + return error.response.data + } + throw error + } +} + export const buildMaaFWAssetUrl = (rootPath?: string, rawPath?: string | null) => { if (!rawPath || !rootPath) return '' if (/^(https?:|data:image\/)/i.test(rawPath)) return rawPath @@ -154,7 +212,7 @@ const normalizeWindowPreviewData = (data: ApiMaaFWWindowPreviewData): MaaFWWindo }) const normalizeAgentEnvPrepareData = ( - data: MaaFWAgentEnvPrepareData, + data: MaaFWPluginAgentEnvPrepareData, status?: string, responseMessage?: string ): MaaFWAgentEnvPrepareData => ({ @@ -166,13 +224,6 @@ const normalizeAgentEnvPrepareData = ( message: responseMessage || data.message, }) -type MaaFWAgentEnvPrepareOut = { - code?: number - status?: string - message?: string - data?: MaaFWAgentEnvPrepareData | null -} - export function useMaaFWApi() { const loading = ref(false) const error = ref(null) @@ -239,20 +290,20 @@ export function useMaaFWApi() { } } - const prepareAgentEnv = async (path: string): Promise => { + const prepareAgentEnv = async ( + path: string, + scriptId?: string + ): Promise => { loading.value = true error.value = null try { - const response = await apiRequest(OpenAPI, { - method: 'POST', - url: '/api/scripts/maafw/agent-env/prepare', - body: { path }, - mediaType: 'application/json', - errors: { - 422: 'Validation Error', - }, - }) + const payload: Record = { path } + if (scriptId) payload.scriptId = scriptId + const response = await requestPluginRoute( + `${MAAFW_PLUGIN_ROUTE_PREFIX}/agent-env/prepare`, + payload + ) if (response.code !== 200 || !response.data) { const errorMsg = response.message || '准备 MaaFW 运行环境失败' @@ -279,15 +330,17 @@ export function useMaaFWApi() { } const updateProjectResources = async ( - scriptId: string - ): Promise => { + scriptId: string, + apply = false + ): Promise | null> => { loading.value = true error.value = null try { - const response = await MaaFwService.updateMaafwProjectApiScriptsMaafwProjectUpdatePost({ - scriptId, - }) + const response = await requestPluginRoute( + `${MAAFW_PLUGIN_ROUTE_PREFIX}/project/update`, + { scriptId, apply } + ) if (response.code !== 200) { const errorMsg = response.message || 'MaaFW 项目更新失败' diff --git a/frontend/src/composables/useMaaFWConfigurationReuse.ts b/frontend/src/composables/useMaaFWConfigurationReuse.ts new file mode 100644 index 000000000..9fa1a783c --- /dev/null +++ b/frontend/src/composables/useMaaFWConfigurationReuse.ts @@ -0,0 +1,149 @@ +import { ref } from 'vue' +import axios from 'axios' +import { OpenAPI } from '@/api/core/OpenAPI' +import type { ScriptUserRecord } from '@/types/scriptRegistry' + +export type MaaFWConfigurationSource = { + sourceId: string + label: string + kind: string + path: string + selector?: Record + fingerprint: string + modifiedAt: string + summary?: { + taskCount?: number + controller?: string + resource?: string + hasGamePath?: boolean + hasAdbDevice?: boolean + } +} + +export type MaaFWConfigurationPlan = { + planId: string + schemaVersion: number + kind: string + target: 'project-and-first-user' | 'new-user' + sourceFingerprint?: string + summary: { + taskCount?: number + enabledTaskCount?: number + optionCount?: number + scriptFieldCount?: number + sourceUserName?: string + targetUserName?: string + } + warnings: string[] + manualActions: Array<{ + kind: string + blocking?: boolean + message: string + }> + orphans: Record + readyToApply: boolean + preview: { + sourceLabel: string + format: string + scriptFields: string[] + userName: string + taskCount: number + optionCount: number + gamePathPresent: boolean + adbDevicePresent: boolean + } + expiresAt: string +} + +export type MaaFWConfigurationApplyResult = { + applied: boolean + planId: string + target: 'project-and-first-user' | 'new-user' + createdUser: ScriptUserRecord + scriptUpdated: boolean +} + +type PluginResponse = { + code: number + status: string + message: string + data: T | null +} + +const post = async (path: string, payload: Record): Promise => { + const response = await axios.post>( + `${OpenAPI.BASE}/plugin/maafw/config-reuse${path}`, + payload + ) + const body = response.data + if (body.code !== 200 || body.data === null) { + throw new Error(body.message || 'MaaFW 配置复用操作失败') + } + return body.data +} + +export function useMaaFWConfigurationReuse() { + const loading = ref(false) + const error = ref(null) + + const run = async (operation: () => Promise): Promise => { + loading.value = true + error.value = null + try { + return await operation() + } catch (caught) { + error.value = caught instanceof Error ? caught.message : String(caught) + throw caught + } finally { + loading.value = false + } + } + + const discoverSources = (scriptId: string, sourcePath: string) => + run(async () => { + const result = await post<{ sources: MaaFWConfigurationSource[]; count: number }>( + '/sources', + { scriptId, sourcePath } + ) + return result.sources + }) + + const planExternal = ( + scriptId: string, + source: MaaFWConfigurationSource, + target: MaaFWConfigurationPlan['target'] + ) => + run(() => + post('/plan/external', { + scriptId, + source, + target, + }) + ) + + const planCopy = (scriptId: string, sourceUserId: string, targetName = '') => + run(() => + post('/plan/copy', { + scriptId, + sourceUserId, + targetName, + }) + ) + + const applyPlan = (scriptId: string, planId: string) => + run(() => + post('/apply', { + scriptId, + planId, + }) + ) + + return { + loading, + error, + discoverSources, + planExternal, + planCopy, + applyPlan, + } +} diff --git a/frontend/src/composables/useMaaFWScriptConfig.ts b/frontend/src/composables/useMaaFWScriptConfig.ts index 6ead1a947..8da2566a2 100644 --- a/frontend/src/composables/useMaaFWScriptConfig.ts +++ b/frontend/src/composables/useMaaFWScriptConfig.ts @@ -1,6 +1,6 @@ import { computed, reactive, ref } from 'vue' import { message } from 'ant-design-vue' -import { Service, type ComboBoxItem } from '@/api' +import { OpenAPI, Service, type ComboBoxItem } from '@/api' import { useMaaFWApi } from '@/composables/useMaaFWApi' import { useScriptRegistryApi } from '@/composables/useScriptRegistryApi' import { useSettingsApi } from '@/composables/useSettingsApi' @@ -15,6 +15,90 @@ import type { const logger = window.electronAPI.getLogger('MaaFW脚本编辑') +const MAAFW_PROJECT_UPDATE_PROGRESS_TYPE = 'maafw.project-update.progress' +const MAAFW_ENV_PREPARE_PROGRESS_TYPE = 'maafw.env-prepare.progress' +const MAAFW_PROGRESS_SOCKET_TIMEOUT_MS = 3000 + +type MaaFWProjectUpdateProgressData = { + scriptId?: string + phase?: string | null + final?: boolean + stage: string + status?: string + message?: string + provider_error_code?: number | null + version?: string | null + metadata_source?: string | null + package_source?: string | null + downloaded_bytes?: number | null + total_bytes?: number | null + percent?: number | null +} + +type MaaFWEnvPrepareProgressData = { + scriptId?: string + project_path?: string | null + stage: string + status?: string + message?: string + percent?: number | null + downloaded_bytes?: number | null + total_bytes?: number | null + logs?: string[] +} + +type MaaFWProgressEnvelope = { + id: string + type: typeof MAAFW_PROJECT_UPDATE_PROGRESS_TYPE | typeof MAAFW_ENV_PREPARE_PROGRESS_TYPE + data: MaaFWProjectUpdateProgressData | MaaFWEnvPrepareProgressData +} + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const parseMaaFWProgressEnvelope = (value: unknown): MaaFWProgressEnvelope | null => { + if (!isRecord(value) || typeof value.id !== 'string' || typeof value.type !== 'string') { + return null + } + if (!isRecord(value.data) || !value.id.trim() || typeof value.data.stage !== 'string') return null + if ( + value.type !== MAAFW_PROJECT_UPDATE_PROGRESS_TYPE && + value.type !== MAAFW_ENV_PREPARE_PROGRESS_TYPE + ) { + return null + } + return { + id: value.id, + type: value.type, + data: value.data as MaaFWProgressEnvelope['data'], + } +} + +const toWebSocketOrigin = (value: string): string => { + const raw = value.trim() + if (!raw) return '' + try { + const parsed = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`) + const protocol = parsed.protocol === 'https:' || parsed.protocol === 'wss:' ? 'wss:' : 'ws:' + return `${protocol}//${parsed.host}` + } catch { + return '' + } +} + +const resolveMaaFWProgressSocketUrl = async (): Promise => { + let endpoint = '' + try { + endpoint = (await window.electronAPI?.getApiEndpoint('websocket')) || '' + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.warn(`获取 MaaFW WebSocket 端点失败,将回退 HTTP/OpenAPI 基础地址: ${errorMsg}`) + } + const origin = toWebSocketOrigin(endpoint || OpenAPI.BASE || window.location.origin) + if (!origin) throw new Error('无法解析 MaaFW WebSocket 基础地址') + return `${origin}/plugin/maafw/progress` +} + export type EmulatorType = 'general' | 'mumu' | 'ldplayer' const EMULATOR_TYPE_LABELS: Record = { @@ -23,13 +107,48 @@ const EMULATOR_TYPE_LABELS: Record = { ldplayer: '雷电模拟器', } -type MaaFWConcreteUpdateSource = Exclude +type MaaFWProjectUpdateSource = MaaFWScriptConfig['Update']['Source'] type MaaFWConcreteUpdateChannel = Exclude -const MAAFW_UPDATE_SOURCES: MaaFWConcreteUpdateSource[] = ['MirrorChyan', 'GitHub'] +const MAAFW_UPDATE_SOURCES: MaaFWProjectUpdateSource[] = ['', 'MirrorChyan', 'GitHub'] const MAAFW_UPDATE_CHANNELS: MaaFWConcreteUpdateChannel[] = ['stable', 'beta'] const MAAFW_DIRECT_CONTROLLER_TYPES = ['Adb', 'Win32'] as const +export type MaaFWProjectUpdateStatus = 'idle' | 'running' | 'completed' | 'failed' +export type MaaFWProjectUpdateAction = 'check' | 'apply' +export type MaaFWAgentEnvProgressStatus = 'idle' | 'running' | 'completed' | 'failed' + +const PROJECT_UPDATE_STAGE_LABELS: Record = { + checking: '正在检查可用版本', + downloading: '正在下载项目资源', + validating: '正在校验下载内容', + extracting: '正在解压项目资源', + switching: '正在切换项目版本', + preparing_environment: '正在准备运行环境', + completed: '项目更新已完成', + failed: '项目更新失败', +} + +const PROJECT_UPDATE_STAGE_PROGRESS: Record = { + checking: 5, + validating: 75, + extracting: 82, + switching: 90, + preparing_environment: 95, + completed: 100, +} + +const AGENT_ENV_STAGE_LABELS: Record = { + checking: '正在检查运行环境', + resolving: '正在解析 Agent 运行要求', + preparing_runtime: '正在准备 Runner 运行环境', + creating_venv: '正在创建隔离 Python 环境', + installing_dependencies: '正在安装 Agent 依赖', + preparing_agents: '正在准备项目 Agent', + completed: '运行环境准备完成', + failed: '运行环境准备失败', +} + type MaaFWDirectControllerType = (typeof MAAFW_DIRECT_CONTROLLER_TYPES)[number] export const isDirectControllerType = ( @@ -55,13 +174,17 @@ export const getAgentRuntimeColor = (runtimeKind?: string | null) => { return 'default' } -const isMaaFWUpdateSource = (value: string): value is MaaFWConcreteUpdateSource => - MAAFW_UPDATE_SOURCES.includes(value as MaaFWConcreteUpdateSource) +const isMaaFWUpdateSource = (value: string): value is MaaFWProjectUpdateSource => + MAAFW_UPDATE_SOURCES.includes(value as MaaFWProjectUpdateSource) const isMaaFWUpdateChannel = (value: string): value is MaaFWConcreteUpdateChannel => MAAFW_UPDATE_CHANNELS.includes(value as MaaFWConcreteUpdateChannel) -const updateSourceOptions = MAAFW_UPDATE_SOURCES.map(value => ({ label: value, value })) +const updateSourceOptions = [ + { label: '自动', value: '' }, + { label: 'MirrorChyan', value: 'MirrorChyan' }, + { label: 'GitHub', value: 'GitHub' }, +] satisfies Array<{ label: string; value: MaaFWProjectUpdateSource }> const updateChannelOptions = [ { label: '稳定版', value: 'stable' as MaaFWConcreteUpdateChannel }, @@ -101,7 +224,7 @@ const getDefaultMaaFWScriptConfig = (): MaaFWScriptConfig => ({ }, Update: { IfAutoUpdate: true, - Source: 'MirrorChyan', + Source: '', Channel: 'stable', MirrorChyanCDK: '', GitHubRepo: '', @@ -147,21 +270,49 @@ export function useMaaFWScriptConfig(scriptId: string) { } | null>(null) const previewData = ref(null) const agentEnvResult = ref(null) + const agentEnvProgressStatus = ref('idle') + const agentEnvProgressStage = ref('') + const agentEnvProgressPercent = ref(null) + const agentEnvProgressMessage = ref('') + const agentEnvProgressLogs = ref([]) + const agentEnvProgressDownloadedBytes = ref(null) + const agentEnvProgressTotalBytes = ref(null) const projectUpdateLogs = ref([]) + const projectUpdateAction = ref('check') + const projectUpdateStatus = ref('idle') + const projectUpdateStage = ref('') + const projectUpdateProgress = ref(null) + const projectUpdateDownloadPercent = ref(null) + const projectUpdateDownloadedBytes = ref(null) + const projectUpdateTotalBytes = ref(null) + const projectUpdateMessage = ref('') + const projectUpdateProviderErrorCode = ref(null) + const projectUpdateDiscoveredVersion = ref('') + const projectUpdateMetadataSource = ref('') + const projectUpdatePackageSource = ref('') const scriptEditHint = ref(null) const scriptIconUrl = ref(null) const dailyOnceTasks = ref([]) const weeklyOnceTasks = ref([]) const monthlyOnceTasks = ref([]) - const globalUpdateSource = ref('') const globalUpdateChannel = ref('') + const globalMirrorChyanCDK = ref('') let saveStatusTimer: ReturnType | null = null + let maaFWProgressSocket: WebSocket | null = null + let maaFWProgressSocketPromise: Promise | null = null + let maaFWProgressSocketGeneration = 0 + let agentEnvPrepareRequest: { path: string; promise: Promise } | null = null const emulatorLoading = ref(false) + const emulatorOptionsReady = ref(false) const emulatorDeviceLoading = ref(false) const emulatorOptions = ref([]) const emulatorDeviceOptions = ref([]) const emulatorTypeById = ref>({}) + let emulatorOptionsLoaded = false + let emulatorOptionsPromise: Promise | null = null + const emulatorDeviceOptionsCache = new Map() + const emulatorDeviceRequests = new Map>() const maafwConfig = reactive(getDefaultMaaFWScriptConfig()) @@ -195,6 +346,21 @@ export function useMaaFWScriptConfig(scriptId: string) { () => Boolean(agentEnvResult.value) && agentEnvResult.value?.status !== 'error' ) const isAgentEnvFailed = computed(() => agentEnvResult.value?.status === 'error') + const isAgentEnvPreparing = computed( + () => agentEnvProgressStatus.value === 'running' || agentEnvLoading.value + ) + + const hasEffectiveMirrorChyanCDK = computed( + () => + Boolean(String(maafwConfig.Update.MirrorChyanCDK || '').trim()) || + Boolean(String(globalMirrorChyanCDK.value || '').trim()) + ) + const projectUpdateMirrorSourceBlocked = computed( + () => maafwConfig.Update.Source === 'MirrorChyan' && !hasEffectiveMirrorChyanCDK.value + ) + const isProjectUpdateRunning = computed( + () => projectUpdateStatus.value === 'running' || projectUpdateLoading.value + ) const projectUpdateDisabled = computed( () => @@ -202,8 +368,10 @@ export function useMaaFWScriptConfig(scriptId: string) { !previewData.value || isAutoUpdateDisabled.value || isSaving.value || + hasUnsavedChanges.value || interfaceLoading.value || - projectUpdateLoading.value + isAgentEnvPreparing.value || + isProjectUpdateRunning.value ) const periodTaskOptions = computed(() => @@ -281,12 +449,10 @@ export function useMaaFWScriptConfig(scriptId: string) { return resolveProjectScriptName(data) } - const resolveUpdateSource = (value?: string | null): MaaFWConcreteUpdateSource => { - if (value && isMaaFWUpdateSource(value)) return value - if (globalUpdateSource.value && isMaaFWUpdateSource(globalUpdateSource.value)) { - return globalUpdateSource.value - } - return MAAFW_UPDATE_SOURCES[0] + const resolveUpdateSource = (value?: string | null): MaaFWProjectUpdateSource => { + const source = value ?? '' + if (isMaaFWUpdateSource(source)) return source + return '' } const resolveUpdateChannel = (value?: string | null): MaaFWConcreteUpdateChannel => { @@ -377,6 +543,9 @@ export function useMaaFWScriptConfig(scriptId: string) { } hasUnsavedChanges.value = true + if (category === 'Update' || (category === 'Info' && key === 'Path')) { + projectUpdateAction.value = 'check' + } setSaveStatus('saving') isSaving.value = true try { @@ -646,9 +815,280 @@ export function useMaaFWScriptConfig(scriptId: string) { await handleChange('Info', 'ProjectLabel', nextLabel, true) } + const toProgressNumber = (value: number | null | undefined): number | null => { + if (typeof value !== 'number' || !Number.isFinite(value)) return null + return value + } + + const handleProjectUpdateProgress = (data: MaaFWProjectUpdateProgressData) => { + if (data.scriptId && data.scriptId !== scriptId) return + + const stage = String(data.stage || '') + const phase = String(data.phase || '') + const status = String(data.status || '').toLowerCase() + const isFinal = data.final === true + const terminalEvent = isFinal || stage === 'failed' + if ( + !terminalEvent && + !projectUpdateLoading.value && + (projectUpdateStatus.value === 'completed' || projectUpdateStatus.value === 'failed') + ) { + return + } + const percent = toProgressNumber(data.percent) + const downloadedBytes = toProgressNumber(data.downloaded_bytes) + const totalBytes = toProgressNumber(data.total_bytes) + + projectUpdateStage.value = + phase === 'preparing_environment' && stage !== 'completed' && stage !== 'failed' + ? `正在准备运行环境${data.message ? ` · ${data.message}` : ''}` + : PROJECT_UPDATE_STAGE_LABELS[stage] || data.message || stage || '正在更新项目资源' + projectUpdateMessage.value = data.message || '' + if (typeof data.provider_error_code === 'number') { + projectUpdateProviderErrorCode.value = data.provider_error_code + } + if (data.version) projectUpdateDiscoveredVersion.value = data.version + if (data.metadata_source) projectUpdateMetadataSource.value = data.metadata_source + if (data.package_source) projectUpdatePackageSource.value = data.package_source + if (status === 'version_discovered' && data.version) { + projectUpdateStage.value = `已发现版本 ${data.version}` + } + if (phase === 'preparing_environment' && stage !== 'completed' && stage !== 'failed') { + projectUpdateProgress.value = 95 + } else if (stage === 'preparing_environment') { + projectUpdateProgress.value = percent === null ? 95 : Math.min(Math.max(percent, 0), 100) + } else if (stage === 'downloading') { + projectUpdateDownloadPercent.value = + percent === null ? null : Math.min(Math.max(percent, 0), 100) + projectUpdateProgress.value = + projectUpdateDownloadPercent.value === null + ? null + : 10 + projectUpdateDownloadPercent.value * 0.6 + } else if (stage === 'completed' && !isFinal) { + projectUpdateProgress.value = Math.min(Math.max(projectUpdateProgress.value ?? 90, 90), 95) + } else if (stage in PROJECT_UPDATE_STAGE_PROGRESS) { + projectUpdateProgress.value = PROJECT_UPDATE_STAGE_PROGRESS[stage] + } else if (percent !== null) { + projectUpdateProgress.value = Math.min(Math.max(percent, 0), 100) + } + if (downloadedBytes !== null) projectUpdateDownloadedBytes.value = downloadedBytes + if (totalBytes !== null) projectUpdateTotalBytes.value = totalBytes + + if (stage === 'failed' || status === 'failed' || status === 'error') { + projectUpdateStatus.value = 'failed' + return + } + if (isFinal) { + projectUpdateStatus.value = 'completed' + projectUpdateProgress.value = 100 + return + } + projectUpdateStatus.value = 'running' + } + + const normalizeProgressPath = (value: string) => + value.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase() + + const handleAgentEnvProgress = (data: MaaFWEnvPrepareProgressData) => { + if (data.scriptId && data.scriptId !== scriptId) return + if ( + data.project_path && + maafwConfig.Info.Path && + normalizeProgressPath(data.project_path) !== normalizeProgressPath(maafwConfig.Info.Path) + ) { + return + } + + const stage = String(data.stage || '') + const status = String(data.status || '').toLowerCase() + const terminalEvent = stage === 'completed' || stage === 'failed' + if ( + !terminalEvent && + !agentEnvLoading.value && + (agentEnvProgressStatus.value === 'completed' || agentEnvProgressStatus.value === 'failed') + ) { + return + } + + const percent = toProgressNumber(data.percent) + const downloadedBytes = toProgressNumber(data.downloaded_bytes) + const totalBytes = toProgressNumber(data.total_bytes) + agentEnvProgressStage.value = + AGENT_ENV_STAGE_LABELS[stage] || data.message || stage || '正在准备 MaaFW 运行环境' + agentEnvProgressMessage.value = data.message || '' + if (percent !== null) { + agentEnvProgressPercent.value = Math.min(Math.max(percent, 0), 100) + } + if (downloadedBytes !== null) agentEnvProgressDownloadedBytes.value = downloadedBytes + if (totalBytes !== null) agentEnvProgressTotalBytes.value = totalBytes + if (Array.isArray(data.logs)) agentEnvProgressLogs.value = data.logs.map(String) + + if (stage === 'failed' || status === 'failed' || status === 'error') { + agentEnvProgressStatus.value = 'failed' + return + } + if (stage === 'completed' || status === 'completed' || status === 'ready') { + agentEnvProgressStatus.value = 'completed' + agentEnvProgressPercent.value = 100 + return + } + agentEnvProgressStatus.value = 'running' + } + + const handleMaaFWProgressMessage = (event: MessageEvent) => { + if (maaFWProgressSocket === null) return + let parsed: unknown + try { + parsed = JSON.parse(String(event.data)) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.warn(`解析 MaaFW 插件进度消息失败: ${errorMsg}`) + return + } + + const envelope = parseMaaFWProgressEnvelope(parsed) + if (!envelope) { + logger.warn('收到无效的 MaaFW 插件进度消息,已忽略') + return + } + const dataScriptId = envelope.data.scriptId + if (envelope.id !== scriptId && dataScriptId !== scriptId) return + + if (envelope.type === MAAFW_PROJECT_UPDATE_PROGRESS_TYPE) { + handleProjectUpdateProgress(envelope.data as MaaFWProjectUpdateProgressData) + } else { + handleAgentEnvProgress(envelope.data as MaaFWEnvPrepareProgressData) + } + } + + const ensureMaaFWProgressSocket = async (): Promise => { + if (maaFWProgressSocket?.readyState === WebSocket.OPEN) return true + if (maaFWProgressSocketPromise) return maaFWProgressSocketPromise + + const generation = maaFWProgressSocketGeneration + const pending = (async () => { + let url: string + try { + url = await resolveMaaFWProgressSocketUrl() + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.warn(`准备 MaaFW 插件 WebSocket 失败: ${errorMsg}`) + return false + } + + return await new Promise(resolve => { + let settled = false + let timeoutId: number | undefined + const settle = (connected: boolean) => { + if (settled) return + settled = true + if (timeoutId !== undefined) window.clearTimeout(timeoutId) + resolve(connected) + } + + let socket: WebSocket + try { + socket = new WebSocket(url) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.warn(`创建 MaaFW 插件 WebSocket 失败: ${errorMsg}`) + settle(false) + return + } + maaFWProgressSocket = socket + timeoutId = window.setTimeout(() => { + logger.warn('MaaFW 插件 WebSocket 连接超时') + try { + socket.close(1000, '连接超时') + } catch { + // 忽略已关闭连接 + } + settle(false) + }, MAAFW_PROGRESS_SOCKET_TIMEOUT_MS) + + socket.onopen = () => { + if (generation !== maaFWProgressSocketGeneration || maaFWProgressSocket !== socket) { + try { + socket.close(1000, '编辑器已销毁') + } catch { + // 忽略 + } + settle(false) + return + } + settle(true) + } + socket.onmessage = handleMaaFWProgressMessage + socket.onerror = () => { + if (maaFWProgressSocket !== socket) return + logger.warn('MaaFW 插件 WebSocket 发生错误') + settle(false) + } + socket.onclose = () => { + if (maaFWProgressSocket === socket) maaFWProgressSocket = null + settle(false) + } + }) + })() + maaFWProgressSocketPromise = pending + try { + return await pending + } finally { + if (maaFWProgressSocketPromise === pending) maaFWProgressSocketPromise = null + } + } + + const closeMaaFWProgressSocket = () => { + maaFWProgressSocketGeneration += 1 + const socket = maaFWProgressSocket + maaFWProgressSocket = null + maaFWProgressSocketPromise = null + if (socket && socket.readyState !== WebSocket.CLOSED) { + try { + socket.close(1000, '编辑器已销毁') + } catch { + // 忽略已关闭连接 + } + } + } + + const resetProjectUpdateProgress = () => { + projectUpdateStatus.value = 'running' + projectUpdateStage.value = '正在准备更新检查' + projectUpdateProgress.value = null + projectUpdateDownloadPercent.value = null + projectUpdateDownloadedBytes.value = null + projectUpdateTotalBytes.value = null + projectUpdateMessage.value = '' + projectUpdateProviderErrorCode.value = null + projectUpdateDiscoveredVersion.value = '' + projectUpdateMetadataSource.value = '' + projectUpdatePackageSource.value = '' + projectUpdateLogs.value = [] + } + + const markProjectUpdateFailed = (reason: string) => { + projectUpdateStatus.value = 'failed' + projectUpdateAction.value = 'check' + projectUpdateStage.value = '项目更新失败' + projectUpdateMessage.value = reason + } + + const clearAgentEnvUiState = () => { + agentEnvResult.value = null + agentEnvProgressStatus.value = 'idle' + agentEnvProgressStage.value = '' + agentEnvProgressPercent.value = null + agentEnvProgressMessage.value = '' + agentEnvProgressLogs.value = [] + agentEnvProgressDownloadedBytes.value = null + agentEnvProgressTotalBytes.value = null + } + // ---- Action handlers ---- const handlePreviewInterface = async () => { + if (isAgentEnvPreparing.value || isProjectUpdateRunning.value) return if (!maafwConfig.Info.Path) { message.warning('请先选择 MaaFramework 项目目录') return @@ -662,29 +1102,80 @@ export function useMaaFWScriptConfig(scriptId: string) { syncControllerResourceSelection(!isInitializing.value) await prunePeriodTaskSelections() message.success(`已读取 ${previewProjectTitle.value}`) + await handlePrepareAgentEnv() } } const handlePrepareAgentEnv = async () => { + if (isProjectUpdateRunning.value) return if (!maafwConfig.Info.Path) { message.warning('请先选择 MaaFramework 项目目录') return } + const targetPath = maafwConfig.Info.Path + if (agentEnvPrepareRequest) { + const activeRequest = agentEnvPrepareRequest + await activeRequest.promise + if (activeRequest.path === targetPath || maafwConfig.Info.Path !== targetPath) { + return + } + } + agentEnvResult.value = null - const data = await prepareAgentEnv(maafwConfig.Info.Path) - if (!data) return + await ensureMaaFWProgressSocket() + agentEnvProgressStatus.value = 'running' + agentEnvProgressStage.value = '正在准备 MaaFW 运行环境' + agentEnvProgressPercent.value = null + agentEnvProgressMessage.value = '正在检查 Runner 与项目 Agent 依赖' + agentEnvProgressLogs.value = [] + agentEnvProgressDownloadedBytes.value = null + agentEnvProgressTotalBytes.value = null + const promise = (async () => { + const data = await prepareAgentEnv(targetPath, scriptId) + if (maafwConfig.Info.Path !== targetPath) return + + if (!data) { + agentEnvResult.value = { + path: targetPath, + agentCount: 0, + agents: [], + logs: [], + status: 'error', + message: 'MaaFW 运行环境准备失败', + } + agentEnvProgressStatus.value = 'failed' + agentEnvProgressStage.value = '运行环境准备失败' + agentEnvProgressMessage.value = 'MaaFW 运行环境准备失败' + return + } - agentEnvResult.value = data - if (data.status === 'error') { - message.error(data.message || 'MaaFW 运行环境准备失败') - return - } - if (data.agentCount === 0) { - message.info('MaaFW Runner 环境已准备完成,当前项目没有声明 Agent') - return + agentEnvResult.value = data + agentEnvProgressLogs.value = [...data.logs] + if (data.status === 'error') { + agentEnvProgressStatus.value = 'failed' + agentEnvProgressStage.value = '运行环境准备失败' + agentEnvProgressMessage.value = data.message || 'MaaFW 运行环境准备失败' + message.error(data.message || 'MaaFW 运行环境准备失败') + return + } + agentEnvProgressStatus.value = 'completed' + agentEnvProgressStage.value = '运行环境准备完成' + agentEnvProgressPercent.value = 100 + agentEnvProgressMessage.value = data.message || 'MaaFW Runner 与 Agent 环境已就绪' + if (data.agentCount === 0) { + message.info('MaaFW Runner 环境已准备完成,当前项目没有声明 Agent') + return + } + message.success(`MaaFW 运行环境已准备完成,共 ${data.agentCount} 个 Agent`) + })() + const request = { path: targetPath, promise } + agentEnvPrepareRequest = request + try { + await promise + } finally { + if (agentEnvPrepareRequest === request) agentEnvPrepareRequest = null } - message.success(`MaaFW 运行环境已准备完成,共 ${data.agentCount} 个 Agent`) } const handleManualProjectUpdate = async () => { @@ -700,34 +1191,99 @@ export function useMaaFWScriptConfig(scriptId: string) { message.warning('当前脚本未声明版本,无法判断更新') return } - if (isSaving.value || projectUpdateLoading.value) return + if ( + isSaving.value || + hasUnsavedChanges.value || + isAgentEnvPreparing.value || + isProjectUpdateRunning.value + ) { + return + } - projectUpdateLogs.value = [] - isSaving.value = true + await ensureMaaFWProgressSocket() + const applyUpdate = projectUpdateAction.value === 'apply' + resetProjectUpdateProgress() try { - const saved = await updateScriptConfig({ - Update: { ...maafwConfig.Update }, - }) - if (!saved) return - } finally { - isSaving.value = false - } + projectUpdateStage.value = applyUpdate ? '正在检查并应用项目资源更新' : '正在检查项目资源更新' + const response = await updateProjectResources(scriptId, applyUpdate) + projectUpdateLogs.value = response?.data?.logs ?? [] + const updateData = response?.data + projectUpdateProviderErrorCode.value = + typeof updateData?.providerErrorCode === 'number' ? updateData.providerErrorCode : null + if (updateData?.latestVersion) { + projectUpdateDiscoveredVersion.value = updateData.latestVersion + } + if (updateData?.source && (updateData.updated || updateData.installable)) { + projectUpdatePackageSource.value = updateData.source + } + + if (updateData?.updated) { + const updatedWithWarning = response?.code !== 200 || response?.status !== 'success' + projectUpdateAction.value = 'check' + clearAgentEnvUiState() + projectUpdateStage.value = '项目已更新,正在刷新 interface' + const refreshed = await refreshPreviewIfPossible(true, updateData.latestVersion) + projectUpdateStatus.value = 'completed' + projectUpdateStage.value = updatedWithWarning + ? '项目资源已更新,运行环境需要处理' + : refreshed + ? '项目资源与 interface 已刷新' + : '项目资源已更新,interface 已按返回版本刷新' + projectUpdateProgress.value = 100 + projectUpdateMessage.value = response?.message || 'MaaFW 项目资源已更新' + if (updatedWithWarning) { + message.warning(projectUpdateMessage.value) + } else { + message.success('MaaFW 项目资源已更新') + } + return + } - const response = await updateProjectResources(scriptId) - projectUpdateLogs.value = response?.data?.logs ?? [] - if (!response?.data || response.code !== 200) return + if (!response || response.code !== 200 || !updateData) { + markProjectUpdateFailed(response?.message || 'MaaFW 项目更新失败') + return + } - await refreshPreviewIfPossible() - if (response.data.updated) { - message.success(response.message || 'MaaFW 项目资源已更新') - return - } + if (!applyUpdate && updateData.checked) { + projectUpdateAction.value = + updateData.updateAvailable && updateData.installable ? 'apply' : 'check' + await refreshPreviewIfPossible() + projectUpdateStatus.value = 'completed' + projectUpdateStage.value = updateData.updateAvailable + ? updateData.installable + ? '已发现可安装更新,请再次点击“开始更新”' + : '已发现更新,但当前来源没有可安装包' + : '项目更新检查已完成' + projectUpdateProgress.value = 100 + projectUpdateMessage.value = response.message || 'MaaFW 项目更新检查已完成' + message.info(projectUpdateMessage.value) + return + } - message.info(response.message || 'MaaFW 项目已是最新') + await refreshPreviewIfPossible() + projectUpdateAction.value = 'check' + projectUpdateStatus.value = 'completed' + projectUpdateStage.value = '项目更新检查已完成' + projectUpdateProgress.value = 100 + projectUpdateMessage.value = response.message || 'MaaFW 项目已是最新' + message.info(response.message || 'MaaFW 项目已是最新') + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + logger.error(`MaaFW 项目更新失败: ${reason}`) + markProjectUpdateFailed(reason || 'MaaFW 项目更新失败') + } finally { + if (projectUpdateStatus.value === 'running') { + markProjectUpdateFailed('MaaFW 项目更新未正常完成') + } + } } - const refreshPreviewIfPossible = async () => { - if (!maafwConfig.Info.Path) return + const refreshPreviewIfPossible = async ( + forceUiRefresh = false, + fallbackVersion?: string | null + ): Promise => { + if (!maafwConfig.Info.Path) return false + const previousData = previewData.value const data = await previewInterface(maafwConfig.Info.Path) if (data) { previewData.value = data @@ -735,57 +1291,120 @@ export function useMaaFWScriptConfig(scriptId: string) { await syncProjectLabelFromProject(data) syncControllerResourceSelection(!isInitializing.value) await prunePeriodTaskSelections() + return true } + if (forceUiRefresh && previousData) { + previewData.value = { + ...previousData, + project: { + ...previousData.project, + version: fallbackVersion || previousData.project.version, + }, + } + } + return false } const loadGlobalUpdateDefaults = async () => { const settings = await getSettings() - globalUpdateSource.value = settings?.Update?.Source || '' globalUpdateChannel.value = settings?.Update?.Channel || '' + globalMirrorChyanCDK.value = settings?.Update?.MirrorChyanCDK || '' } const loadEmulatorOptions = async () => { - emulatorLoading.value = true - try { - const [response, detailResponse] = await Promise.all([ - Service.getEmulatorComboxApiInfoComboxEmulatorPost(), - Service.getEmulatorApiEmulatorGetPost({}), - ]) - if (response?.code === 200) { - emulatorOptions.value = response.data || [] - } - if (detailResponse?.code === 200) { - const typeMap: Record = {} - Object.entries(detailResponse.data || {}).forEach(([emulatorId, config]) => { - const emulatorType = config.Info?.Type - if (emulatorType) typeMap[emulatorId] = emulatorType - }) - emulatorTypeById.value = typeMap + if (emulatorOptionsLoaded) { + emulatorOptionsReady.value = true + return + } + if (emulatorOptionsPromise) return emulatorOptionsPromise + + const request = (async () => { + emulatorLoading.value = true + emulatorOptionsReady.value = false + let comboLoaded = false + let detailLoaded = false + try { + const [response, detailResponse] = await Promise.all([ + Service.getEmulatorComboxApiInfoComboxEmulatorPost(), + Service.getEmulatorApiEmulatorGetPost({}), + ]) + if (response?.code === 200) { + emulatorOptions.value = response.data || [] + comboLoaded = true + } + if (detailResponse?.code === 200) { + const typeMap: Record = {} + Object.entries(detailResponse.data || {}).forEach(([emulatorId, config]) => { + const emulatorType = config.Info?.Type + if (emulatorType) typeMap[emulatorId] = emulatorType + }) + emulatorTypeById.value = typeMap + detailLoaded = true + } + emulatorOptionsLoaded = comboLoaded && detailLoaded + emulatorOptionsReady.value = emulatorOptionsLoaded + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`加载模拟器选项失败: ${errorMsg}`) + emulatorOptionsReady.value = false + } finally { + emulatorLoading.value = false } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - logger.error(`加载模拟器选项失败: ${errorMsg}`) + })() + emulatorOptionsPromise = request + try { + await request } finally { - emulatorLoading.value = false + if (emulatorOptionsPromise === request) emulatorOptionsPromise = null } } const loadEmulatorDeviceOptions = async (emulatorId: string) => { - if (!emulatorId || emulatorId === '-') return + if (!emulatorId || emulatorId === '-') { + emulatorDeviceOptions.value = [] + emulatorDeviceLoading.value = false + return + } + + const cachedOptions = emulatorDeviceOptionsCache.get(emulatorId) + if (cachedOptions) { + if (maafwConfig.Emulator.Id === emulatorId) { + emulatorDeviceOptions.value = [...cachedOptions] + emulatorDeviceLoading.value = false + } + return + } emulatorDeviceLoading.value = true + let request = emulatorDeviceRequests.get(emulatorId) + if (!request) { + request = (async () => { + try { + const response = await Service.getEmulatorDevicesComboxApiInfoComboxEmulatorDevicesPost({ + emulatorId, + }) + if (response?.code !== 200) return null + const options = response.data || [] + emulatorDeviceOptionsCache.set(emulatorId, [...options]) + return options + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`加载模拟器实例选项失败: ${errorMsg}`) + return null + } + })() + emulatorDeviceRequests.set(emulatorId, request) + } try { - const response = await Service.getEmulatorDevicesComboxApiInfoComboxEmulatorDevicesPost({ - emulatorId, - }) - if (response?.code === 200) { - emulatorDeviceOptions.value = response.data || [] + const options = await request + if (options && maafwConfig.Emulator.Id === emulatorId) { + emulatorDeviceOptions.value = [...options] } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - logger.error(`加载模拟器实例选项失败: ${errorMsg}`) } finally { - emulatorDeviceLoading.value = false + if (emulatorDeviceRequests.get(emulatorId) === request) { + emulatorDeviceRequests.delete(emulatorId) + } + if (maafwConfig.Emulator.Id === emulatorId) emulatorDeviceLoading.value = false } } @@ -803,6 +1422,13 @@ export function useMaaFWScriptConfig(scriptId: string) { if (path) { maafwConfig.Info.Path = path agentEnvResult.value = null + agentEnvProgressStatus.value = 'idle' + agentEnvProgressStage.value = '' + agentEnvProgressPercent.value = null + agentEnvProgressMessage.value = '' + agentEnvProgressLogs.value = [] + agentEnvProgressDownloadedBytes.value = null + agentEnvProgressTotalBytes.value = null await handleChange('Info', 'Path', path) await handlePreviewInterface() } @@ -886,6 +1512,7 @@ export function useMaaFWScriptConfig(scriptId: string) { clearTimeout(saveStatusTimer) saveStatusTimer = null } + closeMaaFWProgressSocket() } const handleBeforeUnload = (event: BeforeUnloadEvent) => { @@ -901,7 +1528,26 @@ export function useMaaFWScriptConfig(scriptId: string) { rules, previewData, agentEnvResult, + agentEnvProgressStatus, + agentEnvProgressStage, + agentEnvProgressPercent, + agentEnvProgressMessage, + agentEnvProgressLogs, + agentEnvProgressDownloadedBytes, + agentEnvProgressTotalBytes, projectUpdateLogs, + projectUpdateAction, + projectUpdateStatus, + projectUpdateStage, + projectUpdateProgress, + projectUpdateDownloadPercent, + projectUpdateDownloadedBytes, + projectUpdateTotalBytes, + projectUpdateMessage, + projectUpdateProviderErrorCode, + projectUpdateDiscoveredVersion, + projectUpdateMetadataSource, + projectUpdatePackageSource, scriptEditHint, scriptIconUrl, // loading / save state @@ -914,6 +1560,7 @@ export function useMaaFWScriptConfig(scriptId: string) { interfaceLoading, agentEnvLoading, projectUpdateLoading, + emulatorOptionsReady, emulatorLoading, emulatorDeviceLoading, // emulator state @@ -929,6 +1576,10 @@ export function useMaaFWScriptConfig(scriptId: string) { isInterfaceReady, isAgentEnvReady, isAgentEnvFailed, + isAgentEnvPreparing, + hasEffectiveMirrorChyanCDK, + projectUpdateMirrorSourceBlocked, + isProjectUpdateRunning, projectUpdateDisabled, periodTaskOptions, previewProjectTitle, diff --git a/frontend/src/composables/useScriptApi.ts b/frontend/src/composables/useScriptApi.ts index 62cccc311..8281d1822 100644 --- a/frontend/src/composables/useScriptApi.ts +++ b/frontend/src/composables/useScriptApi.ts @@ -4,7 +4,6 @@ import { type GeneralConfig, type MaaConfig, type MaaEndConfig, - type M9AConfig, type MaaFWConfig, type OkwwConfig, type PluginScriptConfig, @@ -26,7 +25,6 @@ type ScriptListConfig = | OkwwConfig | SrcConfig | MaaEndConfig - | M9AConfig | MaaFWConfig | PluginScriptConfig @@ -34,7 +32,6 @@ const SCRIPT_CREATE_TYPE_BY_SCRIPT_TYPE: Record MAA: ScriptCreateIn.type.MAA, SRC: ScriptCreateIn.type.SRC, MaaEnd: ScriptCreateIn.type.MAA_END, - M9A: ScriptCreateIn.type.M9A, MaaFW: ScriptCreateIn.type.MAA_FW, Okww: ScriptCreateIn.type.OKWW, General: ScriptCreateIn.type.GENERAL, @@ -45,7 +42,6 @@ const SCRIPT_TYPE_BY_CONFIG_TYPE: Record = { [ScriptIndexItem.type.SRC_CONFIG]: 'SRC', [ScriptIndexItem.type.OKWW_CONFIG]: 'Okww', [ScriptIndexItem.type.MAA_END_CONFIG]: 'MaaEnd', - [ScriptIndexItem.type.M9ACONFIG]: 'M9A', [ScriptIndexItem.type.MAA_FWCONFIG]: 'MaaFW', } @@ -828,104 +824,6 @@ export function useScriptApi() { : '未知', }, } - } else if (userIndex.type === 'M9AUserConfig' && userData) { - const m9aUserData = userData as any - return { - id: userIndex.uid, - name: m9aUserData.Info?.Name || `用户${userIndex.uid}`, - Info: { - Name: - m9aUserData.Info?.Name !== undefined - ? m9aUserData.Info.Name - : `用户${userIndex.uid}`, - Status: - m9aUserData.Info?.Status !== undefined ? m9aUserData.Info.Status : true, - RemainedDay: - m9aUserData.Info?.RemainedDay !== undefined - ? m9aUserData.Info.RemainedDay - : -1, - Notes: m9aUserData.Info?.Notes !== undefined ? m9aUserData.Info.Notes : '', - Tag: m9aUserData.Info?.Tag !== undefined ? m9aUserData.Info.Tag : null, - Resource: - m9aUserData.Info?.Resource !== undefined - ? m9aUserData.Info.Resource - : '官服', - Account: - m9aUserData.Info?.Account !== undefined ? m9aUserData.Info.Account : '', - EmulatorId: - m9aUserData.Info?.EmulatorId !== undefined - ? m9aUserData.Info.EmulatorId - : '', - EmulatorIndex: - m9aUserData.Info?.EmulatorIndex !== undefined - ? m9aUserData.Info.EmulatorIndex - : 0, - }, - Task: { - AvailableTasks: - m9aUserData.Task?.AvailableTasks !== undefined - ? m9aUserData.Task.AvailableTasks - : '[]', - Queue: - m9aUserData.Task?.Queue !== undefined ? m9aUserData.Task.Queue : '[]', - }, - Notify: { - Enabled: - m9aUserData.Notify?.Enabled !== undefined - ? m9aUserData.Notify.Enabled - : false, - IfSendStatistic: - m9aUserData.Notify?.IfSendStatistic !== undefined - ? m9aUserData.Notify.IfSendStatistic - : false, - IfSendMail: - m9aUserData.Notify?.IfSendMail !== undefined - ? m9aUserData.Notify.IfSendMail - : false, - ToAddress: - m9aUserData.Notify?.ToAddress !== undefined - ? m9aUserData.Notify.ToAddress - : '', - IfServerChan: - m9aUserData.Notify?.IfServerChan !== undefined - ? m9aUserData.Notify.IfServerChan - : false, - ServerChanKey: - m9aUserData.Notify?.ServerChanKey !== undefined - ? m9aUserData.Notify.ServerChanKey - : '', - CustomWebhooks: - m9aUserData.Notify?.CustomWebhooks !== undefined - ? m9aUserData.Notify.CustomWebhooks - : [], - }, - Data: { - LastProxyDate: - m9aUserData.Data?.LastProxyDate !== undefined - ? m9aUserData.Data.LastProxyDate - : '', - LastPsychubeDate: - m9aUserData.Data?.LastPsychubeDate !== undefined - ? m9aUserData.Data.LastPsychubeDate - : '', - LastLimboMonth: - m9aUserData.Data?.LastLimboMonth !== undefined - ? m9aUserData.Data.LastLimboMonth - : '', - LastLucidscapeMonth: - m9aUserData.Data?.LastLucidscapeMonth !== undefined - ? m9aUserData.Data.LastLucidscapeMonth - : '', - ProxyTimes: - m9aUserData.Data?.ProxyTimes !== undefined - ? m9aUserData.Data.ProxyTimes - : 0, - IfPassCheck: - m9aUserData.Data?.IfPassCheck !== undefined - ? m9aUserData.Data.IfPassCheck - : false, - }, - } } else if (userIndex.type === UserIndexItem.type.MAA_FWUSER_CONFIG && userData) { const maafwUserData = userData as any return { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 83c91273c..e1ba2f1aa 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,5 +1,4 @@ import { createRouter, createWebHashHistory } from 'vue-router' -import type { RouteLocationGeneric } from 'vue-router' import { useAppInitialization } from '@/composables/useAppInitialization' import { getInitializationDecision } from '@/utils/initializationDecision' import { startSkippedInitializationStartup } from '@/utils/skippedInitializationStartup' @@ -33,24 +32,12 @@ const routes = [ component: () => import('../views/EditView/Script/MaaEndScriptEdit.vue'), meta: { title: '\u7f16\u8f91 MaaEnd \u811a\u672c' }, }, - { - path: '/scripts/:id/edit/m9a', - redirect: (to: RouteLocationGeneric) => `/scripts/${to.params.id}/edit/maafw`, - name: 'M9AScriptEdit', - meta: { title: '编辑M9A脚本' }, - }, { path: '/scripts/:id/edit/maafw', name: 'MaaFWScriptEdit', component: () => import('../views/EditView/Script/MaaFWScriptEdit.vue'), meta: { title: '编辑MaaFramework项目' }, }, - { - path: '/scripts/:id/setup/m9a', - redirect: (to: RouteLocationGeneric) => `/scripts/${to.params.id}/setup/maafw`, - name: 'M9ASetupWizard', - meta: { title: 'M9A项目引导' }, - }, { path: '/scripts/:id/setup/maafw', name: 'MaaFWSetupWizard', @@ -111,12 +98,6 @@ const routes = [ component: () => import('../views/EditView/User/MaaEndUserEdit.vue'), meta: { title: '\u6dfb\u52a0 MaaEnd \u7528\u6237' }, }, - { - path: '/scripts/:scriptId/users/add/m9a', - redirect: (to: RouteLocationGeneric) => `/scripts/${to.params.scriptId}/users/add/maafw`, - name: 'M9AUserAdd', - meta: { title: '添加M9A用户' }, - }, { path: '/scripts/:scriptId/users/add/maafw', name: 'MaaFWUserAdd', @@ -141,13 +122,6 @@ const routes = [ component: () => import('../views/EditView/User/MaaEndUserEdit.vue'), meta: { title: '\u7f16\u8f91 MaaEnd \u7528\u6237' }, }, - { - path: '/scripts/:scriptId/users/:userId/edit/m9a', - redirect: (to: RouteLocationGeneric) => - `/scripts/${to.params.scriptId}/users/${to.params.userId}/edit/maafw`, - name: 'M9AUserEdit', - meta: { title: '编辑M9A用户' }, - }, { path: '/scripts/:scriptId/users/:userId/edit/maafw', name: 'MaaFWUserEdit', diff --git a/frontend/src/types/script.ts b/frontend/src/types/script.ts index 9cc7979a2..522f3a757 100644 --- a/frontend/src/types/script.ts +++ b/frontend/src/types/script.ts @@ -5,7 +5,6 @@ import type { OkwwConfig, SrcConfig, MaaEndConfig, - M9AConfig, MaaFWConfig as ApiMaaFWConfig, } from '@/api' import type { @@ -22,7 +21,7 @@ import type { SchemaDefinition } from './schemaForm' * type_key),因此 ScriptType 为 string;BuiltinScriptType 保留内建类型的 * 字面量枚举供需要穷举内建类型的场景使用。 */ -export type BuiltinScriptType = 'MAA' | 'General' | 'Okww' | 'SRC' | 'MaaEnd' | 'M9A' | 'MaaFW' +export type BuiltinScriptType = 'MAA' | 'General' | 'Okww' | 'SRC' | 'MaaEnd' | 'MaaFW' export type ScriptType = string @@ -152,31 +151,6 @@ export interface MaaEndScriptConfig { } } -// M9A脚本配置 -export interface M9AScriptConfig { - Info: { - Name: string - Path: string - } - Emulator: { - Id: string - Index: string - } - Run: { - ProxyTimesLimit: number - RunTimesLimit: number - RunTimeLimit: number - IfAutoUpdateAfterQueue: boolean - IfPsychubeDailyOnce: boolean - IfSleepDreamMonthlyOnce: boolean - } - SubConfigsInfo: { - UserData: { - instances: any[] - } - } -} - // MaaFramework 项目脚本配置 export interface MaaFWScriptConfig { Info: { @@ -589,7 +563,6 @@ export interface Script { | OkwwConfig | SrcConfig | MaaEndConfig - | M9AConfig | ApiMaaFWConfig | MaaFWScriptConfig | HSRScriptConfig @@ -731,7 +704,6 @@ export interface AddScriptResponse { | OkwwScriptConfig | SRCScriptConfig | MaaEndScriptConfig - | M9AScriptConfig | ApiMaaFWConfig | MaaFWScriptConfig | HSRScriptConfig @@ -740,14 +712,7 @@ export interface AddScriptResponse { // 脚本索引项 export interface ScriptIndexItem { uid: string - type: - | 'MaaConfig' - | 'GeneralConfig' - | 'OkwwConfig' - | 'SrcConfig' - | 'MaaEndConfig' - | 'M9AConfig' - | 'MaaFWConfig' + type: 'MaaConfig' | 'GeneralConfig' | 'OkwwConfig' | 'SrcConfig' | 'MaaEndConfig' | 'MaaFWConfig' } // 获取脚本API响应 @@ -763,7 +728,6 @@ export interface GetScriptsResponse { | OkwwScriptConfig | SRCScriptConfig | MaaEndScriptConfig - | M9AScriptConfig | ApiMaaFWConfig | MaaFWScriptConfig | HSRScriptConfig @@ -781,7 +745,6 @@ export interface ScriptDetail { | OkwwConfig | SrcConfig | MaaEndConfig - | M9AConfig | ApiMaaFWConfig | MaaFWScriptConfig | HSRScriptConfig @@ -796,21 +759,6 @@ export interface DeleteScriptResponse { message: string } -// M9A 任务选项类型 -export interface M9ATaskOption { - name: string - index: number - sub_options?: M9ATaskOption[] - input_values?: Record - selected_cases?: string[] -} - -// M9A 任务队列项类型 -export interface M9ATaskQueueItem { - name: string - options: M9ATaskOption[] -} - // 更新脚本API响应 export interface UpdateScriptResponse { code: number diff --git a/frontend/src/types/scriptRegistry.ts b/frontend/src/types/scriptRegistry.ts index 40cf6e48c..e8bea4fb8 100644 --- a/frontend/src/types/scriptRegistry.ts +++ b/frontend/src/types/scriptRegistry.ts @@ -8,6 +8,7 @@ export interface ScriptTypeDescriptor { theme_color?: string | null create_group?: 'general' | 'specialized' create_group_declared?: boolean + creatable?: boolean docs_url?: string | null editor_kind: string supported_modes: string[] diff --git a/frontend/src/utils/scriptRegistry.ts b/frontend/src/utils/scriptRegistry.ts index 57e18c031..8334a177c 100644 --- a/frontend/src/utils/scriptRegistry.ts +++ b/frontend/src/utils/scriptRegistry.ts @@ -68,7 +68,7 @@ const DEFAULT_USER_SHAPE = { }, } -export const BUILTIN_SCRIPT_TYPES = new Set(['MAA', 'SRC', 'MaaEnd', 'M9A', 'MaaFW']) +export const BUILTIN_SCRIPT_TYPES = new Set(['MAA', 'SRC', 'MaaEnd', 'MaaFW']) export const isBuiltinScriptType = (type: string) => BUILTIN_SCRIPT_TYPES.has(type) @@ -138,21 +138,25 @@ const BUILTIN_EDITOR_SEGMENTS: Record = { 'builtin:maa': 'maa', 'builtin:src': 'src', 'builtin:maaend': 'maaend', - 'builtin:m9a': 'm9a', 'builtin:maafw': 'maafw', } const TYPE_KEY_EDITOR_SEGMENTS: Record = { MaaFW: 'maafw', - M9A: 'maafw', // ok-ww 使用专属编辑页(/edit/okww 等),与 PR #287/#288 的视觉与配置字段保持一致 Okww: 'okww', } const PLUGIN_EDITOR_SEGMENTS: Record = { + 'plugin:automas_script_maafw': 'maafw', 'plugin:automas_script_hsr': 'hsr', } +const MAAFW_EDITOR_KINDS = new Set(['builtin:maafw', 'plugin:automas_script_maafw']) + +export const isMaaFWEditorKind = (editorKind?: string | null) => + Boolean(editorKind && MAAFW_EDITOR_KINDS.has(editorKind)) + export const getScriptEditPath = (script: Pick) => { const segment = BUILTIN_EDITOR_SEGMENTS[script.editorKind ?? ''] ?? diff --git a/frontend/src/views/EditView/Script/MaaFWScriptEdit.vue b/frontend/src/views/EditView/Script/MaaFWScriptEdit.vue index 1c552a998..60ee7d1e4 100644 --- a/frontend/src/views/EditView/Script/MaaFWScriptEdit.vue +++ b/frontend/src/views/EditView/Script/MaaFWScriptEdit.vue @@ -15,7 +15,7 @@ class="breadcrumb-logo" @error="event => handleScriptIconError(event, formData.type)" /> - 项目配置 + {{ projectDisplayName }} 项目配置 @@ -47,11 +47,7 @@
- + @@ -63,8 +59,17 @@ :rules="rules" :preview-data="previewData" :agent-env-result="agentEnvResult" + :agent-env-progress-status="agentEnvProgressStatus" + :agent-env-progress-stage="agentEnvProgressStage" + :agent-env-progress-percent="agentEnvProgressPercent" + :agent-env-progress-message="agentEnvProgressMessage" + :agent-env-progress-logs="agentEnvProgressLogs" + :agent-env-progress-downloaded-bytes="agentEnvProgressDownloadedBytes" + :agent-env-progress-total-bytes="agentEnvProgressTotalBytes" :interface-loading="interfaceLoading" :agent-env-loading="agentEnvLoading" + :is-agent-env-preparing="isAgentEnvPreparing" + :is-project-update-running="isProjectUpdateRunning" :is-setup-mode="false" :preview-project-title="previewProjectTitle" :interface-stats="interfaceStats" @@ -87,6 +92,7 @@ :preview-data="previewData" :interface-loading="interfaceLoading" :emulator-loading="emulatorLoading" + :emulator-options-ready="emulatorOptionsReady" :emulator-device-loading="emulatorDeviceLoading" :emulator-options="emulatorOptions" :emulator-device-options="emulatorDeviceOptions" @@ -116,6 +122,19 @@ :is-auto-update-disabled="isAutoUpdateDisabled" :project-update-loading="projectUpdateLoading" :project-update-disabled="projectUpdateDisabled" + :project-update-mirror-source-blocked="projectUpdateMirrorSourceBlocked" + :project-update-action="projectUpdateAction" + :project-update-status="projectUpdateStatus" + :project-update-stage="projectUpdateStage" + :project-update-progress="projectUpdateProgress" + :project-update-download-percent="projectUpdateDownloadPercent" + :project-update-downloaded-bytes="projectUpdateDownloadedBytes" + :project-update-total-bytes="projectUpdateTotalBytes" + :project-update-message="projectUpdateMessage" + :project-update-provider-error-code="projectUpdateProviderErrorCode" + :project-update-discovered-version="projectUpdateDiscoveredVersion" + :project-update-metadata-source="projectUpdateMetadataSource" + :project-update-package-source="projectUpdatePackageSource" :project-update-logs="projectUpdateLogs" :update-source-options="updateSourceOptions" :update-channel-options="updateChannelOptions" @@ -163,7 +182,7 @@ - - - -
-
- -

自动代理统计报告

-
- -
-

代理信息:{{ user_info }}

-

开始时间:{{ start_time }}

-

结束时间:{{ end_time }}

-

执行结果: - {% if user_result == '代理成功' or user_result == '代理任务全部完成' %} - {{ user_result }} - {% elif user_result == '代理失败' or user_result == '代理任务失败' %} - {{ user_result }} - {% else %} - {{ user_result }} - {% endif %} -

- - {% if task_details %} -

任务详情

-
{{ task_details }}
- {% endif %} - -
- -

AUTO-MAS 敬上

- - -
- - - diff --git a/res/version.json b/res/version.json index af10da4cf..47ce72dd0 100644 --- a/res/version.json +++ b/res/version.json @@ -9,7 +9,8 @@ ], "程序优化": [ "统一全局滚动条样式,并适配浅色与深色主题", - "放宽关闭流程等待后端清理完成的超时时间,避免重任务场景下清理被强制中断" + "放宽关闭流程等待后端清理完成的超时时间,避免重任务场景下清理被强制中断", + "MaaFW/M9A专项 收口普通 MaaFW 插件能力并移除主程序内置 M9A,统一由独立插件注册、配置与运行" ], "修复BUG": [ "SRC专项 修复 src.exe 因 stdout 指向 DEVNULL 在无控制台下 print/colorama flush 抛 OSError[22] 导致的启动崩溃",