From 76a62cf1a72062c72ace584dbcb93734b81976f4 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:40:52 +0100 Subject: [PATCH 01/19] feat(bettergi): add bounded prompt for unresolved installation --- app/task/BetterGI/tools/install_recovery.py | 113 ++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 app/task/BetterGI/tools/install_recovery.py diff --git a/app/task/BetterGI/tools/install_recovery.py b/app/task/BetterGI/tools/install_recovery.py new file mode 100644 index 0000000000..0fd0dbf028 --- /dev/null +++ b/app/task/BetterGI/tools/install_recovery.py @@ -0,0 +1,113 @@ +# 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, +) -> tuple[str, bytes]: + """返回 (retry/skip, 原标记字节),让后续操作对同一份安装记录做乐观校验。""" + marker = script_install.workspace(root) / "bgi-install.json" + 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 (OSError, ValueError, ImportError, IndexError, KeyError): + 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, + }, + ) + 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 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 安装恢复弹窗关闭通知失败") From f3383014153e5f765711acf96bb7dec192b48582 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:48:38 +0100 Subject: [PATCH 02/19] fix(bettergi): route interrupted installs through guarded user recovery --- app/task/BetterGI/manager.py | 95 ++++++++++++++++----- app/task/BetterGI/tools/install_recovery.py | 63 +++++++++++++- app/task/BetterGI/tools/program_update.py | 31 +++++-- 3 files changed, 162 insertions(+), 27 deletions(-) diff --git a/app/task/BetterGI/manager.py b/app/task/BetterGI/manager.py index ca7c4eacc2..d8ba0008be 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 调度器") @@ -167,6 +168,58 @@ async def prepare(self): self.build_proxy_user_list(self.user_config.items()) ) + async def _run_program_update(self, root: Path) -> 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, + ) + + 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, + 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) + 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 +228,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 +261,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 +290,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 index 0fd0dbf028..63d04889cd 100644 --- a/app/task/BetterGI/tools/install_recovery.py +++ b/app/task/BetterGI/tools/install_recovery.py @@ -33,6 +33,8 @@ async def ask_install_recovery( ) -> 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 安装标记异常,请手动检查安装状态") @@ -94,7 +96,8 @@ def receive(envelope) -> None: raise script_install.InstallUnsafeError( "用户取消 BetterGI 安装恢复,本次任务已停止;未修改安装标记" ) - if not marker.is_file() or await asyncio.to_thread(marker.read_bytes) != snapshot: + if (marker.is_symlink() or not marker.is_file() + or await asyncio.to_thread(marker.read_bytes) != snapshot): raise script_install.InstallUnsafeError( "BetterGI 安装状态在等待选择时发生变化,本次任务已停止,请重新检查" ) @@ -111,3 +114,61 @@ def receive(envelope) -> None: ) 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 990d5b16ea..86095ec92c 100644 --- a/app/task/BetterGI/tools/program_update.py +++ b/app/task/BetterGI/tools/program_update.py @@ -29,7 +29,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 +41,7 @@ # BetterGI 使用严格 SemVer 比较,不能填通配符或空的版本号。 IGNORED_VERSION = "2147483647.0.0" _INSTALLER_CLEANUP_SECONDS = 5 +logger = get_logger("BetterGI 本体更新") def assert_idle(root: Path) -> None: @@ -49,10 +50,16 @@ 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"} - or name.startswith("bettergi.install.") - ): + # 安装器可以由用户从下载目录手动启动,不一定在 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 更新器已退出,请以管理员身份重试" ) @@ -318,6 +325,10 @@ async def update_before_run( directory = script_install.workspace(root) marker = directory / "bgi-install.json" + logger.info( + f"BetterGI 版本检查:当前={current},目标={target}," + f"通道={config.Update.Channel},存在未确认标记={marker.exists()}" + ) if target <= current and not marker.exists(): return f"BetterGI 当前版本 {current} 无需更新" if target < current: @@ -366,6 +377,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, @@ -388,13 +401,19 @@ async def update_before_run( await asyncio.to_thread(verify_installer, installer, size, digest) await asyncio.to_thread(assert_idle, root) _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 From cb299c504db06c213a329e33b5ec6e0ea9f6e7ca Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:49:40 +0100 Subject: [PATCH 03/19] feat(bettergi): show recovery choice in all launch modes --- app/core/ws/protocol.py | 5 + frontend/src/App.vue | 3 + frontend/src/bootstrap/realtimeResidents.ts | 8 ++ .../BetterGIInstallRecoveryModal.vue | 105 ++++++++++++++++++ frontend/src/i18n/bootstrap/en-US.json | 15 +++ frontend/src/i18n/bootstrap/ja-JP.json | 15 +++ frontend/src/i18n/bootstrap/zh-CN.json | 15 +++ .../src/services/bettergiInstallRecovery.ts | 73 ++++++++++++ frontend/src/services/websocket/types.ts | 21 ++++ 9 files changed, 260 insertions(+) create mode 100644 frontend/src/components/BetterGIInstallRecoveryModal.vue create mode 100644 frontend/src/services/bettergiInstallRecovery.ts 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/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..86adb0da2c --- /dev/null +++ b/frontend/src/components/BetterGIInstallRecoveryModal.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/frontend/src/i18n/bootstrap/en-US.json b/frontend/src/i18n/bootstrap/en-US.json index f5abbfe3aa..47c5200816 100644 --- a/frontend/src/i18n/bootstrap/en-US.json +++ b/frontend/src/i18n/bootstrap/en-US.json @@ -3,6 +3,21 @@ "languageSaveFailed": "Failed to save the language setting; reverted to the previous one." }, "comp": { + "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.", + "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..7944d85fed 100644 --- a/frontend/src/i18n/bootstrap/ja-JP.json +++ b/frontend/src/i18n/bootstrap/ja-JP.json @@ -3,6 +3,21 @@ "languageSaveFailed": "言語設定の保存に失敗しました。元の言語に戻しました。" }, "comp": { + "bettergiRecoveryTitle": "BetterGI の前回インストールが未確認です", + "bettergiRecoveryMessage": "前回の BetterGI 更新が完了したことを確認できません。配布ファイルの公開が遅れているか、インストールが中断された可能性があります。今回の処理を選択してください。", + "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..86544e6097 100644 --- a/frontend/src/i18n/bootstrap/zh-CN.json +++ b/frontend/src/i18n/bootstrap/zh-CN.json @@ -3,6 +3,21 @@ "languageSaveFailed": "语言设置保存失败,已恢复原语言" }, "comp": { + "bettergiRecoveryTitle": "BetterGI 上次安装未完成", + "bettergiRecoveryMessage": "检测到上一次 BetterGI 更新的未确认记录。可能是新版本安装资源尚未分发完成,或安装中途取消。请选择如何处理本次任务。", + "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.ts b/frontend/src/services/bettergiInstallRecovery.ts new file mode 100644 index 0000000000..d942a99ffa --- /dev/null +++ b/frontend/src/services/bettergiInstallRecovery.ts @@ -0,0 +1,73 @@ +// 应用级 BetterGI 安装恢复询问:首条主连接之前常驻订阅,避免路由切换漏掉请求。 +// 一次只能处理一个弹窗,后续请求排队;断线或后端关闭请求时立即清除旧选择。 + +import { ref } from 'vue' +import { 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 + +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 + }) +} + +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 + bettergiInstallPrompts.value = [] + bettergiInstallDecisionPending.value = null +} diff --git a/frontend/src/services/websocket/types.ts b/frontend/src/services/websocket/types.ts index d7f0005eba..d7b3c9121d 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,20 @@ 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 +} + +export interface WSBetterGIInstallPromptClosedData { + requestId: string +} + /** 新任务创建通知数据 (id=TaskManager, type=task.created) */ export interface WSTaskCreatedData { trigger?: TaskTriggerReason @@ -329,6 +348,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 From ed2814d02c69a10e3dc40ee915c5fb259c10239f Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:50:45 +0100 Subject: [PATCH 04/19] fix(bettergi): translate stable and beta recovery channel labels --- frontend/src/components/BetterGIInstallRecoveryModal.vue | 2 +- frontend/src/i18n/bootstrap/en-US.json | 2 ++ frontend/src/i18n/bootstrap/ja-JP.json | 2 ++ frontend/src/i18n/bootstrap/zh-CN.json | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/BetterGIInstallRecoveryModal.vue b/frontend/src/components/BetterGIInstallRecoveryModal.vue index 86adb0da2c..92643a88b5 100644 --- a/frontend/src/components/BetterGIInstallRecoveryModal.vue +++ b/frontend/src/components/BetterGIInstallRecoveryModal.vue @@ -46,7 +46,7 @@ function decide(action: 'retry' | 'skip' | 'stop') { {{ prompt.targetVersion || t('bootstrap.comp.bettergiRecoveryUnknown') }} - {{ prompt.channel === 'stable' ? t('bootstrap.comp.channelStable') : prompt.channel }} + {{ prompt.channel === 'stable' ? t('bootstrap.comp.bettergiRecoveryStable') : t('bootstrap.comp.bettergiRecoveryBeta') }} {{ t(prompt.takeoverEnabled ? 'bootstrap.comp.bettergiRecoveryEnabled' : 'bootstrap.comp.bettergiRecoveryDisabled') }} diff --git a/frontend/src/i18n/bootstrap/en-US.json b/frontend/src/i18n/bootstrap/en-US.json index 47c5200816..abdf6d9aa6 100644 --- a/frontend/src/i18n/bootstrap/en-US.json +++ b/frontend/src/i18n/bootstrap/en-US.json @@ -3,6 +3,8 @@ "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.", "bettergiRecoveryCurrent": "Installed version", diff --git a/frontend/src/i18n/bootstrap/ja-JP.json b/frontend/src/i18n/bootstrap/ja-JP.json index 7944d85fed..5247def71a 100644 --- a/frontend/src/i18n/bootstrap/ja-JP.json +++ b/frontend/src/i18n/bootstrap/ja-JP.json @@ -3,6 +3,8 @@ "languageSaveFailed": "言語設定の保存に失敗しました。元の言語に戻しました。" }, "comp": { + "bettergiRecoveryStable": "正式版", + "bettergiRecoveryBeta": "ベータ版", "bettergiRecoveryTitle": "BetterGI の前回インストールが未確認です", "bettergiRecoveryMessage": "前回の BetterGI 更新が完了したことを確認できません。配布ファイルの公開が遅れているか、インストールが中断された可能性があります。今回の処理を選択してください。", "bettergiRecoveryCurrent": "現在のバージョン", diff --git a/frontend/src/i18n/bootstrap/zh-CN.json b/frontend/src/i18n/bootstrap/zh-CN.json index 86544e6097..3e25410fad 100644 --- a/frontend/src/i18n/bootstrap/zh-CN.json +++ b/frontend/src/i18n/bootstrap/zh-CN.json @@ -3,6 +3,8 @@ "languageSaveFailed": "语言设置保存失败,已恢复原语言" }, "comp": { + "bettergiRecoveryStable": "正式版", + "bettergiRecoveryBeta": "公测版", "bettergiRecoveryTitle": "BetterGI 上次安装未完成", "bettergiRecoveryMessage": "检测到上一次 BetterGI 更新的未确认记录。可能是新版本安装资源尚未分发完成,或安装中途取消。请选择如何处理本次任务。", "bettergiRecoveryCurrent": "当前程序版本", From 6cf5a9410657ab2ee827ded88ab211410e565882 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:51:53 +0100 Subject: [PATCH 05/19] test(bettergi): cover skip, retry, mark races, installer and prompt fallback --- tests/task/test_bettergi_program_update.py | 238 ++++++++++++++++++++- 1 file changed, 236 insertions(+), 2 deletions(-) diff --git a/tests/task/test_bettergi_program_update.py b/tests/task/test_bettergi_program_update.py index cb98b9566e..f5df20a9f5 100644 --- a/tests/task/test_bettergi_program_update.py +++ b/tests/task/test_bettergi_program_update.py @@ -223,7 +223,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 @@ -237,10 +237,18 @@ 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())) + 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 @@ -527,3 +535,229 @@ async def download(url, destination, **kwargs): update.download_script_package.side_effect = download 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 From 4625c27a077fe7d303fb2aeb4e6aedfba2183e93 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:54:24 +0100 Subject: [PATCH 06/19] style(bettergi): format recovery flow and add changelog fragment --- app/task/BetterGI/tools/install_recovery.py | 39 +++++++++---- app/task/BetterGI/tools/program_update.py | 13 +++-- changelog.d/1900.fix.md | 2 + tests/task/test_bettergi_program_update.py | 63 +++++++++++++++------ 4 files changed, 85 insertions(+), 32 deletions(-) create mode 100644 changelog.d/1900.fix.md diff --git a/app/task/BetterGI/tools/install_recovery.py b/app/task/BetterGI/tools/install_recovery.py index 63d04889cd..f3dce2e61f 100644 --- a/app/task/BetterGI/tools/install_recovery.py +++ b/app/task/BetterGI/tools/install_recovery.py @@ -34,13 +34,21 @@ async def ask_install_recovery( """返回 (retry/skip, 原标记字节),让后续操作对同一份安装记录做乐观校验。""" marker = script_install.workspace(root) / "bgi-install.json" if marker.is_symlink() or not marker.is_file(): - raise script_install.InstallUnsafeError("BetterGI 安装恢复记录不是普通文件,请保留现场") + raise script_install.InstallUnsafeError( + "BetterGI 安装恢复记录不是普通文件,请保留现场" + ) snapshot = await asyncio.to_thread(marker.read_bytes) if len(snapshot) > 64 * 1024: - raise script_install.InstallUnsafeError("BetterGI 安装标记异常,请手动检查安装状态") + raise script_install.InstallUnsafeError( + "BetterGI 安装标记异常,请手动检查安装状态" + ) try: content = json.loads(snapshot) - target = str(content.get("target", "未知"))[:64] if isinstance(content, dict) else "未知" + target = ( + str(content.get("target", "未知"))[:64] + if isinstance(content, dict) + else "未知" + ) except (ValueError, UnicodeDecodeError): target = "未知" @@ -96,8 +104,11 @@ def receive(envelope) -> None: raise script_install.InstallUnsafeError( "用户取消 BetterGI 安装恢复,本次任务已停止;未修改安装标记" ) - if (marker.is_symlink() or not marker.is_file() - or await asyncio.to_thread(marker.read_bytes) != snapshot): + if ( + marker.is_symlink() + or not marker.is_file() + or await asyncio.to_thread(marker.read_bytes) != snapshot + ): raise script_install.InstallUnsafeError( "BetterGI 安装状态在等待选择时发生变化,本次任务已停止,请重新检查" ) @@ -137,8 +148,11 @@ async def skip_unconfirmed_install(root: Path, snapshot: bytes) -> str: # 不能只看安装目录:官方安装器可能从用户的下载文件夹启动。 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): + if ( + marker.is_symlink() + or not marker.is_file() + or await asyncio.to_thread(marker.read_bytes) != snapshot + ): raise script_install.InstallUnsafeError( "BetterGI 安装恢复记录已经变化,本次不清理,请重新检查" ) @@ -156,8 +170,11 @@ async def skip_unconfirmed_install(root: Path, snapshot: bytes) -> str: # 版本及架构检查不保证所有附属文件完整;这是用户主动接受的风险。 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): + 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: @@ -170,5 +187,7 @@ async def skip_unconfirmed_install(root: Path, snapshot: bytes) -> str: except OSError as error: logger.error(f"BetterGI 无法恢复安装保护标记:{error}") raise - logger.warning(f"用户确认跳过 BetterGI 未完成安装,已清除原标记;当前版本={version}") + 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 86095ec92c..934a9c1544 100644 --- a/app/task/BetterGI/tools/program_update.py +++ b/app/task/BetterGI/tools/program_update.py @@ -53,9 +53,12 @@ def assert_idle(root: Path) -> None: # 安装器可以由用户从下载目录手动启动,不一定在 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.")): + 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 安装器仍在运行,请先结束安装或退出安装器" ) @@ -405,7 +408,9 @@ async def update_before_run( try: await run_installer(installer, root) except BaseException as error: - logger.warning(f"BetterGI 安装未确认,保留标记:{type(error).__name__}: {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: 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/tests/task/test_bettergi_program_update.py b/tests/task/test_bettergi_program_update.py index f5df20a9f5..388ede2200 100644 --- a/tests/task/test_bettergi_program_update.py +++ b/tests/task/test_bettergi_program_update.py @@ -587,7 +587,10 @@ async def test_skip_with_takeover_on_does_not_install_again(installation, monkey 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 + assert ( + json.loads((root / "User/config.json").read_text())[update.NOTICE_KEY] + == update.IGNORED_VERSION + ) update.recover_update_notice(root) @@ -717,21 +720,39 @@ async def test_recovery_prompt_accepts_only_matching_request(installation, monke 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"}, - )) + 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) @@ -740,7 +761,10 @@ async def send(*, id, type, data): ) assert action == "retry" assert snapshot == marker.read_bytes() - assert outgoing == [protocol.BETTERGI_INSTALL_PROMPT, protocol.BETTERGI_INSTALL_PROMPT_CLOSED] + assert outgoing == [ + protocol.BETTERGI_INSTALL_PROMPT, + protocol.BETTERGI_INSTALL_PROMPT_CLOSED, + ] @pytest.mark.asyncio @@ -760,4 +784,7 @@ async def test_recovery_prompt_timeout_never_deletes_marker(installation, monkey "task1", root, channel="stable", takeover_enabled=False ) assert marker.exists() - assert send.await_args_list[-1].kwargs["type"] == protocol.BETTERGI_INSTALL_PROMPT_CLOSED + assert ( + send.await_args_list[-1].kwargs["type"] + == protocol.BETTERGI_INSTALL_PROMPT_CLOSED + ) From 38d4a7d09e7b9e7568f22ca794293c72567b8cfd Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:57:06 +0100 Subject: [PATCH 07/19] style(bettergi): normalize frontend formatting and make recovery dialog scrollable --- app/task/BetterGI/tools/install_recovery.py | 4 +- .../BetterGIInstallRecoveryModal.vue | 44 +++++++++++++++---- .../src/services/bettergiInstallRecovery.ts | 23 +++++++--- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/app/task/BetterGI/tools/install_recovery.py b/app/task/BetterGI/tools/install_recovery.py index f3dce2e61f..9e565c0208 100644 --- a/app/task/BetterGI/tools/install_recovery.py +++ b/app/task/BetterGI/tools/install_recovery.py @@ -54,7 +54,9 @@ async def ask_install_recovery( try: installed = str(await asyncio.to_thread(program_update.read_version, root)) - except (OSError, ValueError, ImportError, IndexError, KeyError): + except Exception as error: + # 元数据不可读时仍允许用户选择重试;跳过阶段才严格校验。 + logger.warning(f"读取 BetterGI 当前版本失败,恢复提示显示未知:{error}") installed = "未知" request_id = uuid4().hex diff --git a/frontend/src/components/BetterGIInstallRecoveryModal.vue b/frontend/src/components/BetterGIInstallRecoveryModal.vue index 92643a88b5..0dd1e89b20 100644 --- a/frontend/src/components/BetterGIInstallRecoveryModal.vue +++ b/frontend/src/components/BetterGIInstallRecoveryModal.vue @@ -11,12 +11,17 @@ const { t } = useI18n() const prompt = computed(() => bettergiInstallPrompts.value[0] ?? null) const acknowledged = ref(false) const sendFailed = ref(false) -const submitting = computed(() => bettergiInstallDecisionPending.value === prompt.value?.requestId) +const submitting = computed( + () => bettergiInstallDecisionPending.value === prompt.value?.requestId +) -watch(() => prompt.value?.requestId, () => { - acknowledged.value = false - sendFailed.value = false -}) +watch( + () => prompt.value?.requestId, + () => { + acknowledged.value = false + sendFailed.value = false + } +) function decide(action: 'retry' | 'skip' | 'stop') { if (submitting.value || (action === 'skip' && !acknowledged.value)) return @@ -34,6 +39,7 @@ function decide(action: 'retry' | 'skip' | 'stop') { :footer="null" :z-index="900" :width="560" + class="bettergi-recovery-modal" centered >