diff --git a/app/models/config/script.py b/app/models/config/script.py index 50a2207d37..c7b46bc08f 100644 --- a/app/models/config/script.py +++ b/app/models/config/script.py @@ -2499,7 +2499,13 @@ class EmulatorFields(ConfigGroup): class GameFields(ConfigGroup): Arguments: CliArgumentString = Field( - default_factory=lambda: "", description="游戏启动参数" + default_factory=lambda: "", + description="游戏启动参数,仅 DirectExe 模式使用;URL 模式的参数写进链接", + ) + + CloseMode: Literal["Script", "OnFailure", "Always"] = Field( + default_factory=lambda: "OnFailure", + description="AttachOnly 模式下结束后谁关游戏:Script 交给脚本(MAS 不关);OnFailure 本次运行没正常完成(失败、超时、停止、异常)时由 MAS 关;Always 结束后一律由 MAS 关。MAS 只关本次运行开始之后才出现的进程,重试之间不关。DirectExe 与 URL 模式忽略它", ) Hotkeys: JsonDictString = Field( @@ -2507,13 +2513,19 @@ class GameFields(ConfigGroup): description='托管级键位(PI v2.8 hotkey 选项):``{option 名: {字段名: 组合键字符串}}``, 组合键写成「修饰键+…+主键」(如 ``"Ctrl+E"``)。只存与 interface 默认不同的 字段;运行时叠加到每个任务实际生效的 hotkey 选项上,盖过账号快照 / 预设里的值。 仅 Win32 控制器生效(Adb 等其他控制器不叠加)。interface 里已没有的 option / 字段静默忽略,映射不了的值告警后回落到快照 / 默认值(runner/run_plan.py)。', ) - LaunchMode: Literal["DirectExe", "AttachOnly"] = Field( - default_factory=lambda: "DirectExe", description="游戏生命周期模式" + LaunchMode: Literal["DirectExe", "URL", "AttachOnly"] = Field( + default_factory=lambda: "DirectExe", + description="游戏生命周期模式:DirectExe 由 MAS 按 exe 启动、结束后关闭;URL 由 MAS 打开链接拉起游戏、结束后关闭;AttachOnly 由脚本或用户自己启动,MAS 只接管窗口,结束后是否由 MAS 关闭见 CloseMode", ) LaunchPath: FilePath = Field( default_factory=lambda: None, - description="DirectExe 模式下 MAS 启动的游戏 exe", + description="游戏 exe:DirectExe 模式下 MAS 启动它;URL 模式下必填,用来认出游戏(是否已在运行、等窗口、关闭、Unity 分辨率);AttachOnly 模式下可选,用来认出要关闭的游戏", + ) + + LauncherPath: FilePath = Field( + default_factory=lambda: None, + description="AttachOnly 模式下可选的启动器 exe,MAS 兜底关闭游戏时一并关闭本次运行期间打开的启动器", ) PackageName: str = Field( @@ -2521,9 +2533,14 @@ class GameFields(ConfigGroup): description="安卓游戏包名,Adb controller 用:启动模拟器时顺带把游戏拉起来。 留空表示从项目的 pipeline 里自动识别(见 embedded/game_package.py); 自动识别是启发式的,填了这里就以这里为准。识别不出且没填则不启动游戏。", ) + URL: str = Field( + default_factory=lambda: "", + description="URL 模式下 MAS 打开的链接,如 steam://rungameid/", + ) + UnityResolution: Literal["Off", "1920x1080", "1280x720"] = Field( default_factory=lambda: "Off", - description="DirectExe 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。", + description="DirectExe 与 URL 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。", ) WaitTime: Annotated[int, Field(ge=0, le=9999), none_as(0)] = Field( diff --git a/app/task/MaaFW/AGENTS.md b/app/task/MaaFW/AGENTS.md index 353189dd31..0a4255cc5d 100644 --- a/app/task/MaaFW/AGENTS.md +++ b/app/task/MaaFW/AGENTS.md @@ -497,10 +497,28 @@ MFW 不使用原生编辑器会话或 base 三态选择运行配置;已有配 异常用异常原文,同样过 `human_reason` 过滤)与失败类别的固定说法;`errorMessage`(节点链、框架失败 事件、原生栈)不进推送,仍照旧写进任务日志、history 状态与调度台完成面板。记录是旁路, **不参与重试判断**。 -- Win32 下 `Game.LaunchMode` 只有两态:`DirectExe`(默认,MAS 启动、结束后一律关闭)与 - `AttachOnly`(其他方式启停,MAS 只接管窗口)。关不关只看 `opened_game`,没有开关; - DirectExe 下发现游戏已在运行时也只接管、不关。`Game.UnityResolution`(Off / 1920x1080 / - 1280x720)走 +- Win32 下 `Game.LaunchMode` 三态:`DirectExe`(默认,MAS 按 `Game.LaunchPath` 启动、结束后关闭)、 + `URL`(MAS 用 ShellExecute 打开 `Game.URL` 拉起游戏,如 `steam://rungameid/`,结束后关闭)与 + `AttachOnly`(脚本或用户自己启动,MAS 只接管窗口)。 + - DirectExe / URL 关不关只看 `opened_game`;发现游戏已在运行(控制器窗口在,或 exe 在跑)时只接管、 + 不关。重试前 `_restart_client_before_retry` 照常关、下一轮重新拉起。 + - **URL 模式**:`LaunchPath` 必填但不用来启动,只用来认游戏(是否已在运行、等窗口、前置、关闭、Unity + 分辨率覆盖);缺 exe 或缺链接在运行前检查就报。打开链接前记下时刻,按 exe + + `min_create_time` 找进程,时限 `max(WaitTime, 60)`(Steam 冷启动慢);链接一打开就置 + `opened_game`。`Game.Arguments` 不用,参数写进链接(`steam://run///<参数>/`)。关闭时 + `kill()` 跟踪的那个之后,再把 exe 等于游戏 exe、创建时间不早于打开链接时刻(容差 2s)的进程都 + 结束(先 terminate,3s 后 kill):游戏不是 MAS 的子进程,有的还会自我重启换 PID。 + - **AttachOnly 兜底关闭**(`Game.CloseMode`,默认 `OnFailure`;DirectExe / URL 忽略它):`Script` + 交给脚本、MAS 不关;`OnFailure` 本次运行没正常完成(`run_complete` 为假:失败、超时、停止、维护 + 信号,以及 `on_crash`)时关;`Always` 结束后一律关。**硬规则:只关本次运行期间新出现的进程**—— + 账号运行开始(脚本前任务与第一次尝试的运行前设置之前)记下 `run_started_at`,收尾时候选三路(控制器窗口匹配 + 到的窗口所属进程、exe 等于 `Game.LaunchPath` 的、exe 等于可选 `Game.LauncherPath` 的),只结束 + 创建时间 ≥ `run_started_at − 2s` 的,运行前就在的各写一行「运行开始前就已打开,不关闭」;创建时间 + 读不到的按「运行前就在」处理。控制器没写窗口规则时窗口这一路不参与。**只在最终收尾做** + (`_close_game_after_run`:`_finalize_user` 与 `on_crash`),重试之间不关,免得和运行前设置的 + 重跑判定(`_pretasks_need_rerun`)打架;每次运行只做一次。运行前设置超时 / 停止时的清理不变, + 照旧不碰游戏和启动器。 +- `Game.UnityResolution`(Off / 1920x1080 / 1280x720,DirectExe 与 URL 下生效)走 `game_resolution.py`:按 `_Data/app.info` 反查 `HKCU\Software\<公司>\<产品>`, 只改 Unity 播放器的 `Screenmanager *` 值,不碰游戏自有的那层(星铁的 `GraphicsSettings_PCResolution`、终末地的 `video_resolution_*`),效果要实机验证。 @@ -508,7 +526,7 @@ MFW 不使用原生编辑器会话或 base 三态选择运行配置;已有配 游戏自有层由该游戏的特调或专项自己调。 - **项目的运行前设置(interface `pretask`)在每次尝试里跑**,不像账号级「脚本前任务」只跑一次: MaaEnd 的「🚀启动游戏(PC)」就是它,此时 `Game.LaunchMode` 是 AttachOnly、`opened_game` 为假, - MAS 既不拉起也不关游戏。第一次尝试照常跑;它自己失败按普通失败重试;成功过之后的重试只在客户端 + MAS 不拉起游戏、重试之间也不关(结束后按 `Game.CloseMode` 兜底,见上一条)。第一次尝试照常跑;它自己失败按普通失败重试;成功过之后的重试只在客户端 已经不在时重跑(`_pretasks_need_rerun`:MAS 刚关掉自己起的游戏 / 模拟器——关的时候报错也算, `_close_*` 不论成败都清 `opened_*`、下一轮会重新拉起,见 `_reset_client_for_retry`;或 Win32 下 控制器窗口找不到——队列末尾的「关闭游戏」任务会把它关掉)。游戏还开着不重跑,免得项目的启动脚本 diff --git a/app/task/MaaFW/tools/embedded/runner_task.py b/app/task/MaaFW/tools/embedded/runner_task.py index 6452afdead..b22c958266 100644 --- a/app/task/MaaFW/tools/embedded/runner_task.py +++ b/app/task/MaaFW/tools/embedded/runner_task.py @@ -262,6 +262,12 @@ WINDOW_SEARCH_TIMEOUT_SECONDS = 5.0 # Game.WaitTime 填 0 时等游戏窗口出现的上限(秒) _WINDOW_READY_FALLBACK_SECONDS = 60 +# URL 模式打开链接后找游戏进程的最短时限(秒):Steam 冷启动要先起客户端再拉游戏 +_URL_LAUNCH_MIN_TIMEOUT_SECONDS = 60 +# 按创建时间认「本次运行期间打开的进程」时的容差(秒),与 ProcessManager.search_process 一致 +_PROCESS_CREATE_TIME_TOLERANCE_SECONDS = 2.0 +# 兜底关闭进程时 terminate 之后等它自己退出的秒数,到点仍在就 kill +_PROCESS_TERMINATE_GRACE_SECONDS = 3.0 # 托管页没有填窗口句柄的入口,提示不能让用户去找一个不存在的设置。 _WIN32_NO_WINDOW_RULES_MESSAGE = ( "该项目的这个 Win32 控制器没有声明窗口匹配规则(interface 里的 class_regex / " @@ -565,6 +571,16 @@ def __init__( # 启动的才记:桌面游戏是窗口出现那一刻(MAS 拉起、或接管时等到窗口冒出来), # 模拟器是 MAS 随模拟器把游戏拉到前台那一刻。None 表示游戏早就在跑。 self.game_started_at: float | None = None + # URL 模式本轮打开链接的时刻(epoch 秒):Steam 拉起的游戏不是 MAS 的子进程、还可能 + # 自我重启换 PID,关游戏时据此兜底结束这之后出现的同 exe 进程。None 表示没经链接拉起 + self.game_launched_at: float | None = None + # URL 模式本次运行**第一次**打开链接的时刻:重试之间不清,只在最终收尾用完清空。 + # Steam 冷启动 / 更新超过等待时限时,游戏会在 MAS 放弃之后才出现,下一轮又按「已在 + # 运行」接管、不算 MAS 开的;收尾按它再扫一遍,MAS 拉起的游戏不会留在外面 + self.url_first_launched_at: float | None = None + # 本账号这次运行开始的时刻(epoch 秒,第一次尝试的运行前设置之前):AttachOnly 下由 + # MAS 兜底关闭时只关这之后才出现的进程,收尾做过一次就清空(见 _close_attached_game) + self.run_started_at: float | None = None # 模拟器冷启动游戏时为真:闸门改按「画面有内容 + 客体读盘停下来」放行,上限仍是 # Game.WaitTime(加载页和动态标题都一直在动,像素判断分不出两者)。桌面路径始终为假。 self.game_start_disk_quiet = False @@ -787,6 +803,8 @@ async def main_task(self) -> None: await self._mark_run_started() await self.prepare() await self.cur_user_item.set_state(status="运行") + # AttachOnly 兜底关闭的分界:之后才出现的游戏 / 启动器才算本次运行打开的 + self.run_started_at = time.time() logger.info(f"开始代理 MaaFW 账号: {self.cur_user_uid}") if self.run_plan is not None: selected_preset = str( @@ -1121,7 +1139,7 @@ async def _finalize_user(self) -> None: return await self._close_emulator() - await self._close_game() + await self._close_game_after_run(failed=not self.run_complete) if self.run_complete: if ( self.cur_user_config.Data.ProxyTimes == 0 @@ -1177,28 +1195,55 @@ async def on_crash(self, e: Exception) -> None: pass await self._shutdown_runner() await self._close_emulator() - await self._close_game() + await self._close_game_after_run(failed=True) await self._release_project_path() + def _game_launch_mode(self) -> str: + """``Game.LaunchMode``:DirectExe / URL / AttachOnly。 + + 配置激活后一定有默认值,这里的兜底只是防御性的。 + """ + + return str(self.script_config.Game.LaunchMode or "DirectExe").strip() + def _mas_manages_game_launch(self) -> bool: """MAS 是否负责启动/关闭游戏。 - 只有两种模式:DirectExe(默认;MAS 启动,结束后一律由 MAS 关闭)与 - AttachOnly(脚本或用户自己启停,MAS 只接管窗口、不启动也不关闭)。 - 此前这里根本没读过 LaunchMode,Win32 controller 无论哪种模式都强制索要 exe。 - 配置激活后 LaunchMode 一定有默认值,这里的兜底 - 只是防御性的。 + 三种模式:DirectExe(默认;MAS 按 exe 启动,结束后由 MAS 关闭)、URL(MAS 打开 + 链接拉起游戏,游戏 exe 只用来认出它,结束后同样由 MAS 关闭)与 AttachOnly(脚本或 + 用户自己启动,MAS 只接管窗口;结束后关不关看 ``Game.CloseMode``,见 + ``_close_attached_game``)。 """ - mode = str(self.script_config.Game.LaunchMode or "DirectExe").strip() - return mode != "AttachOnly" + return self._game_launch_mode() != "AttachOnly" def _resolve_game_launch_path(self) -> Path | None: - """DirectExe 模式下 MAS 要启动的客户端 exe。""" + """游戏 exe:DirectExe 下 MAS 启动它,URL / AttachOnly 下只用来认出游戏。""" raw = str(self.script_config.Game.LaunchPath or "").strip() return Path(raw) if raw else None + def _resolve_launcher_path(self) -> Path | None: + """AttachOnly 下可选的启动器 exe,MAS 兜底关闭时一并关它。""" + + raw = str(self.script_config.Game.LauncherPath or "").strip() + return Path(raw) if raw else None + + def _describe_game_launch_config_error(self) -> str | None: + """MAS 负责启动游戏时缺了什么配置;不缺返回 None。会读盘,调用方放在工作线程里。""" + + mode = self._game_launch_mode() + game_path = self._resolve_game_launch_path() + if mode == "URL": + if not str(self.script_config.Game.URL or "").strip(): + return "启动方式为通过链接启动,请在托管管理页填写启动链接" + if game_path is None or not game_path.is_file(): + return "启动方式为通过链接启动,请在托管管理页选择游戏 exe(用来识别和关闭游戏)" + return None + if game_path is None or not game_path.is_file(): + return "当前 MaaFW controller 需要由 MAS 启动游戏,请在托管管理页选择实际游戏 exe" + return None + def _load_run_state_for_check( self, send_log: Callable[[str], None] | None = None, @@ -1226,9 +1271,7 @@ def _load_run_state_for_check( and run_plan.controllerType == "Win32" and self._mas_manages_game_launch() ): - game_path = self._resolve_game_launch_path() - if game_path is None or not game_path.is_file(): - game_path_error = "当前 MaaFW controller 需要由 MAS 启动游戏,请在托管管理页选择实际游戏 exe" + game_path_error = self._describe_game_launch_config_error() return interface_model, base_run_plan, run_plan, game_path_error def _build_run_plan( @@ -3146,10 +3189,12 @@ async def _ensure_desktop_game_started(self) -> None: # 的窗口」,比在这里索要一个本模式用不上的 exe 更贴题。 return + config_error = await asyncio.to_thread(self._describe_game_launch_config_error) game_path = self._resolve_game_launch_path() - if game_path is None or not await asyncio.to_thread(game_path.is_file): + if config_error is not None or game_path is None: raise RuntimeError( - "当前 MaaFW controller 需要由 MAS 启动游戏,请在托管管理页选择实际游戏 exe" + config_error + or "当前 MaaFW controller 需要由 MAS 启动游戏,请在托管管理页选择实际游戏 exe" ) if self.interface_model is not None and self.run_plan is not None: @@ -3184,6 +3229,11 @@ async def _ensure_desktop_game_started(self) -> None: await self._activate_desktop_game_window(game_path) return + if self._game_launch_mode() == "URL": + await self._launch_game_by_url(game_path) + await self._activate_desktop_game_window(game_path) + return + game_arguments = shlex.split( str(self.script_config.Game.Arguments or "").strip() ) @@ -3211,6 +3261,53 @@ async def _ensure_desktop_game_started(self) -> None: await self._activate_desktop_game_window(game_path) + async def _launch_game_by_url(self, game_path: Path) -> None: + """URL 模式:打开链接拉起游戏,再按游戏 exe 找到本次启动后出现的进程、等窗口。 + + 游戏不是 MAS 的子进程(Steam 等转一手),只能按 exe + 创建时间认;``Game.Arguments`` + 不用,参数要写进链接。Steam 冷启动慢,找进程的时限不少于 + ``_URL_LAUNCH_MIN_TIMEOUT_SECONDS``。 + """ + + url = str(self.script_config.Game.URL or "").strip() + timeout = max( + int(self.script_config.Game.WaitTime or 0), _URL_LAUNCH_MIN_TIMEOUT_SECONDS + ) + logger.info(f"通过链接启动游戏: {url} - {game_path}") + # 注册表必须在进程起来之前改好:Unity 播放器只在启动时读一次这些值 + await self._apply_game_resolution_override(game_path) + launched_at = time.time() + try: + await _open_launch_url(url) + except Exception as exc: + # 被停止(取消)时不在这里恢复:收尾的 _close_game 见 opened_game 为假也会恢复 + await self._restore_game_resolution_override() + raise RuntimeError(f"无法打开启动链接 {url}: {exc}") from exc + # 链接一打开就算 MAS 开的(同 DirectExe):之后等不到、被停止,收尾都要关 + self.opened_game = True + self.game_launched_at = launched_at + if self.url_first_launched_at is None: + self.url_first_launched_at = launched_at + self._append_log( + f"已打开启动链接,等待游戏进程启动(最多 {timeout}s): {game_path.name}" + ) + try: + try: + await self.game_process_manager.search_process( + ProcessInfo(exe=str(game_path.resolve())), + timeout, + min_create_time=launched_at, + ) + except RuntimeError as exc: + raise RuntimeError( + f"打开启动链接后 {timeout}s 内没有等到游戏进程: {game_path.name}" + ) from exc + await self._wait_for_desktop_game_ready(game_path) + self.game_started_at = time.monotonic() + except Exception: + await self._close_game() + raise + def _unity_resolution_target(self) -> tuple[int, int] | None: """``Game.UnityResolution`` 选了尺寸就返回 (宽, 高),Off 返回 None。""" @@ -3225,6 +3322,9 @@ def _note_resolution_override_skipped(self) -> None: def _note_launch_arguments_skipped(self) -> None: """游戏已在运行时不会重复启动,配了启动参数的用户要知道这轮没生效。""" + if self._game_launch_mode() == "URL": + # URL 模式本来就不用启动参数(参数写在链接里) + return arguments = str(self.script_config.Game.Arguments or "").strip() if arguments: self._append_log(f"检测到游戏已在运行,本轮不会应用启动参数({arguments})") @@ -3749,25 +3849,168 @@ def _timed_out_user_summary(self, result: MaaFWRunResult) -> str: async def _close_game(self) -> bool: """关闭由 MAS 启动的游戏,返回是否关成(本来就没开过算关成)。 - 只看 opened_game:由 MAS 启动的一律关,其他方式启动(AttachOnly、或 - DirectExe 下发现游戏已在运行而没重复启动)的一律不碰,没有开关。 + 只看 opened_game:由 MAS 启动的(DirectExe、URL)一律关,其他方式启动(AttachOnly、 + 或发现游戏已在运行而没重复启动)的这里不碰——AttachOnly 的兜底关闭只在最终收尾做, + 见 ``_close_attached_game``。URL 模式另按游戏 exe 兜底扫一遍:游戏不是 MAS 的子进程, + 有的还会自我重启换 PID,跟踪的那一个关掉了不等于游戏关掉了。 """ if not self.opened_game: await self._restore_game_resolution_override() return True + launched_at = self.game_launched_at + closed = True try: - await self.game_process_manager.kill() - return True - except Exception as exc: - logger.warning(f"MaaFW 清理时关闭游戏失败: {exc}") - return False + try: + await self.game_process_manager.kill() + except Exception as exc: + logger.warning(f"MaaFW 清理时关闭游戏失败: {exc}") + closed = False + else: + if launched_at is not None: + # 链接拉起的游戏不是 MAS 的子进程,写一行让用户知道是 MAS 关的 + self._append_log("已关闭本次通过链接打开的游戏") + game_path = self._resolve_game_launch_path() + if launched_at is not None and game_path is not None: + try: + if not await self._end_processes_started_since( + launched_at, exes=[game_path] + ): + closed = False + except Exception as exc: + logger.warning(f"MaaFW 清理时关闭链接拉起的游戏失败: {exc}") + closed = False + return closed finally: self.opened_game = False self.game_started_at = None + self.game_launched_at = None await self._restore_game_resolution_override() + async def _close_game_after_run(self, *, failed: bool) -> None: + """最终收尾关游戏:MAS 启动的照常关,AttachOnly 再按 ``Game.CloseMode`` 兜底。 + + ``failed`` 为真表示本次运行没正常完成(失败、超时、停止、``on_crash``)。重试之间 + 走的是 ``_restart_client_before_retry``,不经这里,AttachOnly 下不关。 + """ + + await self._close_game() + await self._close_url_game_left_behind() + await self._close_attached_game(failed=failed) + + async def _close_url_game_left_behind(self) -> None: + """URL 模式最终收尾:按本次运行第一次打开链接的时刻,再扫一遍同 exe 的进程。 + + 覆盖「等进程超时之后游戏才出现」:那一轮 _close_game 时还没有进程可关,之后的 + 尝试按「已在运行」接管(opened_game 为假),没有这一步就会留在外面。运行前就开着 + 的游戏不会进 URL 分支(直接接管),所以这个时刻之后出现的同 exe 进程都是 MAS 拉起的。 + """ + + since = self.url_first_launched_at + self.url_first_launched_at = None + game_path = self._resolve_game_launch_path() + if since is None or game_path is None: + return + try: + await self._end_processes_started_since(since, exes=[game_path]) + except Exception as exc: + logger.warning(f"MaaFW 收尾关闭链接拉起的游戏失败: {exc}") + self._append_log(f"关闭游戏失败: {exc}", warning=True) + + def _attached_game_close_wanted(self, *, failed: bool) -> bool: + if self._game_launch_mode() != "AttachOnly": + return False + if self.run_plan is None or self.run_plan.controllerType != "Win32": + return False + mode = str(self.script_config.Game.CloseMode or "OnFailure").strip() + return mode == "Always" or (mode == "OnFailure" and failed) + + async def _close_attached_game(self, *, failed: bool) -> None: + """AttachOnly 下由 MAS 兜底关闭本次运行期间打开的游戏与启动器。 + + 只关创建时间不早于本次运行开始(``run_started_at``,容差 2s)的进程,运行前就开着的 + 一律不碰。候选三路:控制器窗口匹配到的窗口所属进程、``Game.LaunchPath``、 + ``Game.LauncherPath``。每次运行只做一次(做过即清空分界时刻),``on_crash`` 与正常 + 收尾先后来时不会重复关、重复写日志。 + """ + + since = self.run_started_at + self.run_started_at = None + if since is None or not self._attached_game_close_wanted(failed=failed): + return + try: + pids = await asyncio.to_thread(self._attached_game_window_pids) + exes = [ + path + for path in ( + self._resolve_game_launch_path(), + self._resolve_launcher_path(), + ) + if path is not None + ] + await self._end_processes_started_since( + since, exes=exes, pids=pids, log_kept=True + ) + except Exception as exc: + logger.warning(f"MaaFW 收尾关闭游戏失败: {exc}") + self._append_log(f"关闭游戏失败: {exc}", warning=True) + + def _attached_game_window_pids(self) -> set[int]: + """控制器窗口匹配到的窗口所属进程;没有窗口规则、扫不了窗口时返回空集。""" + + if self.interface_model is None or self.run_plan is None: + return set() + try: + controller = _find_controller( + self.interface_model, self.run_plan.controllerName + ) + if not controller_has_window_rules(controller): + return set() + windows = _match_controller_windows(controller) + except Exception as exc: + logger.warning(f"MaaFW 收尾查找游戏窗口失败: {exc}") + return set() + pids: set[int] = set() + for window in windows: + pid = _window_process_id(int(window.hWnd)) + if pid: + pids.add(pid) + return pids + + async def _end_processes_started_since( + self, + since: float, + *, + exes: list[Path], + pids: set[int] | None = None, + log_kept: bool = False, + ) -> bool: + """结束候选里创建时间不早于 ``since``(容差 2s)的进程,每个写一行运行日志。 + + 候选是 pid 在 ``pids`` 里、或 exe 等于 ``exes`` 之一的进程。``log_kept`` 为真时, + 运行前就在的候选也各写一行说明为什么不关。返回该关的是否都关掉了;没关掉的 + (多半是游戏以管理员身份运行、MAS 没有)写一行警告进运行日志。 + """ + + closing, kept = await asyncio.to_thread( + _find_processes_started_since, since, exes=exes, pids=pids or set() + ) + if log_kept: + for process in kept: + self._append_log(f"{process.label} 在运行开始前就已打开,不关闭") + if not closing: + return True + ended, failed = await asyncio.to_thread(_end_processes, closing) + for process in ended: + self._append_log(f"已关闭本次运行期间打开的 {process.label}") + for process in failed: + self._append_log( + f"没能关闭 {process.label},可能需要以管理员身份运行 AUTO-MAS", + warning=True, + ) + return not failed + async def _try_enter_project_path(self) -> bool: project_lock_key = await try_reserve_project_path(self.project_path) if project_lock_key is None: @@ -4287,6 +4530,134 @@ def _is_process_path_running(executable_path: Path) -> bool: return False +async def _open_launch_url(url: str) -> None: + """用系统默认处理程序打开启动链接(Windows 上是 ShellExecute)。""" + + from app.utils import process_platform + + await process_platform.platform_process.open_protocol(url) + + +@dataclass(frozen=True) +class _ProcessSnapshot: + """兜底关闭用的进程快照:exe 读不到时为空串,创建时间读不到时为 None。""" + + pid: int + exe: str + create_time: float | None + + @property + def label(self) -> str: + name = Path(self.exe).name if self.exe else "进程" + return f"{name}(pid={self.pid})" + + +def _normalized_exe(path: str | Path) -> str: + return os.path.normcase(os.path.normpath(str(path))) + + +def _snapshot_processes() -> list[_ProcessSnapshot]: + snapshots: list[_ProcessSnapshot] = [] + for proc in psutil.process_iter(["pid", "exe", "create_time"]): + info = proc.info + snapshots.append( + _ProcessSnapshot( + pid=int(info.get("pid") or proc.pid), + exe=str(info.get("exe") or ""), + create_time=info.get("create_time"), + ) + ) + return snapshots + + +def _window_process_id(hwnd: int) -> int | None: + """窗口所属进程的 pid;拿不到(窗口已销毁、非 Windows)返回 None。""" + + try: + import win32process + + _, pid = win32process.GetWindowThreadProcessId(hwnd) + except Exception: + return None + return int(pid) or None + + +def _find_processes_started_since( + since: float, *, exes: list[Path], pids: set[int] +) -> tuple[list[_ProcessSnapshot], list[_ProcessSnapshot]]: + """按 pid 或 exe 找候选,按创建时间分成(本次之后才出现的, 之前就在的)。 + + 创建时间读不到的算「之前就在」:宁可漏关,不误关用户自己开着的进程。本进程不算候选。 + """ + + targets: set[str] = set() + for path in exes: + with suppress(OSError): + path = path.resolve() + targets.add(_normalized_exe(path)) + own_pid = os.getpid() + closing: list[_ProcessSnapshot] = [] + kept: list[_ProcessSnapshot] = [] + for snapshot in _snapshot_processes(): + if snapshot.pid == own_pid: + continue + matched = snapshot.pid in pids or ( + bool(snapshot.exe) and _normalized_exe(snapshot.exe) in targets + ) + if not matched: + continue + if ( + snapshot.create_time is not None + and snapshot.create_time >= since - _PROCESS_CREATE_TIME_TOLERANCE_SECONDS + ): + closing.append(snapshot) + else: + kept.append(snapshot) + return closing, kept + + +def _end_processes( + targets: list[_ProcessSnapshot], + grace_seconds: float = _PROCESS_TERMINATE_GRACE_SECONDS, +) -> tuple[list[_ProcessSnapshot], list[_ProcessSnapshot]]: + """先 terminate,``grace_seconds`` 后仍在的 kill;返回(已结束的, 没能结束的)。 + + 结束前按创建时间再核一次:快照之后 pid 被别的进程复用了就不碰它(两边都不算)。 + 已经自己退出的也不算失败。 + """ + + started: dict[psutil.Process, _ProcessSnapshot] = {} + failed: list[_ProcessSnapshot] = [] + for target in targets: + try: + proc = psutil.Process(target.pid) + if ( + target.create_time is not None + and abs(proc.create_time() - target.create_time) > 1 + ): + continue + proc.terminate() + except psutil.NoSuchProcess: + continue + except (psutil.AccessDenied, OSError) as exc: + logger.warning(f"MaaFW 结束进程失败 {target.label}: {exc}") + failed.append(target) + continue + started[proc] = target + if not started: + return [], failed + _, alive = psutil.wait_procs(list(started), timeout=grace_seconds) + for proc in alive: + with suppress(psutil.NoSuchProcess, psutil.AccessDenied, OSError): + proc.kill() + if alive: + _, alive = psutil.wait_procs(alive, timeout=grace_seconds) + for proc in alive: + logger.warning(f"MaaFW 结束进程后仍在运行: {started[proc].label}") + failed.append(started[proc]) + return [snapshot for proc, snapshot in started.items() if proc not in alive], failed + + def _optional_int(value: Any) -> int | None: if value in (None, "", "-"): return None diff --git a/changelog.d/feat-mfw-game-close-link-20261113.feat.md b/changelog.d/feat-mfw-game-close-link-20261113.feat.md new file mode 100644 index 0000000000..c061ad5e1f --- /dev/null +++ b/changelog.d/feat-mfw-game-close-link-20261113.feat.md @@ -0,0 +1,2 @@ +project: mfw +PC 游戏支持通过 Steam 等链接启动,由脚本控制时也可由 MAS 兜底关闭 diff --git a/frontend/src/api/models/M9AConfig_Game.ts b/frontend/src/api/models/M9AConfig_Game.ts index bee77de1c6..27176b7891 100644 --- a/frontend/src/api/models/M9AConfig_Game.ts +++ b/frontend/src/api/models/M9AConfig_Game.ts @@ -4,27 +4,39 @@ /* eslint-disable */ export type M9AConfig_Game = { /** - * 游戏启动参数 + * 游戏启动参数,仅 DirectExe 模式使用;URL 模式的参数写进链接 */ Arguments?: (string | null); + /** + * AttachOnly 模式下结束后谁关游戏:Script 交给脚本(MAS 不关);OnFailure 本次运行没正常完成(失败、超时、停止、异常)时由 MAS 关;Always 结束后一律由 MAS 关。MAS 只关本次运行开始之后才出现的进程,重试之间不关。DirectExe 与 URL 模式忽略它 + */ + CloseMode?: ('Script' | 'OnFailure' | 'Always' | null); /** * 托管级键位(PI v2.8 hotkey 选项):``{option 名: {字段名: 组合键字符串}}``, 组合键写成「修饰键+…+主键」(如 ``"Ctrl+E"``)。只存与 interface 默认不同的 字段;运行时叠加到每个任务实际生效的 hotkey 选项上,盖过账号快照 / 预设里的值。 仅 Win32 控制器生效(Adb 等其他控制器不叠加)。interface 里已没有的 option / 字段静默忽略,映射不了的值告警后回落到快照 / 默认值(runner/run_plan.py)。 */ Hotkeys?: (string | null); /** - * 游戏生命周期模式 + * 游戏生命周期模式:DirectExe 由 MAS 按 exe 启动、结束后关闭;URL 由 MAS 打开链接拉起游戏、结束后关闭;AttachOnly 由脚本或用户自己启动,MAS 只接管窗口,结束后是否由 MAS 关闭见 CloseMode */ - LaunchMode?: ('DirectExe' | 'AttachOnly' | null); + LaunchMode?: ('DirectExe' | 'URL' | 'AttachOnly' | null); /** - * DirectExe 模式下 MAS 启动的游戏 exe + * 游戏 exe:DirectExe 模式下 MAS 启动它;URL 模式下必填,用来认出游戏(是否已在运行、等窗口、关闭、Unity 分辨率);AttachOnly 模式下可选,用来认出要关闭的游戏 */ LaunchPath?: (string | null); + /** + * AttachOnly 模式下可选的启动器 exe,MAS 兜底关闭游戏时一并关闭本次运行期间打开的启动器 + */ + LauncherPath?: (string | null); /** * 安卓游戏包名,Adb controller 用:启动模拟器时顺带把游戏拉起来。 留空表示从项目的 pipeline 里自动识别(见 embedded/game_package.py); 自动识别是启发式的,填了这里就以这里为准。识别不出且没填则不启动游戏。 */ PackageName?: (string | null); /** - * DirectExe 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 + * URL 模式下 MAS 打开的链接,如 steam://rungameid/ + */ + URL?: (string | null); + /** + * DirectExe 与 URL 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 */ UnityResolution?: ('Off' | '1920x1080' | '1280x720' | null); /** diff --git a/frontend/src/api/models/M9AConfig_GameInput.ts b/frontend/src/api/models/M9AConfig_GameInput.ts index a24cc9f65a..b32695808e 100644 --- a/frontend/src/api/models/M9AConfig_GameInput.ts +++ b/frontend/src/api/models/M9AConfig_GameInput.ts @@ -4,27 +4,39 @@ /* eslint-disable */ export type M9AConfig_GameInput = { /** - * 游戏启动参数 + * 游戏启动参数,仅 DirectExe 模式使用;URL 模式的参数写进链接 */ Arguments?: (string | null); + /** + * AttachOnly 模式下结束后谁关游戏:Script 交给脚本(MAS 不关);OnFailure 本次运行没正常完成(失败、超时、停止、异常)时由 MAS 关;Always 结束后一律由 MAS 关。MAS 只关本次运行开始之后才出现的进程,重试之间不关。DirectExe 与 URL 模式忽略它 + */ + CloseMode?: ('Script' | 'OnFailure' | 'Always' | null); /** * 托管级键位(PI v2.8 hotkey 选项):``{option 名: {字段名: 组合键字符串}}``, 组合键写成「修饰键+…+主键」(如 ``"Ctrl+E"``)。只存与 interface 默认不同的 字段;运行时叠加到每个任务实际生效的 hotkey 选项上,盖过账号快照 / 预设里的值。 仅 Win32 控制器生效(Adb 等其他控制器不叠加)。interface 里已没有的 option / 字段静默忽略,映射不了的值告警后回落到快照 / 默认值(runner/run_plan.py)。 */ Hotkeys?: (string | null); /** - * 游戏生命周期模式 + * 游戏生命周期模式:DirectExe 由 MAS 按 exe 启动、结束后关闭;URL 由 MAS 打开链接拉起游戏、结束后关闭;AttachOnly 由脚本或用户自己启动,MAS 只接管窗口,结束后是否由 MAS 关闭见 CloseMode */ - LaunchMode?: ('DirectExe' | 'AttachOnly' | null); + LaunchMode?: ('DirectExe' | 'URL' | 'AttachOnly' | null); /** - * DirectExe 模式下 MAS 启动的游戏 exe + * 游戏 exe:DirectExe 模式下 MAS 启动它;URL 模式下必填,用来认出游戏(是否已在运行、等窗口、关闭、Unity 分辨率);AttachOnly 模式下可选,用来认出要关闭的游戏 */ LaunchPath?: (string | null); + /** + * AttachOnly 模式下可选的启动器 exe,MAS 兜底关闭游戏时一并关闭本次运行期间打开的启动器 + */ + LauncherPath?: (string | null); /** * 安卓游戏包名,Adb controller 用:启动模拟器时顺带把游戏拉起来。 留空表示从项目的 pipeline 里自动识别(见 embedded/game_package.py); 自动识别是启发式的,填了这里就以这里为准。识别不出且没填则不启动游戏。 */ PackageName?: (string | null); /** - * DirectExe 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 + * URL 模式下 MAS 打开的链接,如 steam://rungameid/ + */ + URL?: (string | null); + /** + * DirectExe 与 URL 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 */ UnityResolution?: ('Off' | '1920x1080' | '1280x720' | null); /** diff --git a/frontend/src/api/models/MSSConfig_Game.ts b/frontend/src/api/models/MSSConfig_Game.ts index 74d9a6a19e..7d516cbc94 100644 --- a/frontend/src/api/models/MSSConfig_Game.ts +++ b/frontend/src/api/models/MSSConfig_Game.ts @@ -4,27 +4,39 @@ /* eslint-disable */ export type MSSConfig_Game = { /** - * 游戏启动参数 + * 游戏启动参数,仅 DirectExe 模式使用;URL 模式的参数写进链接 */ Arguments?: (string | null); + /** + * AttachOnly 模式下结束后谁关游戏:Script 交给脚本(MAS 不关);OnFailure 本次运行没正常完成(失败、超时、停止、异常)时由 MAS 关;Always 结束后一律由 MAS 关。MAS 只关本次运行开始之后才出现的进程,重试之间不关。DirectExe 与 URL 模式忽略它 + */ + CloseMode?: ('Script' | 'OnFailure' | 'Always' | null); /** * 托管级键位(PI v2.8 hotkey 选项):``{option 名: {字段名: 组合键字符串}}``, 组合键写成「修饰键+…+主键」(如 ``"Ctrl+E"``)。只存与 interface 默认不同的 字段;运行时叠加到每个任务实际生效的 hotkey 选项上,盖过账号快照 / 预设里的值。 仅 Win32 控制器生效(Adb 等其他控制器不叠加)。interface 里已没有的 option / 字段静默忽略,映射不了的值告警后回落到快照 / 默认值(runner/run_plan.py)。 */ Hotkeys?: (string | null); /** - * 游戏生命周期模式 + * 游戏生命周期模式:DirectExe 由 MAS 按 exe 启动、结束后关闭;URL 由 MAS 打开链接拉起游戏、结束后关闭;AttachOnly 由脚本或用户自己启动,MAS 只接管窗口,结束后是否由 MAS 关闭见 CloseMode */ - LaunchMode?: ('DirectExe' | 'AttachOnly' | null); + LaunchMode?: ('DirectExe' | 'URL' | 'AttachOnly' | null); /** - * DirectExe 模式下 MAS 启动的游戏 exe + * 游戏 exe:DirectExe 模式下 MAS 启动它;URL 模式下必填,用来认出游戏(是否已在运行、等窗口、关闭、Unity 分辨率);AttachOnly 模式下可选,用来认出要关闭的游戏 */ LaunchPath?: (string | null); + /** + * AttachOnly 模式下可选的启动器 exe,MAS 兜底关闭游戏时一并关闭本次运行期间打开的启动器 + */ + LauncherPath?: (string | null); /** * 安卓游戏包名,Adb controller 用:启动模拟器时顺带把游戏拉起来。 留空表示从项目的 pipeline 里自动识别(见 embedded/game_package.py); 自动识别是启发式的,填了这里就以这里为准。识别不出且没填则不启动游戏。 */ PackageName?: (string | null); /** - * DirectExe 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 + * URL 模式下 MAS 打开的链接,如 steam://rungameid/ + */ + URL?: (string | null); + /** + * DirectExe 与 URL 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 */ UnityResolution?: ('Off' | '1920x1080' | '1280x720' | null); /** diff --git a/frontend/src/api/models/MSSConfig_GameInput.ts b/frontend/src/api/models/MSSConfig_GameInput.ts index f71fbd9088..e1966f4b66 100644 --- a/frontend/src/api/models/MSSConfig_GameInput.ts +++ b/frontend/src/api/models/MSSConfig_GameInput.ts @@ -4,27 +4,39 @@ /* eslint-disable */ export type MSSConfig_GameInput = { /** - * 游戏启动参数 + * 游戏启动参数,仅 DirectExe 模式使用;URL 模式的参数写进链接 */ Arguments?: (string | null); + /** + * AttachOnly 模式下结束后谁关游戏:Script 交给脚本(MAS 不关);OnFailure 本次运行没正常完成(失败、超时、停止、异常)时由 MAS 关;Always 结束后一律由 MAS 关。MAS 只关本次运行开始之后才出现的进程,重试之间不关。DirectExe 与 URL 模式忽略它 + */ + CloseMode?: ('Script' | 'OnFailure' | 'Always' | null); /** * 托管级键位(PI v2.8 hotkey 选项):``{option 名: {字段名: 组合键字符串}}``, 组合键写成「修饰键+…+主键」(如 ``"Ctrl+E"``)。只存与 interface 默认不同的 字段;运行时叠加到每个任务实际生效的 hotkey 选项上,盖过账号快照 / 预设里的值。 仅 Win32 控制器生效(Adb 等其他控制器不叠加)。interface 里已没有的 option / 字段静默忽略,映射不了的值告警后回落到快照 / 默认值(runner/run_plan.py)。 */ Hotkeys?: (string | null); /** - * 游戏生命周期模式 + * 游戏生命周期模式:DirectExe 由 MAS 按 exe 启动、结束后关闭;URL 由 MAS 打开链接拉起游戏、结束后关闭;AttachOnly 由脚本或用户自己启动,MAS 只接管窗口,结束后是否由 MAS 关闭见 CloseMode */ - LaunchMode?: ('DirectExe' | 'AttachOnly' | null); + LaunchMode?: ('DirectExe' | 'URL' | 'AttachOnly' | null); /** - * DirectExe 模式下 MAS 启动的游戏 exe + * 游戏 exe:DirectExe 模式下 MAS 启动它;URL 模式下必填,用来认出游戏(是否已在运行、等窗口、关闭、Unity 分辨率);AttachOnly 模式下可选,用来认出要关闭的游戏 */ LaunchPath?: (string | null); + /** + * AttachOnly 模式下可选的启动器 exe,MAS 兜底关闭游戏时一并关闭本次运行期间打开的启动器 + */ + LauncherPath?: (string | null); /** * 安卓游戏包名,Adb controller 用:启动模拟器时顺带把游戏拉起来。 留空表示从项目的 pipeline 里自动识别(见 embedded/game_package.py); 自动识别是启发式的,填了这里就以这里为准。识别不出且没填则不启动游戏。 */ PackageName?: (string | null); /** - * DirectExe 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 + * URL 模式下 MAS 打开的链接,如 steam://rungameid/ + */ + URL?: (string | null); + /** + * DirectExe 与 URL 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 */ UnityResolution?: ('Off' | '1920x1080' | '1280x720' | null); /** diff --git a/frontend/src/api/models/MaaFWConfig_Game.ts b/frontend/src/api/models/MaaFWConfig_Game.ts index 416d57bb3f..2ac4e8cd58 100644 --- a/frontend/src/api/models/MaaFWConfig_Game.ts +++ b/frontend/src/api/models/MaaFWConfig_Game.ts @@ -4,27 +4,39 @@ /* eslint-disable */ export type MaaFWConfig_Game = { /** - * 游戏启动参数 + * 游戏启动参数,仅 DirectExe 模式使用;URL 模式的参数写进链接 */ Arguments?: (string | null); + /** + * AttachOnly 模式下结束后谁关游戏:Script 交给脚本(MAS 不关);OnFailure 本次运行没正常完成(失败、超时、停止、异常)时由 MAS 关;Always 结束后一律由 MAS 关。MAS 只关本次运行开始之后才出现的进程,重试之间不关。DirectExe 与 URL 模式忽略它 + */ + CloseMode?: ('Script' | 'OnFailure' | 'Always' | null); /** * 托管级键位(PI v2.8 hotkey 选项):``{option 名: {字段名: 组合键字符串}}``, 组合键写成「修饰键+…+主键」(如 ``"Ctrl+E"``)。只存与 interface 默认不同的 字段;运行时叠加到每个任务实际生效的 hotkey 选项上,盖过账号快照 / 预设里的值。 仅 Win32 控制器生效(Adb 等其他控制器不叠加)。interface 里已没有的 option / 字段静默忽略,映射不了的值告警后回落到快照 / 默认值(runner/run_plan.py)。 */ Hotkeys?: (string | null); /** - * 游戏生命周期模式 + * 游戏生命周期模式:DirectExe 由 MAS 按 exe 启动、结束后关闭;URL 由 MAS 打开链接拉起游戏、结束后关闭;AttachOnly 由脚本或用户自己启动,MAS 只接管窗口,结束后是否由 MAS 关闭见 CloseMode */ - LaunchMode?: ('DirectExe' | 'AttachOnly' | null); + LaunchMode?: ('DirectExe' | 'URL' | 'AttachOnly' | null); /** - * DirectExe 模式下 MAS 启动的游戏 exe + * 游戏 exe:DirectExe 模式下 MAS 启动它;URL 模式下必填,用来认出游戏(是否已在运行、等窗口、关闭、Unity 分辨率);AttachOnly 模式下可选,用来认出要关闭的游戏 */ LaunchPath?: (string | null); + /** + * AttachOnly 模式下可选的启动器 exe,MAS 兜底关闭游戏时一并关闭本次运行期间打开的启动器 + */ + LauncherPath?: (string | null); /** * 安卓游戏包名,Adb controller 用:启动模拟器时顺带把游戏拉起来。 留空表示从项目的 pipeline 里自动识别(见 embedded/game_package.py); 自动识别是启发式的,填了这里就以这里为准。识别不出且没填则不启动游戏。 */ PackageName?: (string | null); /** - * DirectExe 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 + * URL 模式下 MAS 打开的链接,如 steam://rungameid/ + */ + URL?: (string | null); + /** + * DirectExe 与 URL 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 */ UnityResolution?: ('Off' | '1920x1080' | '1280x720' | null); /** diff --git a/frontend/src/api/models/MaaFWConfig_GameInput.ts b/frontend/src/api/models/MaaFWConfig_GameInput.ts index abab2b8e49..c959394fbf 100644 --- a/frontend/src/api/models/MaaFWConfig_GameInput.ts +++ b/frontend/src/api/models/MaaFWConfig_GameInput.ts @@ -4,27 +4,39 @@ /* eslint-disable */ export type MaaFWConfig_GameInput = { /** - * 游戏启动参数 + * 游戏启动参数,仅 DirectExe 模式使用;URL 模式的参数写进链接 */ Arguments?: (string | null); + /** + * AttachOnly 模式下结束后谁关游戏:Script 交给脚本(MAS 不关);OnFailure 本次运行没正常完成(失败、超时、停止、异常)时由 MAS 关;Always 结束后一律由 MAS 关。MAS 只关本次运行开始之后才出现的进程,重试之间不关。DirectExe 与 URL 模式忽略它 + */ + CloseMode?: ('Script' | 'OnFailure' | 'Always' | null); /** * 托管级键位(PI v2.8 hotkey 选项):``{option 名: {字段名: 组合键字符串}}``, 组合键写成「修饰键+…+主键」(如 ``"Ctrl+E"``)。只存与 interface 默认不同的 字段;运行时叠加到每个任务实际生效的 hotkey 选项上,盖过账号快照 / 预设里的值。 仅 Win32 控制器生效(Adb 等其他控制器不叠加)。interface 里已没有的 option / 字段静默忽略,映射不了的值告警后回落到快照 / 默认值(runner/run_plan.py)。 */ Hotkeys?: (string | null); /** - * 游戏生命周期模式 + * 游戏生命周期模式:DirectExe 由 MAS 按 exe 启动、结束后关闭;URL 由 MAS 打开链接拉起游戏、结束后关闭;AttachOnly 由脚本或用户自己启动,MAS 只接管窗口,结束后是否由 MAS 关闭见 CloseMode */ - LaunchMode?: ('DirectExe' | 'AttachOnly' | null); + LaunchMode?: ('DirectExe' | 'URL' | 'AttachOnly' | null); /** - * DirectExe 模式下 MAS 启动的游戏 exe + * 游戏 exe:DirectExe 模式下 MAS 启动它;URL 模式下必填,用来认出游戏(是否已在运行、等窗口、关闭、Unity 分辨率);AttachOnly 模式下可选,用来认出要关闭的游戏 */ LaunchPath?: (string | null); + /** + * AttachOnly 模式下可选的启动器 exe,MAS 兜底关闭游戏时一并关闭本次运行期间打开的启动器 + */ + LauncherPath?: (string | null); /** * 安卓游戏包名,Adb controller 用:启动模拟器时顺带把游戏拉起来。 留空表示从项目的 pipeline 里自动识别(见 embedded/game_package.py); 自动识别是启发式的,填了这里就以这里为准。识别不出且没填则不启动游戏。 */ PackageName?: (string | null); /** - * DirectExe 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 + * URL 模式下 MAS 打开的链接,如 steam://rungameid/ + */ + URL?: (string | null); + /** + * DirectExe 与 URL 模式下,启动游戏前按 exe 路径反查 Unity 注册表,临时把分辨率改成所选 尺寸的窗口模式,游戏关闭后恢复原值(tools/embedded/game_resolution.py)。 只对 Unity 引擎的游戏有效;游戏已在运行时不改。Off 表示不碰。 */ UnityResolution?: ('Off' | '1920x1080' | '1280x720' | null); /** diff --git a/frontend/src/composables/useMaaFWScriptConfig.ts b/frontend/src/composables/useMaaFWScriptConfig.ts index cbc1879e3c..279a0f881b 100644 --- a/frontend/src/composables/useMaaFWScriptConfig.ts +++ b/frontend/src/composables/useMaaFWScriptConfig.ts @@ -95,6 +95,9 @@ export const getDefaultMaaFWScriptConfig = (): MaaFWScriptConfig => ({ Game: { LaunchMode: 'DirectExe', LaunchPath: '', + LauncherPath: '', + URL: '', + CloseMode: 'OnFailure', PackageName: '', Arguments: '', WaitTime: 120, @@ -407,7 +410,8 @@ export function useMaaFWControlConfig( await loadEmulatorDeviceOptions(emulatorId) } - const selectLaunchPath = async () => { + // 游戏 exe 与启动器 exe 共用同一个选择流程,只差写回哪个字段 + const selectGameExePath = async (key: 'LaunchPath' | 'LauncherPath') => { try { const paths = await window.electronAPI?.selectFile([ { @@ -424,15 +428,18 @@ export function useMaaFWControlConfig( return } - maafwConfig.Game.LaunchPath = path - await handleChange('Game', 'LaunchPath', path) + maafwConfig.Game[key] = path + await handleChange('Game', key, path) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) - logger.error(`选择启动 exe 失败: ${errorMsg}`) + logger.error(`选择 ${key} exe 失败: ${errorMsg}`) message.error(t('core.misc.couldNotPickLaunch')) } } + const selectLaunchPath = () => selectGameExePath('LaunchPath') + const selectLauncherPath = () => selectGameExePath('LauncherPath') + return { emulatorLoading, emulatorOptionsReady, @@ -460,5 +467,6 @@ export function useMaaFWControlConfig( loadEmulatorDeviceOptions, handleEmulatorSelectChange, selectLaunchPath, + selectLauncherPath, } } diff --git a/frontend/src/types/script.ts b/frontend/src/types/script.ts index 859a3c4cf3..fa0bc51e6d 100644 --- a/frontend/src/types/script.ts +++ b/frontend/src/types/script.ts @@ -118,7 +118,9 @@ export interface MaaEndScriptConfig { export type HSRScriptConfig = HSRConfig // MaaFramework 项目脚本配置(宿主 Config v1;托管字段仍保留兼容读取) -export type MaaFWLaunchMode = 'DirectExe' | 'AttachOnly' +export type MaaFWLaunchMode = 'DirectExe' | 'URL' | 'AttachOnly' +/** 由脚本自身控制游戏时,结束后谁关游戏:交给脚本 / 没正常完成时由 MAS 关 / 总是由 MAS 关。 */ +export type MaaFWCloseMode = 'Script' | 'OnFailure' | 'Always' /** 启动 Unity 游戏前临时改成的窗口分辨率;Off 不修改。 */ export type MaaFWUnityResolution = 'Off' | '1920x1080' | '1280x720' @@ -157,15 +159,25 @@ export interface MaaFWScriptConfig { PlayCoverUuid: string } Game: { - /** DirectExe:MAS 启动并在结束后关闭(默认);AttachOnly:其他方式启停,MAS 只接管。 */ + /** + * DirectExe:MAS 按 exe 启动并在结束后关闭(默认);URL:MAS 打开链接拉起游戏、结束后关闭; + * AttachOnly:脚本或用户自己启动,MAS 只接管窗口,结束后关不关看 CloseMode。 + */ LaunchMode: MaaFWLaunchMode + /** 游戏 exe:DirectExe 下由 MAS 启动;URL 下必填、只用来识别;AttachOnly 下可选、用来识别要关的游戏。 */ LaunchPath: string | null + /** AttachOnly 下可选的启动器 exe,MAS 兜底关闭时一并关闭本次运行期间打开的启动器。 */ + LauncherPath: string | null + /** URL 模式下打开的链接,如 steam://rungameid/;启动参数写进链接。 */ + URL: string + /** AttachOnly 下结束后谁关游戏;只关本次运行开始后才打开的进程。 */ + CloseMode: MaaFWCloseMode /** 安卓游戏包名,留空则从项目的 pipeline 中自动识别。 */ PackageName: string Arguments: string WaitTime: number /** 由 MAS 启动游戏时,窗口出现后至少再等多少秒才下发第一个任务;0 关闭。 */ - /** DirectExe 下启动前按 exe 反查 Unity 注册表,临时改成所选窗口尺寸,关闭后恢复。 */ + /** DirectExe / URL 下启动前按 exe 反查 Unity 注册表,临时改成所选窗口尺寸,关闭后恢复。 */ UnityResolution: MaaFWUnityResolution /** * PI v2.8 hotkey 键位映射(仅 Win32):JSON 文本 `{option 名: {字段名: 组合键}}`, diff --git a/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts b/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts index 63ca0784b6..b88f02d3cf 100644 --- a/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts +++ b/frontend/src/views/EditView/MaaFWFlavor/sectionContracts.ts @@ -117,6 +117,8 @@ export interface MaaFWScriptControlSectionEmits { 'resource-change': [] 'emulator-select-change': [emulatorId: string] 'select-launch-path': [] + /** 由脚本控制游戏时选启动器 exe(MAS 兜底关闭时一并关它) */ + 'select-launcher-path': [] } /** 脚本页 `update`:项目更新来源、渠道、CDK 与检查 / 应用 */ diff --git a/frontend/src/views/EditView/Script/MaaFWScriptEdit.vue b/frontend/src/views/EditView/Script/MaaFWScriptEdit.vue index 857d40e978..b56c1ab70c 100644 --- a/frontend/src/views/EditView/Script/MaaFWScriptEdit.vue +++ b/frontend/src/views/EditView/Script/MaaFWScriptEdit.vue @@ -102,6 +102,7 @@ @resource-change="handleResourceChangeWithPackage" @emulator-select-change="handleEmulatorSelectChange" @select-launch-path="selectLaunchPath" + @select-launcher-path="selectLauncherPath" > + + diff --git a/frontend/src/views/EditView/Script/MaaFWScriptEdit/MaaFWHotkeyField.vue b/frontend/src/views/EditView/Script/MaaFWScriptEdit/MaaFWHotkeyField.vue index 89da6b56eb..fd5f99aad7 100644 --- a/frontend/src/views/EditView/Script/MaaFWScriptEdit/MaaFWHotkeyField.vue +++ b/frontend/src/views/EditView/Script/MaaFWScriptEdit/MaaFWHotkeyField.vue @@ -1,6 +1,6 @@