diff --git a/app/core/ws/protocol.py b/app/core/ws/protocol.py index 045b74b18b..a37a3f43a6 100644 --- a/app/core/ws/protocol.py +++ b/app/core/ws/protocol.py @@ -66,6 +66,11 @@ TASK_SCRIPT_STARTED = "task.script.started" TASK_SCRIPT_FINISHED = "task.script.finished" +# BetterGI 上次安装未确认时的单次交互请求与关闭(id=TaskManager) +BETTERGI_INSTALL_PROMPT = "bettergi.install.prompt" +BETTERGI_INSTALL_PROMPT_CLOSED = "bettergi.install.prompt.closed" +BETTERGI_INSTALL_DECISION = "bettergi.install.decision" + # 应用生命周期与电源(id=Main) BACKEND_SHUTDOWN_READY = "backend.shutdown.ready" # 启动期攒下来、主连接建立后一次性发出的系统通知(id=Main):配置迁移结果之类。 diff --git a/app/task/BetterGI/manager.py b/app/task/BetterGI/manager.py index ca7c4eacc2..d3afac2a57 100644 --- a/app/task/BetterGI/manager.py +++ b/app/task/BetterGI/manager.py @@ -15,6 +15,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with AUTO-MAS. If not, see . +import asyncio import uuid from contextlib import ExitStack, suppress from datetime import datetime @@ -36,7 +37,7 @@ from .AutoProxy import _BGI_REL_EXE, AutoProxyTask from .ScriptConfig import ScriptConfigTask -from .tools import archive_native_backup, program_update +from .tools import archive_native_backup, install_recovery, program_update from .Update import BetterGIUpdateTask logger = get_logger("BetterGI 调度器") @@ -77,8 +78,14 @@ async def check(self) -> str: if not script_config.Info.RootPath: return "请先设置有效的 BetterGI 脚本路径" root_path = script_config.Info.RootPath - if not root_path.is_dir() or not (root_path / _BGI_REL_EXE).is_file(): + if not root_path.is_dir(): return "请先设置有效的 BetterGI 脚本路径" + if not (root_path / _BGI_REL_EXE).is_file(): + marker = script_install.workspace(root_path) / "bgi-install.json" + # 唯有上轮安装保护标记存在时,允许进入完整安装恢复询问。 + # 无标记的失效路径仍然拒绝,不能直接打开损坏程序。 + if marker.is_symlink() or not marker.is_file(): + return "请先设置有效的 BetterGI 脚本路径" target_user_id = self.task_info.user_id or "Default" if target_user_id != "Default": try: @@ -167,6 +174,62 @@ async def prepare(self): self.build_proxy_user_list(self.user_config.items()) ) + async def _run_program_update( + self, root: Path, *, recovery_snapshot: bytes | None = None + ) -> str: + await self.script_info.set_log("正在检查 BetterGI 本体更新…") + + async def progress(downloaded: int, total: int, speed: float) -> None: + await self.script_info.set_log( + f"BetterGI 更新下载:{downloaded / 1024**2:.1f} / {total / 1024**2:.1f} MB" + ) + + return await run_script_update( + ScriptUpdateRequest(self.script_info.script_id, "BetterGI"), + program_update.update_before_run, + root, + self.script_config, + progress=progress, + recovery_snapshot=recovery_snapshot, + ) + + async def _recover_unconfirmed_install(self) -> bool: + """残留标记由用户明确决定;关闭自动接管时也允许主动重试一次。""" + root = self._update_root + if root is None: + return False + marker = script_install.workspace(root) / "bgi-install.json" + if not marker.exists() and not marker.is_symlink(): + return False + + # 持有安装锁,但还须排除 MAS 之外手工启动的安装器和运行中的 BGI。 + await asyncio.to_thread(program_update.assert_idle, root) + action, snapshot = await install_recovery.ask_install_recovery( + self.task_info.task_id, + root, + managed_name=getattr(self.script_info, "name", "BetterGI"), + channel=self.script_config.Update.Channel, + takeover_enabled=self.script_config.Update.TakeoverEnabled, + ) + if action == "skip": + version = await install_recovery.skip_unconfirmed_install(root, snapshot) + result = f"用户确认跳过 BetterGI 未完成安装,当前版本 {version},继续任务" + logger.warning(result) + await self.script_info.set_log(result) + with suppress(Exception): + await Publisher.send( + id=self.task_info.task_id, + type=protocol.TASK_NOTICE, + data=WSTaskNoticeData(level="warning", message=result), + ) + else: + # 用户主动选择重试:即使 TakeoverEnabled=False,也只为本次恢复调用更新器。 + # 不走普通更新失败的旧版回退:安装标记仍在时任何失败都必须停止。 + result = await self._run_program_update(root, recovery_snapshot=snapshot) + await program_update.confirm_current_install(root) + await self.script_info.set_log(f"BetterGI 安装恢复成功:{result}") + return True + async def _prepare_program_update(self) -> None: root = self._update_root if root is None: @@ -175,24 +238,24 @@ async def _prepare_program_update(self) -> None: if self._program_update_prepared: if self.script_config.Update.TakeoverEnabled: await program_update.confirm_current_install(root) + elif (script_install.workspace(root) / "bgi-install.json").exists(): + raise script_install.InstallUnsafeError( + "BetterGI 安装状态在任务期间发生变化,已停止启动" + ) self.check_result = "Pass" return - if self.script_config.Update.TakeoverEnabled: - await self.script_info.set_log("正在检查 BetterGI 本体更新…") - async def progress(downloaded: int, total: int, speed: float) -> None: - await self.script_info.set_log( - f"BetterGI 更新下载:{downloaded / 1024**2:.1f} / {total / 1024**2:.1f} MB" - ) + if await self._recover_unconfirmed_install(): + # 无论重试还是跳过,本轮已由用户确定更新动作;不能继续自动更新第二次。 + if self.script_config.Update.TakeoverEnabled: + program_update.suppress_update_notice(root) + self._program_update_prepared = True + self.check_result = "Pass" + return + if self.script_config.Update.TakeoverEnabled: try: - result = await run_script_update( - ScriptUpdateRequest(self.script_info.script_id, "BetterGI"), - program_update.update_before_run, - root, - self.script_config, - progress=progress, - ) + result = await self._run_program_update(root) except (script_install.InstallUnsafeError, FluxdownCancelUnconfirmed): raise except Exception as error: @@ -208,11 +271,11 @@ async def progress(downloaded: int, total: int, speed: float) -> None: await self.script_info.set_log(result) program_update.suppress_update_notice(root) else: - # 关闭接管仍不能启动上轮未确认安装;不发远程请求。 + # 关闭自动接管不等于自动忽略上次更新记录;状态变化时保守停止。 marker = script_install.workspace(root) / "bgi-install.json" if marker.exists(): raise script_install.InstallUnsafeError( - "BetterGI 上次安装未确认,请开启更新托管重试或手动修复" + "BetterGI 上次安装未确认,无法直接启动" ) self._program_update_prepared = True self.check_result = "Pass" @@ -237,10 +300,12 @@ async def main_task(self): # Update 只更新原神客户端,不启动 BetterGI;运行中的正常 BGI # 不应该阻断游戏更新,但未完成的本体安装必须阻断。 root = self._update_root - if root is not None and self.execution_mode == "Update": - await program_update.confirm_safe_game_update(root) - elif root is not None and self.execution_mode == "ScriptConfig": - await program_update.confirm_current_install(root) + if root is not None and self.execution_mode in ("Update", "ScriptConfig"): + await self._recover_unconfirmed_install() + if self.execution_mode == "Update": + await program_update.confirm_safe_game_update(root) + else: + await program_update.confirm_current_install(root) # 「检查更新」不动 BetterGI 配置,不必归档,直接进更新任务 if self.execution_mode == "Update": diff --git a/app/task/BetterGI/tools/install_recovery.py b/app/task/BetterGI/tools/install_recovery.py new file mode 100644 index 0000000000..f352c60f78 --- /dev/null +++ b/app/task/BetterGI/tools/install_recovery.py @@ -0,0 +1,198 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""BetterGI 未确认本体安装的交互恢复。 + +等待选择时持有专项原有安装锁;没有前端、超时、取消或连接丢失都不默认放行。 +本模块只收集一次显式决定,实际清除标记及重新安装仍由专项执行并复核。 +""" + +import asyncio +import json +from pathlib import Path +from uuid import uuid4 + +from app.core import Dispatcher, Publisher +from app.core.ws import protocol +from app.services import script_install +from app.utils import get_logger + +from . import program_update + +logger = get_logger("BetterGI 安装恢复") +_PROMPT_TIMEOUT_SECONDS = 120 + + +async def ask_install_recovery( + task_id: str, + root: Path, + *, + channel: str, + takeover_enabled: bool, + managed_name: str = "BetterGI", +) -> tuple[str, bytes]: + """返回 (retry/skip, 原标记字节),让后续操作对同一份安装记录做乐观校验。""" + marker = script_install.workspace(root) / "bgi-install.json" + if marker.is_symlink() or not marker.is_file(): + raise script_install.InstallUnsafeError( + "BetterGI 安装恢复记录不是普通文件,请保留现场" + ) + snapshot = await asyncio.to_thread(marker.read_bytes) + if len(snapshot) > 64 * 1024: + raise script_install.InstallUnsafeError( + "BetterGI 安装标记异常,请手动检查安装状态" + ) + try: + content = json.loads(snapshot) + target = ( + str(content.get("target", "未知"))[:64] + if isinstance(content, dict) + else "未知" + ) + except (ValueError, UnicodeDecodeError): + target = "未知" + + try: + installed = str(await asyncio.to_thread(program_update.read_version, root)) + except Exception as error: + # 元数据不可读时仍允许用户选择重试;跳过阶段才严格校验。 + logger.warning(f"读取 BetterGI 当前版本失败,恢复提示显示未知:{error}") + installed = "未知" + + request_id = uuid4().hex + future: asyncio.Future[str] = asyncio.get_running_loop().create_future() + + def receive(envelope) -> None: + data = envelope.data + if ( + data.get("requestId") == request_id + and data.get("taskId") == task_id + and data.get("action") in ("retry", "skip", "stop") + and not future.done() + ): + future.set_result(data["action"]) + + unregister = Dispatcher.register( + protocol.ID_TASK_MANAGER, protocol.BETTERGI_INSTALL_DECISION, receive + ) + try: + sent = await Publisher.send( + id=protocol.ID_TASK_MANAGER, + type=protocol.BETTERGI_INSTALL_PROMPT, + data={ + "requestId": request_id, + "taskId": task_id, + "channel": channel, + "takeoverEnabled": takeover_enabled, + "installedVersion": installed, + "targetVersion": target, + "managedName": managed_name, + "installPath": str(root.resolve()), + }, + ) + if not sent: + raise script_install.InstallUnsafeError( + "BetterGI 上次安装未确认;未连接主界面,无法获取恢复选择,请打开 MAS 界面重试" + ) + logger.warning( + f"BetterGI 安装未确认,等待用户选择:当前={installed}," + f"原目标={target},通道={channel},自动接管={takeover_enabled}" + ) + try: + action = await asyncio.wait_for(future, timeout=_PROMPT_TIMEOUT_SECONDS) + except TimeoutError as error: + raise script_install.InstallUnsafeError( + "BetterGI 安装恢复选择超时,本次任务已停止;未修改安装标记" + ) from error + if action == "stop": + raise script_install.InstallUnsafeError( + "用户取消 BetterGI 安装恢复,本次任务已停止;未修改安装标记" + ) + if ( + marker.is_symlink() + or not marker.is_file() + or await asyncio.to_thread(marker.read_bytes) != snapshot + ): + raise script_install.InstallUnsafeError( + "BetterGI 安装状态在等待选择时发生变化,本次任务已停止,请重新检查" + ) + logger.warning(f"用户选择 BetterGI 安装恢复操作:{action}") + return action, snapshot + finally: + unregister() + # 让超时、手动停止及任务取消的旧弹窗失效;迟到回复不会再被接受。 + try: + await Publisher.send( + id=protocol.ID_TASK_MANAGER, + type=protocol.BETTERGI_INSTALL_PROMPT_CLOSED, + data={"requestId": request_id}, + ) + except Exception: + logger.warning("发送 BetterGI 安装恢复弹窗关闭通知失败") + + +async def skip_unconfirmed_install(root: Path, snapshot: bytes) -> str: + """在用户明确确认后跳过一次残留安装;先核查程序,最后清除同一份标记。 + + 只允许拥有安装锁的 BetterGIManager 调用。无法证明进程停止、标记未变、 + 当前可执行文件版本/架构可识别时,不能清理标记。 + """ + from packaging.version import Version + + marker = script_install.workspace(root) / "bgi-install.json" + try: + record = json.loads(snapshot) + if not isinstance(record, dict) or not isinstance(record.get("target"), str): + raise ValueError("安装标记格式不正确") + Version(record["target"]) + except (ValueError, TypeError) as error: + raise script_install.InstallUnsafeError( + "BetterGI 安装标记内容异常,不能直接跳过,请使用官方安装器修复" + ) from error + + # 不能只看安装目录:官方安装器可能从用户的下载文件夹启动。 + await asyncio.to_thread(program_update.assert_idle, root) + if ( + marker.is_symlink() + or not marker.is_file() + or await asyncio.to_thread(marker.read_bytes) != snapshot + ): + raise script_install.InstallUnsafeError( + "BetterGI 安装恢复记录已经变化,本次不清理,请重新检查" + ) + try: + version = await asyncio.to_thread(program_update.read_version, root) + arch = await asyncio.to_thread( + script_install.read_pe_architecture, root / "BetterGI.exe" + ) + if arch != "x86_64": + raise ValueError("程序架构不可识别") + except Exception as error: + raise script_install.InstallUnsafeError( + "BetterGI 当前程序版本或架构不可识别,不能跳过,请先手动修复" + ) from error + + # 版本及架构检查不保证所有附属文件完整;这是用户主动接受的风险。 + await asyncio.to_thread(program_update.assert_idle, root) + if ( + marker.is_symlink() + or not marker.is_file() + or await asyncio.to_thread(marker.read_bytes) != snapshot + ): + raise script_install.InstallUnsafeError("BetterGI 安装记录已变化,拒绝跳过") + marker.unlink() + try: + await program_update.confirm_current_install(root) + except BaseException: + # 最后的退出/版本复核失败时,尽可能恢复原标记,避免失去安全门禁。 + if not marker.exists(): + try: + marker.write_bytes(snapshot) + except OSError as error: + logger.error(f"BetterGI 无法恢复安装保护标记:{error}") + raise + logger.warning( + f"用户确认跳过 BetterGI 未完成安装,已清除原标记;当前版本={version}" + ) + return str(version) diff --git a/app/task/BetterGI/tools/program_update.py b/app/task/BetterGI/tools/program_update.py index 1a7be81403..456c3127d3 100644 --- a/app/task/BetterGI/tools/program_update.py +++ b/app/task/BetterGI/tools/program_update.py @@ -10,6 +10,7 @@ import asyncio import hashlib +import json import re import tempfile import zipfile @@ -29,7 +30,7 @@ DownloadProgress, request_mirror_resource, ) -from app.utils import io as _utils_io +from app.utils import get_logger, io as _utils_io from app.utils.common.process.runner import ( create_subprocess, observe_process_exit, @@ -41,6 +42,7 @@ # BetterGI 使用严格 SemVer 比较,不能填通配符或空的版本号。 IGNORED_VERSION = "2147483647.0.0" _INSTALLER_CLEANUP_SECONDS = 5 +logger = get_logger("BetterGI 本体更新") def assert_idle(root: Path) -> None: @@ -49,10 +51,19 @@ def assert_idle(root: Path) -> None: try: executable = process.info["exe"] name = str(process.info.get("name", "")).casefold() - if not executable and ( - name in {"bettergi.exe", "bettergi.update.exe"} + # 安装器可以由用户从下载目录手动启动,不一定在 root/workspace 内。 + # 只要存在同名安装器就不能在恢复时清除标记。 + exe_name = Path(executable).name.casefold() if executable else "" + if ( + name == "bettergi.update.exe" or name.startswith("bettergi.install.") + or exe_name == "bettergi.update.exe" + or exe_name.startswith("bettergi.install.") ): + raise script_install.InstallUnsafeError( + "BetterGI 安装器仍在运行,请先结束安装或退出安装器" + ) + if not executable and name == "bettergi.exe": raise script_install.InstallUnsafeError( "无法确认 BetterGI 更新器已退出,请以管理员身份重试" ) @@ -347,12 +358,54 @@ async def run_installer(installer: Path, root: Path) -> None: async def update_before_run( - root: Path, config: BetterGIConfig, *, progress: DownloadProgress | None = None + root: Path, + config: BetterGIConfig, + *, + progress: DownloadProgress | None = None, + recovery_snapshot: bytes | None = None, ) -> str: await asyncio.to_thread(assert_idle, root) - current = await asyncio.to_thread(read_version, root) - if script_install.read_pe_architecture(root / "BetterGI.exe") != "x86_64": - raise script_install.InstallUnsafeError("BetterGI 当前安装架构不支持自动更新") + directory = script_install.workspace(root) + marker = directory / "bgi-install.json" + + # 只有用户在恢复对话框中明确选择的那份记录,才可触发损坏本体修复。 + # 普通自动更新仍然必须先确认旧版程序存在、版本与架构均可识别。 + recorded_target = None + if recovery_snapshot is not None: + if ( + marker.is_symlink() + or not marker.is_file() + or await asyncio.to_thread(marker.read_bytes) != recovery_snapshot + ): + raise script_install.InstallUnsafeError( + "BetterGI 安装恢复记录已变化,拒绝重试安装" + ) + try: + record = json.loads(recovery_snapshot) + if not isinstance(record, dict) or not isinstance( + record.get("target"), str + ): + raise ValueError("无效的安装目标") + recorded_target = Version(record["target"]) + except (ValueError, TypeError) as error: + raise script_install.InstallUnsafeError( + "BetterGI 安装恢复记录异常,请使用官方安装器修复" + ) from error + + try: + current = await asyncio.to_thread(read_version, root) + if script_install.read_pe_architecture(root / "BetterGI.exe") != "x86_64": + raise script_install.InstallUnsafeError( + "BetterGI 当前安装架构不支持自动更新" + ) + except Exception: + if recovery_snapshot is None: + raise + current = None + logger.warning( + "BetterGI 恢复安装:当前程序缺失或损坏,将校验并重新安装完整安装包" + ) + cdk = ( config.Update.MirrorChyanCDK.strip() or Config.setting.Update.MirrorChyanCDK.strip() @@ -384,13 +437,17 @@ async def update_before_run( await request_release(config.Update.Channel) ) - directory = script_install.workspace(root) - marker = directory / "bgi-install.json" - if target <= current and not marker.exists(): + logger.info( + f"BetterGI 版本检查:当前={current or '无法识别'},目标={target}," + f"通道={config.Update.Channel},存在未确认标记={marker.exists()}" + ) + if current is not None and target <= current and not marker.exists(): return f"BetterGI 当前版本 {current} 无需更新" - if target < current: + if (current is not None and target < current) or ( + recorded_target is not None and target < recorded_target + ): raise script_install.InstallUnsafeError( - "BetterGI 上次安装未完成且高于所选通道,请切换通道或手动修复,不会自动降级" + "BetterGI 更新目标低于当前或上次安装的版本,拒绝自动降级;请切换通道或手动修复" ) # Mirror 包有独立分发校验;GitHub 正式版须等待官方公告及 OTA 资源同步。 if not cdk and config.Update.Channel == "stable": @@ -443,6 +500,8 @@ async def update_before_run( or not 0 < mirror_size <= 4 * 1024**3 ): raise ValueError("Mirror 酱 BetterGI 安装包大小无效") + # 不记录带鉴权信息的 URL/CDK,只记录来源与目标版本。 + logger.info(f"BetterGI 下载安装器:来源={source},目标={target},文件={name}") # 失败/取消时保留独立下载目标,不删除可能仍由 FluxDown 写入的文件。 await download_script_package( url, @@ -464,14 +523,30 @@ async def update_before_run( else: await asyncio.to_thread(verify_installer, installer, size, digest) await asyncio.to_thread(assert_idle, root) + if recovery_snapshot is not None and ( + marker.is_symlink() + or not marker.is_file() + or await asyncio.to_thread(marker.read_bytes) != recovery_snapshot + ): + raise script_install.InstallUnsafeError( + "BetterGI 安装恢复记录在下载期间发生变化,禁止覆盖标记" + ) _utils_io.write_file(marker, {"target": str(target)}) - await run_installer(installer, root) + logger.warning(f"BetterGI 官方安装器开始执行,安装标记已写入:目标={target}") + try: + await run_installer(installer, root) + except BaseException as error: + logger.warning( + f"BetterGI 安装未确认,保留标记:{type(error).__name__}: {error}" + ) + raise await asyncio.to_thread(assert_idle, root) if await asyncio.to_thread(read_version, root) != target: raise script_install.InstallUnsafeError( "BetterGI 安装后版本未确认,请使用官方安装器修复" ) marker.unlink() + logger.info(f"BetterGI 官方安装结果确认成功,版本={target},已清理安装标记") # 仅在确认安装器和全部子进程退出后回收;失败保留现场。 import shutil diff --git a/changelog.d/1900.fix.md b/changelog.d/1900.fix.md new file mode 100644 index 0000000000..4a04653267 --- /dev/null +++ b/changelog.d/1900.fix.md @@ -0,0 +1,2 @@ +project: bgi +修复 BetterGI 更新中断后残留安装记录导致任务无法继续的问题 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 543f4338d2..0cc3463aa1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -13,6 +13,7 @@ import { useAppInitialization } from './composables/useAppInitialization.ts' import AppLayout from './components/AppLayout.vue' import TitleBar from './components/TitleBar.vue' import UpdateModal from './components/UpdateModal.vue' +import BetterGIInstallRecoveryModal from './components/BetterGIInstallRecoveryModal.vue' import GlobalPowerCountdown from './components/GlobalPowerCountdown.vue' import AppClosingOverlay from './components/AppClosingOverlay.vue' import BackendStartupOverlay from './components/BackendStartupOverlay.vue' @@ -155,6 +156,8 @@ onMounted(async () => { + +
diff --git a/frontend/src/bootstrap/realtimeResidents.ts b/frontend/src/bootstrap/realtimeResidents.ts index 0efa1d2859..182b52eff8 100644 --- a/frontend/src/bootstrap/realtimeResidents.ts +++ b/frontend/src/bootstrap/realtimeResidents.ts @@ -21,6 +21,10 @@ import { bootstrapVirtualDisplayPrompt, disposeVirtualDisplayPrompt, } from '@/services/virtualDisplayPrompt' +import { + bootstrapBetterGIInstallRecovery, + disposeBetterGIInstallRecovery, +} from '@/services/bettergiInstallRecovery' let registered = false @@ -71,6 +75,10 @@ export function bootstrapRealtimeResidents(): void { bootstrap: bootstrapVirtualDisplayPrompt, dispose: disposeVirtualDisplayPrompt, }) + registerResidentResource('bettergi-install-recovery', { + bootstrap: bootstrapBetterGIInstallRecovery, + dispose: disposeBetterGIInstallRecovery, + }) registered = true } bootstrapResidentResources() diff --git a/frontend/src/components/BetterGIInstallRecoveryModal.vue b/frontend/src/components/BetterGIInstallRecoveryModal.vue new file mode 100644 index 0000000000..a99fe38e6c --- /dev/null +++ b/frontend/src/components/BetterGIInstallRecoveryModal.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/frontend/src/i18n/bootstrap/en-US.json b/frontend/src/i18n/bootstrap/en-US.json index f5abbfe3aa..b6f915a0b1 100644 --- a/frontend/src/i18n/bootstrap/en-US.json +++ b/frontend/src/i18n/bootstrap/en-US.json @@ -3,6 +3,25 @@ "languageSaveFailed": "Failed to save the language setting; reverted to the previous one." }, "comp": { + "bettergiRecoveryStable": "Stable", + "bettergiRecoveryBeta": "Beta", + "bettergiRecoveryTitle": "Previous BetterGI installation is unconfirmed", + "bettergiRecoveryMessage": "MAS found an unresolved BetterGI installation record. The release resources may not have finished publishing, or installation may have been interrupted. Choose how this task should proceed.", + "bettergiRecoveryManagedName": "Managed profile", + "bettergiRecoveryInstallPath": "Installation folder", + "bettergiRecoveryCurrent": "Installed version", + "bettergiRecoveryTarget": "Previous target", + "bettergiRecoveryChannel": "Update channel", + "bettergiRecoveryTakeover": "Managed updates", + "bettergiRecoveryEnabled": "Enabled", + "bettergiRecoveryDisabled": "Disabled", + "bettergiRecoveryRisk": "If the previous installer partially modified the installation, skipping could run incomplete files. MAS checks the executable version and architecture and ensures installers have stopped, but cannot verify every file.", + "bettergiRecoveryAcknowledgement": "I have repaired the installation with the official installer or confirmed BetterGI works, and understand the risk of skipping.", + "bettergiRecoveryRetry": "Retry installation", + "bettergiRecoverySkip": "Skip installation and continue", + "bettergiRecoveryStop": "Cancel this task", + "bettergiRecoveryUnknown": "Unknown", + "bettergiRecoverySendFailed": "Could not send your choice. Check the backend connection and try again.", "backendAboutUpdateWhich": "The backend is about to update, which restarts it and interrupts whatever is running. Continue?", "backendStarting": "Starting the backend", "backendUpdateAvailableClick": "A backend update is available — click to update", diff --git a/frontend/src/i18n/bootstrap/ja-JP.json b/frontend/src/i18n/bootstrap/ja-JP.json index 2b4ed31807..f692b42992 100644 --- a/frontend/src/i18n/bootstrap/ja-JP.json +++ b/frontend/src/i18n/bootstrap/ja-JP.json @@ -3,6 +3,25 @@ "languageSaveFailed": "言語設定の保存に失敗しました。元の言語に戻しました。" }, "comp": { + "bettergiRecoveryStable": "正式版", + "bettergiRecoveryBeta": "ベータ版", + "bettergiRecoveryTitle": "BetterGI の前回インストールが未確認です", + "bettergiRecoveryMessage": "前回の BetterGI 更新が完了したことを確認できません。配布ファイルの公開が遅れているか、インストールが中断された可能性があります。今回の処理を選択してください。", + "bettergiRecoveryManagedName": "管理設定名", + "bettergiRecoveryInstallPath": "インストール先", + "bettergiRecoveryCurrent": "現在のバージョン", + "bettergiRecoveryTarget": "前回の更新先", + "bettergiRecoveryChannel": "更新チャネル", + "bettergiRecoveryTakeover": "自動更新", + "bettergiRecoveryEnabled": "有効", + "bettergiRecoveryDisabled": "無効", + "bettergiRecoveryRisk": "前回のインストールでファイルが一部変更された場合、スキップすると不完全な環境で起動する可能性があります。MAS は実行ファイルのバージョンと構成、インストーラーの終了を確認しますが、すべてのファイルの完全性は保証できません。", + "bettergiRecoveryAcknowledgement": "公式インストーラーで修復したか、BetterGI が正常に動作することを確認し、スキップのリスクを理解しました。", + "bettergiRecoveryRetry": "インストールを再試行", + "bettergiRecoverySkip": "スキップして続行", + "bettergiRecoveryStop": "今回のタスクを中止", + "bettergiRecoveryUnknown": "不明", + "bettergiRecoverySendFailed": "選択を送信できません。バックエンドの接続を確認して再試行してください。", "backendAboutUpdateWhich": "バックエンドを更新します。更新にはバックエンドの再起動が必要で、実行中のタスクは中断されます。続行しますか?", "backendStarting": "バックエンドを起動しています", "backendUpdateAvailableClick": "バックエンドの更新があります。クリックして更新してください", diff --git a/frontend/src/i18n/bootstrap/zh-CN.json b/frontend/src/i18n/bootstrap/zh-CN.json index eb4f5910e6..3039100940 100644 --- a/frontend/src/i18n/bootstrap/zh-CN.json +++ b/frontend/src/i18n/bootstrap/zh-CN.json @@ -3,6 +3,25 @@ "languageSaveFailed": "语言设置保存失败,已恢复原语言" }, "comp": { + "bettergiRecoveryStable": "正式版", + "bettergiRecoveryBeta": "公测版", + "bettergiRecoveryTitle": "BetterGI 上次安装未完成", + "bettergiRecoveryMessage": "检测到上一次 BetterGI 更新的未确认记录。可能是新版本安装资源尚未分发完成,或安装中途取消。请选择如何处理本次任务。", + "bettergiRecoveryManagedName": "托管名称", + "bettergiRecoveryInstallPath": "安装目录", + "bettergiRecoveryCurrent": "当前程序版本", + "bettergiRecoveryTarget": "上次安装目标", + "bettergiRecoveryChannel": "更新通道", + "bettergiRecoveryTakeover": "自动更新", + "bettergiRecoveryEnabled": "已开启", + "bettergiRecoveryDisabled": "已关闭", + "bettergiRecoveryRisk": "若上次安装已修改程序文件,跳过可能会启动不完整的安装。MAS 会检查程序版本与架构,并确认安装器已退出,但无法保证所有文件完整。", + "bettergiRecoveryAcknowledgement": "我已使用官方安装器修复或确认 BetterGI 能正常运行,并理解跳过安装的风险。", + "bettergiRecoveryRetry": "重试安装", + "bettergiRecoverySkip": "跳过安装并继续", + "bettergiRecoveryStop": "取消本次任务", + "bettergiRecoveryUnknown": "未知", + "bettergiRecoverySendFailed": "操作未能发送,请检查后端连接后重试。", "backendAboutUpdateWhich": "即将更新后端,这需要重启后端程序,你当前正在运行的任务将会被中断。确认继续?", "backendStarting": "正在启动后端", "backendUpdateAvailableClick": "检测到后端更新,点击以更新后端", diff --git a/frontend/src/services/bettergiInstallRecovery.test.ts b/frontend/src/services/bettergiInstallRecovery.test.ts new file mode 100644 index 0000000000..5c5e59a057 --- /dev/null +++ b/frontend/src/services/bettergiInstallRecovery.test.ts @@ -0,0 +1,86 @@ +import { afterEach, expect, it, vi } from 'vite-plus/test' + +const boundary = vi.hoisted(() => ({ + subscriptions: new Map }) => void>(), + connected: [] as Array<() => void>, + disconnected: [] as Array<() => void>, + send: vi.fn(() => true), +})) + +vi.mock('@/services/websocket/subscriptions', () => ({ + subscribe: ( + criteria: { id: string; type: string }, + handler: (message: { data: Record }) => void + ) => { + const key = `${criteria.id}:${criteria.type}` + boundary.subscriptions.set(key, handler) + return key + }, + unsubscribe: (id: string) => { + boundary.subscriptions.delete(id) + }, +})) + +vi.mock('@/services/websocket/connection', () => ({ + send: boundary.send, + onConnected: (listener: () => void) => { + boundary.connected.push(listener) + return () => { + const at = boundary.connected.indexOf(listener) + if (at >= 0) boundary.connected.splice(at, 1) + } + }, + onDisconnected: (listener: () => void) => { + boundary.disconnected.push(listener) + return () => { + const at = boundary.disconnected.indexOf(listener) + if (at >= 0) boundary.disconnected.splice(at, 1) + } + }, +})) + +afterEach(() => { + vi.unstubAllGlobals() + boundary.subscriptions.clear() + boundary.connected.length = 0 + boundary.disconnected.length = 0 + boundary.send.mockClear() +}) + +it('drops stale BetterGI install prompt after active websocket reconnect without close event', async () => { + vi.stubGlobal('window', { + electronAPI: { getLogger: () => ({ warn: vi.fn() }) }, + }) + const module = await import('./bettergiInstallRecovery') + module.bootstrapBetterGIInstallRecovery() + const prompt = boundary.subscriptions.get('TaskManager:bettergi.install.prompt') + expect(prompt).toBeTruthy() + + prompt!({ + data: { + requestId: 'old-request', + taskId: 'task-one', + managedName: 'Example', + installPath: '/sandbox/one', + }, + }) + expect(module.bettergiInstallPrompts.value).toHaveLength(1) + expect(boundary.connected).toHaveLength(1) + + // Simulate reconnectNow successful: stale socket close was intentionally ignored, + // and backend's final prompt-closed frame never arrived. + boundary.connected[0]() + expect(module.bettergiInstallPrompts.value).toHaveLength(0) + expect(module.bettergiInstallDecisionPending.value).toBeNull() + expect(module.respondToBetterGIInstallRecovery('skip')).toBe(false) + expect(boundary.send).not.toHaveBeenCalled() + + // A new prompt from the new transport remains actionable. + prompt!({ data: { requestId: 'new-request', taskId: 'task-two' } }) + expect(module.respondToBetterGIInstallRecovery('stop')).toBe(true) + expect(boundary.send).toHaveBeenCalledOnce() + + module.disposeBetterGIInstallRecovery() + expect(boundary.connected).toHaveLength(0) + expect(boundary.disconnected).toHaveLength(0) +}) diff --git a/frontend/src/services/bettergiInstallRecovery.ts b/frontend/src/services/bettergiInstallRecovery.ts new file mode 100644 index 0000000000..5f919594ec --- /dev/null +++ b/frontend/src/services/bettergiInstallRecovery.ts @@ -0,0 +1,86 @@ +// 应用级 BetterGI 安装恢复询问:首条主连接之前常驻订阅,避免路由切换漏掉请求。 +// 一次只能处理一个弹窗,后续请求排队;断线或后端关闭请求时立即清除旧选择。 + +import { ref } from 'vue' +import { onConnected, onDisconnected, send } from '@/services/websocket/connection' +import { subscribe, unsubscribe } from '@/services/websocket/subscriptions' +import { + WS_BETTERGI_INSTALL_DECISION, + WS_BETTERGI_INSTALL_PROMPT, + WS_BETTERGI_INSTALL_PROMPT_CLOSED, + WS_ID_TASK_MANAGER, + type WSBetterGIInstallPromptData, +} from '@/services/websocket/types' + +const logger = window.electronAPI.getLogger('BetterGI安装恢复') +export const bettergiInstallPrompts = ref([]) +export const bettergiInstallDecisionPending = ref(null) + +let subscriptionIds: string[] = [] +let disposeDisconnect: (() => void) | null = null +let disposeConnect: (() => void) | null = null + +const forgetRequest = (requestId: string) => { + bettergiInstallPrompts.value = bettergiInstallPrompts.value.filter( + prompt => prompt.requestId !== requestId + ) + if (bettergiInstallDecisionPending.value === requestId) { + bettergiInstallDecisionPending.value = null + } +} + +export function bootstrapBetterGIInstallRecovery(): void { + if (subscriptionIds.length) return + subscriptionIds = [ + subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BETTERGI_INSTALL_PROMPT }, message => { + const prompt = message.data + if ( + !prompt.requestId || + !prompt.taskId || + bettergiInstallPrompts.value.some(previous => previous.requestId === prompt.requestId) + ) { + return + } + bettergiInstallPrompts.value.push(prompt) + }), + subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BETTERGI_INSTALL_PROMPT_CLOSED }, message => { + forgetRequest(message.data.requestId) + }), + ] + disposeDisconnect = onDisconnected(() => { + bettergiInstallPrompts.value = [] + bettergiInstallDecisionPending.value = null + }) + // reconnectNow 在切换连接代次时不会触发 onDisconnected。 + // 新连接建立后,旧连接的关闭帧可能已丢失;绝不可沿用旧确认请求。 + disposeConnect = onConnected(() => { + bettergiInstallPrompts.value = [] + bettergiInstallDecisionPending.value = null + }) +} + +export function respondToBetterGIInstallRecovery(action: 'retry' | 'skip' | 'stop'): boolean { + const prompt = bettergiInstallPrompts.value[0] + if (!prompt || bettergiInstallDecisionPending.value) return false + const sent = send(WS_ID_TASK_MANAGER, WS_BETTERGI_INSTALL_DECISION, { + requestId: prompt.requestId, + taskId: prompt.taskId, + action, + }) + if (!sent) { + logger.warn('未能发送 BetterGI 安装恢复选择') + return false + } + bettergiInstallDecisionPending.value = prompt.requestId + return true +} + +export function disposeBetterGIInstallRecovery(): void { + for (const id of subscriptionIds.splice(0)) unsubscribe(id) + disposeDisconnect?.() + disposeDisconnect = null + disposeConnect?.() + disposeConnect = null + bettergiInstallPrompts.value = [] + bettergiInstallDecisionPending.value = null +} diff --git a/frontend/src/services/websocket/types.ts b/frontend/src/services/websocket/types.ts index d7f0005eba..89308427b4 100644 --- a/frontend/src/services/websocket/types.ts +++ b/frontend/src/services/websocket/types.ts @@ -50,6 +50,11 @@ export const WS_TASK_CREATED = 'task.created' export const WS_TASK_SCRIPT_STARTED = 'task.script.started' export const WS_TASK_SCRIPT_FINISHED = 'task.script.finished' +// BetterGI 安装恢复:由后端单次发起,用户决定后以 requestId 回应。 +export const WS_BETTERGI_INSTALL_PROMPT = 'bettergi.install.prompt' +export const WS_BETTERGI_INSTALL_PROMPT_CLOSED = 'bettergi.install.prompt.closed' +export const WS_BETTERGI_INSTALL_DECISION = 'bettergi.install.decision' + // 应用生命周期与电源(id=Main) export const WS_BACKEND_SHUTDOWN_READY = 'backend.shutdown.ready' export const WS_FRONTEND_CLOSE_REQUESTED = 'frontend.close.requested' @@ -160,6 +165,22 @@ export interface WSTaskScriptFinishedData extends WSTaskScriptStartedData { partialFailure?: 'yes' | 'no' | 'unknown' } +/** BetterGI 上次安装未确认时的请求(id=TaskManager) */ +export interface WSBetterGIInstallPromptData { + requestId: string + taskId: string + channel: string + takeoverEnabled: boolean + installedVersion: string + targetVersion: string + managedName: string + installPath: string +} + +export interface WSBetterGIInstallPromptClosedData { + requestId: string +} + /** 新任务创建通知数据 (id=TaskManager, type=task.created) */ export interface WSTaskCreatedData { trigger?: TaskTriggerReason @@ -329,6 +350,8 @@ interface WSMessageDataMap { [WS_TASK_CREATED]: WSTaskCreatedData [WS_TASK_SCRIPT_STARTED]: WSTaskScriptStartedData [WS_TASK_SCRIPT_FINISHED]: WSTaskScriptFinishedData + [WS_BETTERGI_INSTALL_PROMPT]: WSBetterGIInstallPromptData + [WS_BETTERGI_INSTALL_PROMPT_CLOSED]: WSBetterGIInstallPromptClosedData [WS_BACKEND_SHUTDOWN_READY]: WSEmptyData [WS_FRONTEND_CLOSE_REQUESTED]: FrontendCloseRequest [WS_FRONTEND_RESTART_REQUESTED]: FrontendCloseRequest diff --git a/tests/task/test_bettergi_program_update.py b/tests/task/test_bettergi_program_update.py index ef68638364..4d0f302de0 100644 --- a/tests/task/test_bettergi_program_update.py +++ b/tests/task/test_bettergi_program_update.py @@ -226,7 +226,7 @@ async def test_unsafe_and_cancelled_preparation_never_runs_users( @pytest.mark.asyncio async def test_disabled_takeover_does_not_query_and_blocks_unfinished_install( - installation, + installation, monkeypatch ): root, config, _ = installation config.Update.TakeoverEnabled = False @@ -240,10 +240,19 @@ async def test_disabled_takeover_does_not_query_and_blocks_unfinished_install( assert json.loads((root / "User/config.json").read_text()) == {"custom": True} marker = script_install.workspace(root) / "bgi-install.json" marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text("{}") + marker.write_text('{"target": "0.56.0"}') instance._program_update_prepared = False + instance.task_info = SimpleNamespace(task_id=str(uuid.uuid4())) + instance.script_info = SimpleNamespace(name="BetterGI 测试托管") + monkeypatch.setattr( + manager.install_recovery, + "ask_install_recovery", + AsyncMock(side_effect=script_install.InstallUnsafeError("用户界面未连接")), + ) with pytest.raises(script_install.InstallUnsafeError): await instance._prepare_program_update() + assert marker.exists() + update.request_release.assert_not_awaited() @pytest.mark.asyncio @@ -532,6 +541,380 @@ async def download(url, destination, **kwargs): assert "已更新" in await update.update_before_run(root, config) +def _recovery_instance(root, config): + instance = object.__new__(manager.BetterGIManager) + instance._update_root = root + instance._program_update_prepared = False + instance.script_config = config + instance.script_info = SimpleNamespace(script_id=str(uuid.uuid4()), log="") + instance.task_info = SimpleNamespace(task_id=str(uuid.uuid4())) + return instance + + +@pytest.mark.asyncio +async def test_skip_after_manual_repair_with_takeover_off(installation, monkeypatch): + root, config, _ = installation + config.Update.TakeoverEnabled = False + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}', encoding="utf-8") + monkeypatch.setattr( + manager.install_recovery, + "ask_install_recovery", + AsyncMock(return_value=("skip", marker.read_bytes())), + ) + monkeypatch.setattr(manager.Publisher, "send", AsyncMock(return_value=True)) + instance = _recovery_instance(root, config) + await instance._prepare_program_update() + await instance._prepare_program_update() + assert instance._program_update_prepared + assert instance.check_result == "Pass" + assert not marker.exists() + await update.confirm_current_install(root) + update.request_release.assert_not_awaited() + update.run_installer.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_skip_with_takeover_on_does_not_install_again(installation, monkeypatch): + root, config, _ = installation + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}', encoding="utf-8") + monkeypatch.setattr( + manager.install_recovery, + "ask_install_recovery", + AsyncMock(return_value=("skip", marker.read_bytes())), + ) + monkeypatch.setattr(manager.Publisher, "send", AsyncMock(return_value=True)) + instance = _recovery_instance(root, config) + await instance._prepare_program_update() + assert not marker.exists() + update.request_release.assert_not_awaited() + assert ( + json.loads((root / "User/config.json").read_text())[update.NOTICE_KEY] + == update.IGNORED_VERSION + ) + update.recover_update_notice(root) + + +@pytest.mark.asyncio +async def test_retry_explicitly_allowed_with_takeover_off(installation, monkeypatch): + root, config, _ = installation + config.Update.TakeoverEnabled = False + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}', encoding="utf-8") + monkeypatch.setattr( + manager.install_recovery, + "ask_install_recovery", + AsyncMock(return_value=("retry", marker.read_bytes())), + ) + instance = _recovery_instance(root, config) + await instance._prepare_program_update() + await instance._prepare_program_update() + assert not marker.exists() + assert update.request_release.await_count == 1 + assert update.run_installer.await_count == 1 + assert instance.check_result == "Pass" + + +@pytest.mark.asyncio +async def test_retry_failure_preserves_marker_and_aborts(installation, monkeypatch): + root, config, _ = installation + config.Update.TakeoverEnabled = False + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}', encoding="utf-8") + monkeypatch.setattr( + manager.install_recovery, + "ask_install_recovery", + AsyncMock(return_value=("retry", marker.read_bytes())), + ) + update.run_installer.side_effect = RuntimeError("official installer failed") + instance = _recovery_instance(root, config) + with pytest.raises(RuntimeError, match="official installer failed"): + await instance._prepare_program_update() + assert marker.exists() + assert not instance._program_update_prepared + + +@pytest.mark.asyncio +async def test_skip_changed_marker_is_rejected(installation): + from app.task.BetterGI.tools import install_recovery + + root, _, _ = installation + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}') + snapshot = marker.read_bytes() + marker.write_text('{"target": "0.57.0"}') + with pytest.raises(script_install.InstallUnsafeError, match="变化"): + await install_recovery.skip_unconfirmed_install(root, snapshot) + assert marker.read_text() == '{"target": "0.57.0"}' + + +@pytest.mark.asyncio +async def test_skip_invalid_marker_or_unrecognized_install_stays_blocked( + installation, monkeypatch +): + from app.task.BetterGI.tools import install_recovery + + root, _, _ = installation + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("not valid json") + with pytest.raises(script_install.InstallUnsafeError, match="内容异常"): + await install_recovery.skip_unconfirmed_install(root, marker.read_bytes()) + assert marker.exists() + + marker.write_text('{"target": "0.56.0"}') + monkeypatch.setattr(script_install, "read_pe_architecture", lambda path: "x86") + with pytest.raises(script_install.InstallUnsafeError, match="版本或架构"): + await install_recovery.skip_unconfirmed_install(root, marker.read_bytes()) + assert marker.exists() + + +@pytest.mark.asyncio +async def test_installer_outside_workspace_blocks_recovery(tmp_path, monkeypatch): + from app.task.BetterGI.tools import program_update + + process = SimpleNamespace( + info={ + "exe": str(tmp_path / "Downloads" / "BetterGI.Install.0.67.0.exe"), + "name": "BetterGI.Install.0.67.0.exe", + } + ) + monkeypatch.setattr(program_update.psutil, "process_iter", lambda _: [process]) + with pytest.raises(script_install.InstallUnsafeError, match="安装器仍在运行"): + program_update.assert_idle(tmp_path / "BetterGI") + + +@pytest.mark.asyncio +async def test_recovery_prompt_without_frontend_fails_closed(installation, monkeypatch): + from app.task.BetterGI.tools import install_recovery + + root, _, _ = installation + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}') + send = AsyncMock(return_value=False) + monkeypatch.setattr(install_recovery.Publisher, "send", send) + with pytest.raises(script_install.InstallUnsafeError, match="未连接主界面"): + await install_recovery.ask_install_recovery( + "task1", root, channel="stable", takeover_enabled=False + ) + assert marker.exists() + assert send.await_count == 2 # 提示发不出去仍发送关闭;没有默认跳过。 + + +@pytest.mark.asyncio +async def test_recovery_prompt_accepts_only_matching_request(installation, monkeypatch): + from app.core import Dispatcher + from app.core.ws import protocol + from app.models.schema import WSEnvelope + from app.task.BetterGI.tools import install_recovery + + root, _, _ = installation + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}') + outgoing = [] + + async def send(*, id, type, data): + outgoing.append(type) + if type == protocol.BETTERGI_INSTALL_PROMPT: + Dispatcher.dispatch( + WSEnvelope( + id=id, + type=protocol.BETTERGI_INSTALL_DECISION, + data={ + "taskId": data["taskId"], + "requestId": "wrong", + "action": "skip", + }, + ) + ) + Dispatcher.dispatch( + WSEnvelope( + id=id, + type=protocol.BETTERGI_INSTALL_DECISION, + data={ + "taskId": "another-task", + "requestId": data["requestId"], + "action": "skip", + }, + ) + ) + Dispatcher.dispatch( + WSEnvelope( + id=id, + type=protocol.BETTERGI_INSTALL_DECISION, + data={ + "taskId": data["taskId"], + "requestId": data["requestId"], + "action": "retry", + }, + ) + ) + return True + + monkeypatch.setattr(install_recovery.Publisher, "send", send) + action, snapshot = await install_recovery.ask_install_recovery( + "task1", root, channel="stable", takeover_enabled=False + ) + assert action == "retry" + assert snapshot == marker.read_bytes() + assert outgoing == [ + protocol.BETTERGI_INSTALL_PROMPT, + protocol.BETTERGI_INSTALL_PROMPT_CLOSED, + ] + + +@pytest.mark.asyncio +async def test_recovery_prompt_timeout_never_deletes_marker(installation, monkeypatch): + from app.task.BetterGI.tools import install_recovery + from app.core.ws import protocol + + root, _, _ = installation + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}') + monkeypatch.setattr(install_recovery, "_PROMPT_TIMEOUT_SECONDS", 0.005) + send = AsyncMock(return_value=True) + monkeypatch.setattr(install_recovery.Publisher, "send", send) + with pytest.raises(script_install.InstallUnsafeError, match="超时"): + await install_recovery.ask_install_recovery( + "task1", root, channel="stable", takeover_enabled=False + ) + assert marker.exists() + assert ( + send.await_args_list[-1].kwargs["type"] + == protocol.BETTERGI_INSTALL_PROMPT_CLOSED + ) + + +@pytest.mark.asyncio +async def test_retry_with_corrupted_old_executable_reinstalls_full_package( + installation, monkeypatch +): + """明确重试可修复损坏的旧本体;不能先要求成功读取旧版元数据。""" + root, config, _ = installation + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}', encoding="utf-8") + monkeypatch.setattr( + manager.install_recovery, + "ask_install_recovery", + AsyncMock(return_value=("retry", marker.read_bytes())), + ) + + working = [False] + + def version(_root): + if not working[0]: + raise OSError("unreadable version resource") + return Version("0.56.0") + + async def repair(_installer, _root): + working[0] = True + + monkeypatch.setattr(update, "read_version", version) + update.run_installer.side_effect = repair + instance = _recovery_instance(root, config) + await instance._prepare_program_update() + + assert instance.check_result == "Pass" + assert not marker.exists() + update.download_script_package.assert_awaited_once() + update.run_installer.assert_awaited_once() + await update.confirm_current_install(root) + + +@pytest.mark.asyncio +async def test_missing_executable_requires_explicit_recovery(installation, monkeypatch): + root, config, _ = installation + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}', encoding="utf-8") + (root / "BetterGI.exe").unlink() + + monkeypatch.setattr( + update, "read_version", lambda _root: (_ for _ in ()).throw(OSError("missing")) + ) + with pytest.raises(OSError, match="missing"): + await update.update_before_run(root, config) + update.download_script_package.assert_not_awaited() + assert marker.exists() + + +@pytest.mark.asyncio +async def test_recovery_refuses_to_replace_marker_changed_during_download( + installation, monkeypatch +): + root, config, _ = installation + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target":"0.56.0"}') + snapshot = marker.read_bytes() + original_download = update.download_script_package.side_effect + + async def change_marker(url, destination, **kwargs): + await original_download(url, destination, **kwargs) + marker.write_text('{"target":"0.57.0"}') + + update.download_script_package.side_effect = change_marker + with pytest.raises(script_install.InstallUnsafeError, match="下载期间发生变化"): + await update.update_before_run(root, config, recovery_snapshot=snapshot) + assert marker.read_text() == '{"target":"0.57.0"}' + update.run_installer.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_prompt_identifies_managed_profile_and_installation_root( + installation, monkeypatch +): + from app.core import Dispatcher + from app.core.ws import protocol + from app.models.schema import WSEnvelope + from app.task.BetterGI.tools import install_recovery + + root, _, _ = installation + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}') + seen = [] + + async def send(*, id, type, data): + if type == protocol.BETTERGI_INSTALL_PROMPT: + seen.append(data) + Dispatcher.dispatch( + WSEnvelope( + id=id, + type=protocol.BETTERGI_INSTALL_DECISION, + data={ + "taskId": data["taskId"], + "requestId": data["requestId"], + "action": "stop", + }, + ) + ) + return True + + monkeypatch.setattr(install_recovery.Publisher, "send", send) + with pytest.raises(script_install.InstallUnsafeError, match="用户取消"): + await install_recovery.ask_install_recovery( + "task-id", + root, + managed_name="托管 B(夜间队列)", + channel="stable", + takeover_enabled=False, + ) + assert seen[0]["managedName"] == "托管 B(夜间队列)" + assert seen[0]["installPath"] == str(root.resolve()) + assert marker.exists() + + @pytest.mark.asyncio @pytest.mark.parametrize( ("notice", "resource", "status", "expected"), @@ -652,3 +1035,38 @@ async def test_unready_release_does_not_bypass_existing_unconfirmed_install( await update.update_before_run(root, config) assert marker.exists() update.run_installer.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_script_config_missing_executable_with_marker_reaches_recovery_gate( + installation, monkeypatch +): + root, config, _ = installation + (root / "BetterGI.exe").unlink() + marker = script_install.workspace(root) / "bgi-install.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('{"target": "0.56.0"}') + uid = uuid.uuid4() + config.Info = SimpleNamespace(RootPath=root) + config.UserData = {} + monkeypatch.setattr(manager, "BetterGIConfig", type(config)) + monkeypatch.setattr(manager, "Config", SimpleNamespace(ScriptConfig={uid: config})) + instance = object.__new__(manager.BetterGIManager) + instance.maintenance = False + instance.script_info = SimpleNamespace(script_id=str(uid), name="损坏 BGI") + instance.task_info = SimpleNamespace( + user_id="Default", task_id="task-id", mode="ScriptConfig" + ) + instance._update_root = root + instance.script_config = config + monkeypatch.setattr( + manager.install_recovery, + "ask_install_recovery", + AsyncMock(side_effect=script_install.InstallUnsafeError("用户取消恢复")), + ) + assert await instance.check() == "Pass" + with pytest.raises(script_install.InstallUnsafeError, match="用户取消恢复"): + await instance._recover_unconfirmed_install() + assert marker.exists() + marker.unlink() + assert "有效" in await instance.check()