From 809d2f2efef0820aa2fe3a3f9b273af2b26b5e5b Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:21:44 +0100 Subject: [PATCH 01/29] fix(maaend): add safe recovery for unconfirmed installation transactions --- app/core/ws/protocol.py | 4 + app/task/MaaEnd/manager.py | 52 +++++- app/task/MaaEnd/tools/install_recovery.py | 173 ++++++++++++++++++ .../maaend-transaction-recovery.fix.md | 2 + 4 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 app/task/MaaEnd/tools/install_recovery.py create mode 100644 changelog.d/maaend-transaction-recovery.fix.md diff --git a/app/core/ws/protocol.py b/app/core/ws/protocol.py index 045b74b18b..40f7fdaa0e 100644 --- a/app/core/ws/protocol.py +++ b/app/core/ws/protocol.py @@ -66,6 +66,10 @@ TASK_SCRIPT_STARTED = "task.script.started" TASK_SCRIPT_FINISHED = "task.script.finished" +MAAEND_INSTALL_PROMPT = "maaend.install.prompt" +MAAEND_INSTALL_PROMPT_CLOSED = "maaend.install.prompt.closed" +MAAEND_INSTALL_DECISION = "maaend.install.decision" + # 应用生命周期与电源(id=Main) BACKEND_SHUTDOWN_READY = "backend.shutdown.ready" # 启动期攒下来、主连接建立后一次性发出的系统通知(id=Main):配置迁移结果之类。 diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index 0ddb67f681..2f7419775b 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -51,7 +51,7 @@ from .AutoProxy import AutoProxyTask from .resource_loader import MaaEndResourceLoader, load_maaend_controller_protocol from .ScriptConfig import ScriptConfigTask, maaend_config_base -from .tools import script_update +from .tools import install_recovery, script_update from .tools.backup_archive import archive_native_backup from .tools.update import ensure_game_updated from .Update import EndfieldUpdateTask @@ -485,11 +485,51 @@ async def main_task(self): if (script_install.workspace(root) / "runtime-interface.json").exists(): await asyncio.to_thread(script_update.assert_idle, root) script_update.recover_runtime_interface(root) - await script_install.run_worker( - script_install.recover_install, - root, - lambda: script_update.assert_idle(root), - ) + # 通用事务优先自动恢复;失败后必须以原安装目录为对象请求 + # 用户选择。允许人工确认不代表可以忽略待切换目录或进程占用。 + journal = script_install.workspace(root) / "transaction.json" + if journal.is_symlink(): + raise script_install.InstallUnsafeError( + "MaaEnd 事务记录是符号链接,拒绝恢复" + ) + recovered_by_user = False + try: + await script_install.run_worker( + script_install.recover_install, + root, + lambda: script_update.assert_idle(root), + ) + except script_install.InstallUnsafeError: + if not journal.is_file(): + raise + action, snapshot = await install_recovery.ask_install_recovery( + self.task_info.task_id, + root, + managed_name=getattr(self.script_info, "name", "MaaEnd"), + takeover_enabled=config.Update.TakeoverEnabled, + ) + if action == "skip": + result = await install_recovery.skip_stale_transaction( + root, snapshot + ) + await self.script_info.set_log( + f"用户确认跳过 MaaEnd 无法恢复的旧事务,当前版本 {result}" + ) + else: + install_recovery.confirm_snapshot(root, snapshot) + await script_install.run_worker( + script_install.recover_install, + root, + lambda: script_update.assert_idle(root), + ) + await script_update.confirm_current_install( + root, + verify_receipt=config.Update.TakeoverEnabled, + ) + await self.script_info.set_log("MaaEnd 旧安装事务已重新恢复") + recovered_by_user = True + # 显式恢复操作本轮不再重复下载/覆盖新包。 + self._program_update_prepared = True if self.execution_mode == "AutoProxy" and config.Update.TakeoverEnabled: self.maaend_config_dir = root / "config" self.temp_path = Path.cwd() / f"data/{self.script_info.script_id}/Temp" diff --git a/app/task/MaaEnd/tools/install_recovery.py b/app/task/MaaEnd/tools/install_recovery.py new file mode 100644 index 0000000000..5d42642f5c --- /dev/null +++ b/app/task/MaaEnd/tools/install_recovery.py @@ -0,0 +1,173 @@ +# 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 + +"""MaaEnd 更新目录切换的未确认事务恢复。 + +先调用通用 recover_install;只有它无法确认且存在实际 journal 才询问。 +任何仍有原始备份/ready 暂存的目录均禁止跳过;不触碰 MXU 的内部 pending。 +""" + +import asyncio +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 script_update + +logger = get_logger("MaaEnd 安装恢复") +_TIMEOUT = 120 + + +def _state(root: Path) -> tuple[Path, Path, Path]: + directory = script_install.workspace(root) + return ( + directory / "transaction.json", + directory / "previous", + directory / "ready", + ) + + +def confirm_snapshot(root: Path, snapshot: bytes) -> None: + journal, _, _ = _state(root) + if ( + journal.is_symlink() + or not journal.is_file() + or len(snapshot) > 64 * 1024 + or journal.read_bytes() != snapshot + ): + raise script_install.InstallUnsafeError( + "MaaEnd 安装记录在确认期间发生变化,拒绝旧请求" + ) + + +async def ask_install_recovery( + task_id: str, + root: Path, + *, + managed_name: str, + takeover_enabled: bool, +) -> tuple[str, bytes]: + journal, previous, ready = _state(root) + if journal.is_symlink() or not journal.is_file(): + raise script_install.InstallUnsafeError("MaaEnd 事务记录异常,禁止恢复") + snapshot = await asyncio.to_thread(journal.read_bytes) + if len(snapshot) > 64 * 1024: + raise script_install.InstallUnsafeError("MaaEnd 事务记录过大,禁止自动恢复") + can_skip = not ( + previous.exists() + or previous.is_symlink() + or ready.exists() + or ready.is_symlink() + ) + try: + installed = str((await asyncio.to_thread(script_update.read_install, root))[0]) + except Exception: + 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.MAAEND_INSTALL_DECISION, receive + ) + try: + sent = await Publisher.send( + id=protocol.ID_TASK_MANAGER, + type=protocol.MAAEND_INSTALL_PROMPT, + data={ + "requestId": request_id, + "taskId": task_id, + "managedName": managed_name, + "installPath": str(root.resolve()), + "installedVersion": installed, + "takeoverEnabled": takeover_enabled, + "canSkip": can_skip, + }, + ) + if not sent: + raise script_install.InstallUnsafeError( + "MaaEnd 未完成安装事务尚未恢复,主界面未连接" + ) + try: + action = await asyncio.wait_for(future, timeout=_TIMEOUT) + except TimeoutError as error: + raise script_install.InstallUnsafeError( + "MaaEnd 恢复确认超时,已保留原安装事务" + ) from error + if action == "stop": + raise script_install.InstallUnsafeError( + "用户取消 MaaEnd 恢复,本轮已停止,保留安装事务" + ) + if action == "skip" and not can_skip: + raise script_install.InstallUnsafeError( + "MaaEnd 仍有原始备份或暂存,不能跳过未完成的安装" + ) + await asyncio.to_thread(confirm_snapshot, root, snapshot) + return action, snapshot + finally: + unregister() + try: + await Publisher.send( + id=protocol.ID_TASK_MANAGER, + type=protocol.MAAEND_INSTALL_PROMPT_CLOSED, + data={"requestId": request_id}, + ) + except Exception: + logger.warning("MaaEnd 安装恢复弹窗关闭通知失败") + + +async def skip_stale_transaction(root: Path, snapshot: bytes) -> str: + """只归档没有备份/待切换目录的旧 journal;核对原安装与版本回执。""" + marker, previous, ready = _state(root) + await asyncio.to_thread(script_update.assert_idle, root) + confirm_snapshot(root, snapshot) + if any(p.exists() or p.is_symlink() for p in (previous, ready)): + raise script_install.InstallUnsafeError( + "MaaEnd 有旧安装备份或 ready 暂存目录,禁止跳过" + ) + try: + version, arch = await asyncio.to_thread(script_update.read_install, root) + if arch != "x86_64": + raise ValueError("MaaEnd 架构不可识别") + except (ValueError, OSError) as error: + raise script_install.InstallUnsafeError( + "MaaEnd 当前主程序、资源或版本不完整,无法确认手动恢复" + ) from error + receipt = script_install.record_path(root, "installed.json") + if receipt.exists() and not await script_install.run_worker( + script_install.verify_installed_files, root + ): + raise script_install.InstallUnsafeError( + "MaaEnd 当前文件与 MAS 校验回执不符,不允许跳过;请手动修复" + ) + await asyncio.to_thread(script_update.assert_idle, root) + confirm_snapshot(root, snapshot) + if any(p.exists() or p.is_symlink() for p in (previous, ready)): + raise script_install.InstallUnsafeError( + "MaaEnd 原始备份或暂存目录在恢复过程中发生变化" + ) + backup = marker.with_name(f"maaend-transaction-ack-{uuid4().hex}.json") + marker.replace(backup) + try: + await script_update.confirm_current_install(root) + except BaseException: + if not marker.exists() and backup.exists(): + backup.replace(marker) + raise + logger.warning(f"用户确认 MaaEnd 已手动修复并跳过遗留安装记录:{version}") + return str(version) diff --git a/changelog.d/maaend-transaction-recovery.fix.md b/changelog.d/maaend-transaction-recovery.fix.md new file mode 100644 index 0000000000..82e3346e6b --- /dev/null +++ b/changelog.d/maaend-transaction-recovery.fix.md @@ -0,0 +1,2 @@ +project: maaend +为 MaaEnd 安装事务失败提供人工确认恢复入口 From 8a51c468de87e4efece92d321ebe217cc3400cde Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:22:47 +0100 Subject: [PATCH 02/29] feat(maaend): add native transaction recovery modal and supported architectures --- app/task/MaaEnd/manager.py | 6 +- app/task/MaaEnd/tools/install_recovery.py | 2 +- frontend/src/App.vue | 2 + frontend/src/bootstrap/realtimeResidents.ts | 8 + .../components/MaaEndInstallRecoveryModal.vue | 147 ++++++++++++++++++ frontend/src/i18n/bootstrap/en-US.json | 16 ++ frontend/src/i18n/bootstrap/ja-JP.json | 16 ++ frontend/src/i18n/bootstrap/zh-CN.json | 16 ++ .../src/services/maaendInstallRecovery.ts | 86 ++++++++++ frontend/src/services/websocket/types.ts | 20 +++ 10 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/MaaEndInstallRecoveryModal.vue create mode 100644 frontend/src/services/maaendInstallRecovery.ts diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index 2f7419775b..b9357b69ff 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -492,8 +492,7 @@ async def main_task(self): raise script_install.InstallUnsafeError( "MaaEnd 事务记录是符号链接,拒绝恢复" ) - recovered_by_user = False - try: + try: await script_install.run_worker( script_install.recover_install, root, @@ -527,8 +526,7 @@ async def main_task(self): verify_receipt=config.Update.TakeoverEnabled, ) await self.script_info.set_log("MaaEnd 旧安装事务已重新恢复") - recovered_by_user = True - # 显式恢复操作本轮不再重复下载/覆盖新包。 + # 显式恢复操作本轮不再重复下载/覆盖新包。 self._program_update_prepared = True if self.execution_mode == "AutoProxy" and config.Update.TakeoverEnabled: self.maaend_config_dir = root / "config" diff --git a/app/task/MaaEnd/tools/install_recovery.py b/app/task/MaaEnd/tools/install_recovery.py index 5d42642f5c..15aa7eb99e 100644 --- a/app/task/MaaEnd/tools/install_recovery.py +++ b/app/task/MaaEnd/tools/install_recovery.py @@ -142,7 +142,7 @@ async def skip_stale_transaction(root: Path, snapshot: bytes) -> str: ) try: version, arch = await asyncio.to_thread(script_update.read_install, root) - if arch != "x86_64": + if arch not in ("x86_64", "aarch64"): raise ValueError("MaaEnd 架构不可识别") except (ValueError, OSError) as error: raise script_install.InstallUnsafeError( diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 543f4338d2..672deb7a8c 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 MaaEndInstallRecoveryModal from './components/MaaEndInstallRecoveryModal.vue' import GlobalPowerCountdown from './components/GlobalPowerCountdown.vue' import AppClosingOverlay from './components/AppClosingOverlay.vue' import BackendStartupOverlay from './components/BackendStartupOverlay.vue' @@ -155,6 +156,7 @@ onMounted(async () => { +
diff --git a/frontend/src/bootstrap/realtimeResidents.ts b/frontend/src/bootstrap/realtimeResidents.ts index 0efa1d2859..0326fd28d4 100644 --- a/frontend/src/bootstrap/realtimeResidents.ts +++ b/frontend/src/bootstrap/realtimeResidents.ts @@ -21,6 +21,10 @@ import { bootstrapVirtualDisplayPrompt, disposeVirtualDisplayPrompt, } from '@/services/virtualDisplayPrompt' +import { + bootstrapMaaEndInstallRecovery, + disposeMaaEndInstallRecovery, +} from '@/services/maaendInstallRecovery' let registered = false @@ -71,6 +75,10 @@ export function bootstrapRealtimeResidents(): void { bootstrap: bootstrapVirtualDisplayPrompt, dispose: disposeVirtualDisplayPrompt, }) + registerResidentResource('maaend-install-recovery', { + bootstrap: bootstrapMaaEndInstallRecovery, + dispose: disposeMaaEndInstallRecovery, + }) registered = true } bootstrapResidentResources() diff --git a/frontend/src/components/MaaEndInstallRecoveryModal.vue b/frontend/src/components/MaaEndInstallRecoveryModal.vue new file mode 100644 index 0000000000..14a8c6a5bd --- /dev/null +++ b/frontend/src/components/MaaEndInstallRecoveryModal.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend/src/i18n/bootstrap/en-US.json b/frontend/src/i18n/bootstrap/en-US.json index f5abbfe3aa..73b809fe04 100644 --- a/frontend/src/i18n/bootstrap/en-US.json +++ b/frontend/src/i18n/bootstrap/en-US.json @@ -3,6 +3,22 @@ "languageSaveFailed": "Failed to save the language setting; reverted to the previous one." }, "comp": { + "maaendRecoveryAcknowledgement": "I repaired this MaaEnd installation and understand the risk of dismissing its incomplete transaction.", + "maaendRecoveryCannotSkip": "A previous backup or staging directory still exists; the installation transaction must be recovered.", + "maaendRecoveryCurrent": "Installed version", + "maaendRecoveryDisabled": "Disabled", + "maaendRecoveryEnabled": "Enabled", + "maaendRecoveryInstallPath": "Installation folder", + "maaendRecoveryManagedName": "Managed profile", + "maaendRecoveryMessage": "An interrupted MaaEnd/MXU installation could not be recovered automatically. Verify the profile and installation folder.", + "maaendRecoveryRetry": "Retry transaction recovery", + "maaendRecoveryRisk": "Skipping requires no saved backup or pending install directory, a recognizable installed application, valid existing integrity receipt and stopped processes. This does not guarantee MXU's own pending state or all resources.", + "maaendRecoverySendFailed": "Could not send recovery decision. Check MAS connectivity.", + "maaendRecoverySkip": "Skip and continue", + "maaendRecoveryStop": "Cancel task", + "maaendRecoveryTakeover": "Managed updates", + "maaendRecoveryTitle": "MaaEnd installation transaction could not be recovered", + "maaendRecoveryUnknown": "Unknown", "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..d800a89871 100644 --- a/frontend/src/i18n/bootstrap/ja-JP.json +++ b/frontend/src/i18n/bootstrap/ja-JP.json @@ -3,6 +3,22 @@ "languageSaveFailed": "言語設定の保存に失敗しました。元の言語に戻しました。" }, "comp": { + "maaendRecoveryAcknowledgement": "この MaaEnd インストールを修復し、未完了の更新を無視するリスクを理解しています。", + "maaendRecoveryCannotSkip": "バックアップまたは待機ディレクトリが残っています。更新の復元が必要です。", + "maaendRecoveryCurrent": "現在のバージョン", + "maaendRecoveryDisabled": "無効", + "maaendRecoveryEnabled": "有効", + "maaendRecoveryInstallPath": "インストール先", + "maaendRecoveryManagedName": "管理設定名", + "maaendRecoveryMessage": "MaaEnd/MXU のインストールが中断され自動復元に失敗しました。対象設定とインストール先を確認してください。", + "maaendRecoveryRetry": "トランザクション復元を再試行", + "maaendRecoveryRisk": "バックアップと待機ディレクトリが存在せず、プログラム・既存の検証記録・実行中プロセスを確認できる場合だけスキップ可能です。MXU の状態全体は保証できません。", + "maaendRecoverySendFailed": "選択を送信できませんでした。接続を確認してください。", + "maaendRecoverySkip": "スキップして続行", + "maaendRecoveryStop": "タスク中止", + "maaendRecoveryTakeover": "自動更新", + "maaendRecoveryTitle": "MaaEnd の前回の導入が未完了です", + "maaendRecoveryUnknown": "不明", "backendAboutUpdateWhich": "バックエンドを更新します。更新にはバックエンドの再起動が必要で、実行中のタスクは中断されます。続行しますか?", "backendStarting": "バックエンドを起動しています", "backendUpdateAvailableClick": "バックエンドの更新があります。クリックして更新してください", diff --git a/frontend/src/i18n/bootstrap/zh-CN.json b/frontend/src/i18n/bootstrap/zh-CN.json index eb4f5910e6..3c97b047e7 100644 --- a/frontend/src/i18n/bootstrap/zh-CN.json +++ b/frontend/src/i18n/bootstrap/zh-CN.json @@ -3,6 +3,22 @@ "languageSaveFailed": "语言设置保存失败,已恢复原语言" }, "comp": { + "maaendRecoveryAcknowledgement": "我已手动修复这份 MaaEnd 安装,并了解忽略安装事务的风险。", + "maaendRecoveryCannotSkip": "仍有上次安装的备份或待切换目录,禁止跳过,必须完成事务恢复。", + "maaendRecoveryCurrent": "当前程序版本", + "maaendRecoveryDisabled": "已关闭", + "maaendRecoveryEnabled": "已启用", + "maaendRecoveryInstallPath": "安装目录", + "maaendRecoveryManagedName": "托管名称", + "maaendRecoveryMessage": "检测到 MaaEnd/MXU 上次安装中断且自动恢复失败。请核对托管与安装目录,再选择安全恢复方式。", + "maaendRecoveryRetry": "重试事务恢复", + "maaendRecoveryRisk": "只有原始备份和待切换目录都不存在、当前程序及资源可识别、安装校验回执符合预期且进程已退出时,才能确认跳过。MXU 原生更新记录与资源完整性并不因此保证。", + "maaendRecoverySendFailed": "无法发送恢复决定,请检查 MAS 连接。", + "maaendRecoverySkip": "跳过并继续", + "maaendRecoveryStop": "取消任务", + "maaendRecoveryTakeover": "更新托管", + "maaendRecoveryTitle": "MaaEnd 上次安装事务无法恢复", + "maaendRecoveryUnknown": "未知", "backendAboutUpdateWhich": "即将更新后端,这需要重启后端程序,你当前正在运行的任务将会被中断。确认继续?", "backendStarting": "正在启动后端", "backendUpdateAvailableClick": "检测到后端更新,点击以更新后端", diff --git a/frontend/src/services/maaendInstallRecovery.ts b/frontend/src/services/maaendInstallRecovery.ts new file mode 100644 index 0000000000..f25128b977 --- /dev/null +++ b/frontend/src/services/maaendInstallRecovery.ts @@ -0,0 +1,86 @@ +// 应用级 MaaEnd 安装恢复询问:首条主连接之前常驻订阅,避免路由切换漏掉请求。 +// 一次只能处理一个弹窗,后续请求排队;断线或后端关闭请求时立即清除旧选择。 + +import { ref } from 'vue' +import { onConnected, onDisconnected, send } from '@/services/websocket/connection' +import { subscribe, unsubscribe } from '@/services/websocket/subscriptions' +import { + WS_MAAEND_INSTALL_DECISION, + WS_MAAEND_INSTALL_PROMPT, + WS_MAAEND_INSTALL_PROMPT_CLOSED, + WS_ID_TASK_MANAGER, + type WSMaaEndInstallPromptData, +} from '@/services/websocket/types' + +const logger = window.electronAPI.getLogger('MaaEnd安装恢复') +export const maaendInstallPrompts = ref([]) +export const maaendInstallDecisionPending = ref(null) + +let subscriptionIds: string[] = [] +let disposeDisconnect: (() => void) | null = null +let disposeConnect: (() => void) | null = null + +const forgetRequest = (requestId: string) => { + maaendInstallPrompts.value = maaendInstallPrompts.value.filter( + prompt => prompt.requestId !== requestId + ) + if (maaendInstallDecisionPending.value === requestId) { + maaendInstallDecisionPending.value = null + } +} + +export function bootstrapMaaEndInstallRecovery(): void { + if (subscriptionIds.length) return + subscriptionIds = [ + subscribe({ id: WS_ID_TASK_MANAGER, type: WS_MAAEND_INSTALL_PROMPT }, message => { + const prompt = message.data + if ( + !prompt.requestId || + !prompt.taskId || + maaendInstallPrompts.value.some(previous => previous.requestId === prompt.requestId) + ) { + return + } + maaendInstallPrompts.value.push(prompt) + }), + subscribe({ id: WS_ID_TASK_MANAGER, type: WS_MAAEND_INSTALL_PROMPT_CLOSED }, message => { + forgetRequest(message.data.requestId) + }), + ] + disposeDisconnect = onDisconnected(() => { + maaendInstallPrompts.value = [] + maaendInstallDecisionPending.value = null + }) + // reconnectNow 在切换连接代次时不会触发 onDisconnected。 + // 新连接建立后,旧连接的关闭帧可能已丢失;绝不可沿用旧确认请求。 + disposeConnect = onConnected(() => { + maaendInstallPrompts.value = [] + maaendInstallDecisionPending.value = null + }) +} + +export function respondToMaaEndInstallRecovery(action: 'retry' | 'skip' | 'stop'): boolean { + const prompt = maaendInstallPrompts.value[0] + if (!prompt || maaendInstallDecisionPending.value) return false + const sent = send(WS_ID_TASK_MANAGER, WS_MAAEND_INSTALL_DECISION, { + requestId: prompt.requestId, + taskId: prompt.taskId, + action, + }) + if (!sent) { + logger.warn('未能发送 MaaEnd 安装恢复选择') + return false + } + maaendInstallDecisionPending.value = prompt.requestId + return true +} + +export function disposeMaaEndInstallRecovery(): void { + for (const id of subscriptionIds.splice(0)) unsubscribe(id) + disposeDisconnect?.() + disposeDisconnect = null + disposeConnect?.() + disposeConnect = null + maaendInstallPrompts.value = [] + maaendInstallDecisionPending.value = null +} diff --git a/frontend/src/services/websocket/types.ts b/frontend/src/services/websocket/types.ts index d7f0005eba..c12e7e0631 100644 --- a/frontend/src/services/websocket/types.ts +++ b/frontend/src/services/websocket/types.ts @@ -49,6 +49,9 @@ export const WS_TASK_CREATED = 'task.created' // 每次脚本执行(含循环中的每轮),不随快照重放(id=TaskManager)。 export const WS_TASK_SCRIPT_STARTED = 'task.script.started' export const WS_TASK_SCRIPT_FINISHED = 'task.script.finished' +export const WS_MAAEND_INSTALL_PROMPT = 'maaend.install.prompt' +export const WS_MAAEND_INSTALL_PROMPT_CLOSED = 'maaend.install.prompt.closed' +export const WS_MAAEND_INSTALL_DECISION = 'maaend.install.decision' // 应用生命周期与电源(id=Main) export const WS_BACKEND_SHUTDOWN_READY = 'backend.shutdown.ready' @@ -160,6 +163,21 @@ export interface WSTaskScriptFinishedData extends WSTaskScriptStartedData { partialFailure?: 'yes' | 'no' | 'unknown' } +/** MaaEnd 安装事务失败的用户确认请求 */ +export interface WSMaaEndInstallPromptData { + requestId: string + taskId: string + managedName: string + installPath: string + installedVersion: string + takeoverEnabled: boolean + canSkip: boolean +} + +export interface WSMaaEndInstallPromptClosedData { + requestId: string +} + /** 新任务创建通知数据 (id=TaskManager, type=task.created) */ export interface WSTaskCreatedData { trigger?: TaskTriggerReason @@ -329,6 +347,8 @@ interface WSMessageDataMap { [WS_TASK_CREATED]: WSTaskCreatedData [WS_TASK_SCRIPT_STARTED]: WSTaskScriptStartedData [WS_TASK_SCRIPT_FINISHED]: WSTaskScriptFinishedData + [WS_MAAEND_INSTALL_PROMPT]: WSMaaEndInstallPromptData + [WS_MAAEND_INSTALL_PROMPT_CLOSED]: WSMaaEndInstallPromptClosedData [WS_BACKEND_SHUTDOWN_READY]: WSEmptyData [WS_FRONTEND_CLOSE_REQUESTED]: FrontendCloseRequest [WS_FRONTEND_RESTART_REQUESTED]: FrontendCloseRequest From bec1a99e8c2f849d01985c2d6208a458d5b9c394 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:23:32 +0100 Subject: [PATCH 03/29] test(maaend): cover safe stale transactions and failed recovery prompts --- tests/task/test_maaend_program_update.py | 142 +++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index 9e7f70632d..40be051930 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -936,3 +936,145 @@ async def test_no_update_prefetch_is_rechecked_at_script_start( await scheduler._prepare_program_update(root) check.assert_awaited_once() apply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_maaend_failed_transaction_can_be_confirmed_after_manual_repair( + installation, +): + from app.task.MaaEnd.tools import install_recovery + + root, _ = installation + work = script_install.workspace(root) + work.mkdir(parents=True) + marker = work / "transaction.json" + marker.write_bytes(b"stale-invalid-install-record") + with script_install.installation_access(root): + result = await install_recovery.skip_stale_transaction( + root, marker.read_bytes() + ) + assert result == "2.1.0" + assert not marker.exists() + backups = list(work.glob("maaend-transaction-ack-*.json")) + assert len(backups) == 1 + assert backups[0].read_bytes() == b"stale-invalid-install-record" + await update.confirm_current_install(root) + + +@pytest.mark.asyncio +async def test_maaend_backup_and_ready_prevent_any_skip(installation): + from app.task.MaaEnd.tools import install_recovery + + root, _ = installation + work = script_install.workspace(root) + work.mkdir(parents=True) + marker = work / "transaction.json" + marker.write_bytes(b"stale-invalid-install-record") + original = marker.read_bytes() + (work / "previous").mkdir() + with pytest.raises(script_install.InstallUnsafeError, match="备份"): + await install_recovery.skip_stale_transaction(root, original) + (work / "previous").rmdir() + (work / "ready").mkdir() + with pytest.raises(script_install.InstallUnsafeError, match="暂存"): + await install_recovery.skip_stale_transaction(root, original) + assert marker.read_bytes() == original + + +@pytest.mark.asyncio +async def test_maaend_corrupt_executable_or_changed_marker_cannot_skip(installation): + from app.task.MaaEnd.tools import install_recovery + + root, _ = installation + work = script_install.workspace(root) + work.mkdir(parents=True) + marker = work / "transaction.json" + marker.write_bytes(b"original") + with pytest.raises(script_install.InstallUnsafeError, match="变化"): + await install_recovery.skip_stale_transaction(root, b"old marker") + (root / "MaaEnd.exe").write_bytes(b"damage") + with pytest.raises(script_install.InstallUnsafeError, match="不完整"): + await install_recovery.skip_stale_transaction(root, marker.read_bytes()) + assert marker.exists() + + +@pytest.mark.asyncio +async def test_maaend_recovery_prompt_fails_closed_without_frontend( + installation, monkeypatch +): + from app.task.MaaEnd.tools import install_recovery + + root, _ = installation + work = script_install.workspace(root) + work.mkdir(parents=True) + marker = work / "transaction.json" + marker.write_bytes(b"stale") + monkeypatch.setattr( + install_recovery.Publisher, "send", AsyncMock(return_value=False) + ) + with pytest.raises(script_install.InstallUnsafeError, match="主界面"): + await install_recovery.ask_install_recovery( + "task", root, managed_name="MaaEnd 实例 B", takeover_enabled=False + ) + assert marker.read_bytes() == b"stale" + + +@pytest.mark.asyncio +async def test_maaend_recovery_prompt_identifies_target_and_cancellation( + installation, monkeypatch +): + from app.core import Dispatcher + from app.core.ws import protocol + from app.models.schema import WSEnvelope + from app.task.MaaEnd.tools import install_recovery + + root, _ = installation + work = script_install.workspace(root) + work.mkdir(parents=True) + marker = work / "transaction.json" + marker.write_bytes(b"stale") + notices = [] + + async def send(*, id, type, data): + if type == protocol.MAAEND_INSTALL_PROMPT: + notices.append(data) + Dispatcher.dispatch( + WSEnvelope( + id=id, + type=protocol.MAAEND_INSTALL_DECISION, + data={ + "requestId": data["requestId"], + "taskId": data["taskId"], + "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", root, managed_name="MaaEnd B", takeover_enabled=False + ) + assert notices[0]["managedName"] == "MaaEnd B" + assert notices[0]["installPath"] == str(root.resolve()) + assert notices[0]["canSkip"] is True + assert marker.exists() + + +@pytest.mark.asyncio +async def test_maaend_invalid_receipt_cannot_be_manually_bypassed( + installation, monkeypatch +): + from app.task.MaaEnd.tools import install_recovery + + root, config = installation + await update.update_before_run(root, config) + work = script_install.workspace(root) + work.mkdir(parents=True) + marker = work / "transaction.json" + marker.write_bytes(b"unconfirmed") + (root / "resource/data.json").write_bytes(b"damaged") + with pytest.raises(script_install.InstallUnsafeError, match="回执"): + await install_recovery.skip_stale_transaction(root, marker.read_bytes()) + assert marker.is_file() From 0029bfefd9e12975ec53d0d80a3fd775ea3f570c Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:25:44 +0100 Subject: [PATCH 04/29] fix(maaend): repair recovery manager indentation and modal markup --- app/task/MaaEnd/manager.py | 4 ++-- frontend/src/components/MaaEndInstallRecoveryModal.vue | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index b9357b69ff..0a90be9abb 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -492,7 +492,7 @@ async def main_task(self): raise script_install.InstallUnsafeError( "MaaEnd 事务记录是符号链接,拒绝恢复" ) - try: + try: await script_install.run_worker( script_install.recover_install, root, @@ -526,7 +526,7 @@ async def main_task(self): verify_receipt=config.Update.TakeoverEnabled, ) await self.script_info.set_log("MaaEnd 旧安装事务已重新恢复") - # 显式恢复操作本轮不再重复下载/覆盖新包。 + # 显式恢复操作本轮不再重复下载/覆盖新包。 self._program_update_prepared = True if self.execution_mode == "AutoProxy" and config.Update.TakeoverEnabled: self.maaend_config_dir = root / "config" diff --git a/frontend/src/components/MaaEndInstallRecoveryModal.vue b/frontend/src/components/MaaEndInstallRecoveryModal.vue index 14a8c6a5bd..a2913799cd 100644 --- a/frontend/src/components/MaaEndInstallRecoveryModal.vue +++ b/frontend/src/components/MaaEndInstallRecoveryModal.vue @@ -54,7 +54,7 @@ function decide(action: 'retry' | 'skip' | 'stop') { {{ prompt.installedVersion || t('bootstrap.comp.maaendRecoveryUnknown') }} - + {{ t( prompt.takeoverEnabled From 9ded87cded3caa5d95082a5f3f6a0fc7d6f60bd0 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:31:42 +0100 Subject: [PATCH 05/29] fix(maaend): retain MXU cache cleanup and respect disabled takeover receipts --- app/task/MaaEnd/manager.py | 20 ++++++++++++---- app/task/MaaEnd/tools/install_recovery.py | 16 +++++++++---- tests/task/test_maaend_program_update.py | 28 +++++++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index 0a90be9abb..83bd9f4936 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -335,6 +335,11 @@ async def progress(downloaded: int, total: int, speed: float) -> None: ) else: await push_dispatch_log(self.script_info, result) + await self._refresh_after_program_install(root) + self._program_update_prepared = True + + async def _refresh_after_program_install(self, root: Path) -> None: + """不重复安装,但必须照常校验资源并清理过时的 MXU 缓存包。""" await asyncio.to_thread( MaaEndResourceLoader.get_cached, root, force_reload=True ) @@ -349,7 +354,6 @@ async def progress(downloaded: int, total: int, speed: float) -> None: self.script_info, f"已移除 {len(removed)} 个过期 MXU 更新包;MXU 启动时将清除对应的待安装记录", ) - self._program_update_prepared = True async def _restore_script_config_from_temp(self) -> None: """恢复任务开始前的 MaaEnd working 配置。""" @@ -492,6 +496,7 @@ async def main_task(self): raise script_install.InstallUnsafeError( "MaaEnd 事务记录是符号链接,拒绝恢复" ) + recovered_by_user = False try: await script_install.run_worker( script_install.recover_install, @@ -509,7 +514,7 @@ async def main_task(self): ) if action == "skip": result = await install_recovery.skip_stale_transaction( - root, snapshot + root, snapshot, takeover_enabled=config.Update.TakeoverEnabled ) await self.script_info.set_log( f"用户确认跳过 MaaEnd 无法恢复的旧事务,当前版本 {result}" @@ -526,14 +531,19 @@ async def main_task(self): verify_receipt=config.Update.TakeoverEnabled, ) await self.script_info.set_log("MaaEnd 旧安装事务已重新恢复") - # 显式恢复操作本轮不再重复下载/覆盖新包。 - self._program_update_prepared = True + # 显式恢复操作本轮不再重复下载/覆盖新包,但仍须完成 + # 原有资源加载与 MXU 旧包清理阶段。 + recovered_by_user = True if self.execution_mode == "AutoProxy" and config.Update.TakeoverEnabled: self.maaend_config_dir = root / "config" self.temp_path = Path.cwd() / f"data/{self.script_info.script_id}/Temp" self._recover_previous_run() # 修复在原有资源预检之前,损坏的旧资源不能堵死恢复入口。 - await self._prepare_program_update(root) + if recovered_by_user: + await self._refresh_after_program_install(root) + self._program_update_prepared = True + else: + await self._prepare_program_update(root) elif script_install.record_path(root, "installed.json").exists(): # 非接管模式沿用 MXU 自行更新的安装。恢复事务仍须完成, # 但旧 MAS 回执不应阻断后续配置或运行,也不重算哈希冒充可信。 diff --git a/app/task/MaaEnd/tools/install_recovery.py b/app/task/MaaEnd/tools/install_recovery.py index 15aa7eb99e..acfb36723d 100644 --- a/app/task/MaaEnd/tools/install_recovery.py +++ b/app/task/MaaEnd/tools/install_recovery.py @@ -131,7 +131,9 @@ def receive(envelope) -> None: logger.warning("MaaEnd 安装恢复弹窗关闭通知失败") -async def skip_stale_transaction(root: Path, snapshot: bytes) -> str: +async def skip_stale_transaction( + root: Path, snapshot: bytes, *, takeover_enabled: bool = True +) -> str: """只归档没有备份/待切换目录的旧 journal;核对原安装与版本回执。""" marker, previous, ready = _state(root) await asyncio.to_thread(script_update.assert_idle, root) @@ -149,8 +151,12 @@ async def skip_stale_transaction(root: Path, snapshot: bytes) -> str: "MaaEnd 当前主程序、资源或版本不完整,无法确认手动恢复" ) from error receipt = script_install.record_path(root, "installed.json") - if receipt.exists() and not await script_install.run_worker( - script_install.verify_installed_files, root + if ( + takeover_enabled + and receipt.exists() + and not await script_install.run_worker( + script_install.verify_installed_files, root + ) ): raise script_install.InstallUnsafeError( "MaaEnd 当前文件与 MAS 校验回执不符,不允许跳过;请手动修复" @@ -164,7 +170,9 @@ async def skip_stale_transaction(root: Path, snapshot: bytes) -> str: backup = marker.with_name(f"maaend-transaction-ack-{uuid4().hex}.json") marker.replace(backup) try: - await script_update.confirm_current_install(root) + await script_update.confirm_current_install( + root, verify_receipt=takeover_enabled + ) except BaseException: if not marker.exists() and backup.exists(): backup.replace(marker) diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index 40be051930..6c4c3fb6d4 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -1078,3 +1078,31 @@ async def test_maaend_invalid_receipt_cannot_be_manually_bypassed( with pytest.raises(script_install.InstallUnsafeError, match="回执"): await install_recovery.skip_stale_transaction(root, marker.read_bytes()) assert marker.is_file() + + +@pytest.mark.asyncio +async def test_maaend_manual_mxu_repair_can_ignore_old_mas_receipt_when_takeover_off( + installation, +): + from app.task.MaaEnd.tools import install_recovery + + root, config = installation + await update.update_before_run(root, config) + (root / "interface.json").write_text( + json.dumps({"name": "MaaEnd", "version": "v2.3.0", "mirrorchyan_rid": "MaaEnd"}) + ) + work = script_install.workspace(root) + work.mkdir(parents=True, exist_ok=True) + marker = work / "transaction.json" + marker.write_bytes(b"old-transaction") + assert not script_install.verify_installed_files(root, threading.Event()) + with pytest.raises(script_install.InstallUnsafeError, match="回执"): + await install_recovery.skip_stale_transaction( + root, marker.read_bytes(), takeover_enabled=True + ) + result = await install_recovery.skip_stale_transaction( + root, marker.read_bytes(), takeover_enabled=False + ) + assert result == "2.3.0" + assert not marker.exists() + await update.confirm_current_install(root, verify_receipt=False) From 0d22c8f16727246ff33d2a1be6ba56e0d9aa9db1 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:53:39 +0100 Subject: [PATCH 06/29] fix(recovery): accept legitimate multi-file installation journals beyond 64 KiB --- app/task/MaaEnd/tools/install_recovery.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/task/MaaEnd/tools/install_recovery.py b/app/task/MaaEnd/tools/install_recovery.py index acfb36723d..4d466b9630 100644 --- a/app/task/MaaEnd/tools/install_recovery.py +++ b/app/task/MaaEnd/tools/install_recovery.py @@ -21,6 +21,8 @@ logger = get_logger("MaaEnd 安装恢复") _TIMEOUT = 120 +# 完整事务包含三份路径哈希清单;64 KiB 不是实际安装的有效上限。 +_MAX_TRANSACTION_BYTES = 128 * 1024 * 1024 def _state(root: Path) -> tuple[Path, Path, Path]: @@ -37,7 +39,7 @@ def confirm_snapshot(root: Path, snapshot: bytes) -> None: if ( journal.is_symlink() or not journal.is_file() - or len(snapshot) > 64 * 1024 + or len(snapshot) > _MAX_TRANSACTION_BYTES or journal.read_bytes() != snapshot ): raise script_install.InstallUnsafeError( @@ -56,7 +58,7 @@ async def ask_install_recovery( if journal.is_symlink() or not journal.is_file(): raise script_install.InstallUnsafeError("MaaEnd 事务记录异常,禁止恢复") snapshot = await asyncio.to_thread(journal.read_bytes) - if len(snapshot) > 64 * 1024: + if len(snapshot) > _MAX_TRANSACTION_BYTES: raise script_install.InstallUnsafeError("MaaEnd 事务记录过大,禁止自动恢复") can_skip = not ( previous.exists() From fe50ba9eaa0bd3d26e2481c897d21e3fa9e62526 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:38:23 +0100 Subject: [PATCH 07/29] feat(maaend): port #1910 verified official full-package repair identity into primary #1913 --- app/task/MaaEnd/tools/script_update.py | 49 +++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/app/task/MaaEnd/tools/script_update.py b/app/task/MaaEnd/tools/script_update.py index 516a3c35b7..cc83e809ad 100644 --- a/app/task/MaaEnd/tools/script_update.py +++ b/app/task/MaaEnd/tools/script_update.py @@ -363,13 +363,60 @@ def validate(path: Path) -> None: discard_update(candidate) +def verify_repair_identity(root: Path, snapshot: bytes) -> None: + """只对用户授权的同一份既有 MAS 安装身份执行修复,拒绝换根/换架构。""" + path = script_install.record_path(root, "program-identity.json") + if ( + path.is_symlink() + or not path.is_file() + or not snapshot + or len(snapshot) > 16 * 1024 + or path.read_bytes() != snapshot + ): + raise script_install.InstallUnsafeError( + "MaaEnd 安装身份记录已变化,不允许继续旧修复请求" + ) + try: + record = json.loads(snapshot) + if record.get("arch") not in ("x86_64", "aarch64"): + raise ValueError("untrusted architecture") + except (ValueError, TypeError, AttributeError) as error: + raise script_install.InstallUnsafeError( + "MaaEnd 安装身份缺少可信架构,必须由官方安装器手动修复" + ) from error + + async def update_before_run( - root: Path, config: MaaEndConfig, *, progress: DownloadProgress | None = None + root: Path, + config: MaaEndConfig, + *, + progress: DownloadProgress | None = None, + recovery_identity: bytes | None = None, ) -> str: """手动与无预取路径仍在安装锁下完整等待更新。""" await asyncio.to_thread(assert_idle, root) + if recovery_identity is not None: + verify_repair_identity(root, recovery_identity) + try: + await asyncio.to_thread(read_install, root) + except (OSError, ValueError): + pass + else: + raise script_install.InstallUnsafeError( + "MaaEnd 已由外部安装器修复,请重新检查,不再沿用旧请求" + ) candidate = await prepare_update(root, config, progress=progress) try: + if recovery_identity is not None: + verify_repair_identity(root, recovery_identity) + try: + await asyncio.to_thread(read_install, root) + except (OSError, ValueError): + pass + else: + raise script_install.InstallUnsafeError( + "MaaEnd 安装在下载期间已变化,请重新检查" + ) return await apply_update(root, candidate) finally: discard_update(candidate) From 529f1dab627f63dda8651a903732fd06f2c94e70 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:38:56 +0100 Subject: [PATCH 08/29] feat(maaend): provide #1916 native journal and trusted repair health callbacks without dialogs --- app/task/MaaEnd/tools/managed_recovery.py | 111 ++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 app/task/MaaEnd/tools/managed_recovery.py diff --git a/app/task/MaaEnd/tools/managed_recovery.py b/app/task/MaaEnd/tools/managed_recovery.py new file mode 100644 index 0000000000..f2b8e780eb --- /dev/null +++ b/app/task/MaaEnd/tools/managed_recovery.py @@ -0,0 +1,111 @@ +# Copyright © 2025-2026 AUTO-MAS Team +# SPDX-License-Identifier: AGPL-3.0-or-later +"""MaaEnd 安装状态与修复适配器。公共 #1916 负责决定恢复策略。""" + +import asyncio +from pathlib import Path +from uuid import uuid4 + +from app.core.script_update_recovery import InstallInspection +from app.services import script_install + +from . import script_update + +_MAX_TRANSACTION_BYTES = 128 * 1024 * 1024 + + +class MaaEndInstallAdapter: + def __init__(self, root: Path, config, *, takeover_enabled: bool) -> None: + self.root = root + self.config = config + self.takeover_enabled = takeover_enabled + + async def ensure_idle(self) -> None: + await asyncio.to_thread(script_update.assert_idle, self.root) + + async def inspect(self) -> InstallInspection: + await self.ensure_idle() + directory = script_install.workspace(self.root) + marker = directory / "transaction.json" + previous = directory / "previous" + ready = directory / "ready" + if any(p.is_symlink() for p in (directory, marker, previous, ready)): + raise script_install.InstallUnsafeError("MaaEnd 原生安装事务路径不安全") + pending = marker.exists() + if previous.exists() or ready.exists(): + return InstallInspection(pending=True, healthy=False) + try: + version, architecture = await asyncio.to_thread( + script_update.read_install, self.root + ) + healthy = architecture in {"x86_64", "aarch64"} + receipt = script_install.record_path(self.root, "installed.json") + if healthy and self.takeover_enabled and receipt.is_file(): + healthy = await script_install.run_worker( + script_install.verify_installed_files, self.root + ) + except (OSError, ValueError, script_install.InstallUnsafeError): + return InstallInspection(pending=pending, healthy=False) + return InstallInspection( + pending=pending, + healthy=healthy, + installed_version=str(version), + # 原安装记录存在不等于新版本已完成,必须再次检查回执。 + updated_confirmed=healthy and pending, + previous_confirmed=healthy and not pending, + ) + + async def acknowledge(self) -> None: + """只归档无原版备份、版本和安装清单仍完整的遗留 journal。""" + state = await self.inspect() + if not (state.pending and state.healthy and state.updated_confirmed): + raise script_install.InstallUnsafeError( + "MaaEnd 遗留事务尚未完成,不允许清除记录" + ) + directory = script_install.workspace(self.root) + marker = directory / "transaction.json" + snapshot = marker.read_bytes() + if not snapshot or len(snapshot) > _MAX_TRANSACTION_BYTES: + raise script_install.InstallUnsafeError("原生事务标记无效") + await self.ensure_idle() + if marker.is_symlink() or marker.read_bytes() != snapshot: + raise script_install.InstallUnsafeError("事务内容在恢复时发生变化") + if any( + p.exists() or p.is_symlink() + for p in (directory / "previous", directory / "ready") + ): + raise script_install.InstallUnsafeError("仍有可恢复的安装目录") + archive = directory / f"maaend-transaction-ack-{uuid4().hex}.json" + marker.replace(archive) + try: + await script_update.confirm_current_install( + self.root, verify_receipt=self.takeover_enabled + ) + except BaseException: + if archive.exists() and not marker.exists(): + archive.replace(marker) + raise + + async def retry_native(self) -> None: + await script_install.run_worker( + script_install.recover_install, + self.root, + lambda: script_update.assert_idle(self.root), + ) + await script_update.confirm_current_install( + self.root, verify_receipt=self.takeover_enabled + ) + + async def repair_program(self) -> None: + identity = script_install.record_path(self.root, "program-identity.json") + if identity.is_symlink() or not identity.is_file(): + raise script_install.InstallUnsafeError( + "当前 MaaEnd 安装损坏且缺少可信架构身份,不能自动重装" + ) + snapshot = identity.read_bytes() + script_update.verify_repair_identity(self.root, snapshot) + await script_update.update_before_run( + self.root, self.config, recovery_identity=snapshot + ) + await script_update.confirm_current_install(self.root) + From 62e4a4b750e0b272c15c3b03822c87796bd45e52 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:39:31 +0100 Subject: [PATCH 09/29] refactor(maaend): use #1916 for stale native transactions and verified full-package repairs without modal --- app/task/MaaEnd/manager.py | 79 ++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index 83bd9f4936..5826fc5a84 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -24,7 +24,11 @@ from app.config import ConfigCollection, config_manager from app.core import Config, EmulatorManager, Notify, Publisher from app.core.notify.task_content import screenshot_entries as screenshot_entries -from app.core.script_update import ScriptUpdateRequest, run_script_update +from app.core.script_update import ( + ScriptUpdateRequest, + auto_recover_script_install, + run_script_update, +) from app.core.ws import protocol from app.models.config import MaaEndConfig, MaaEndUserConfig, UserConfigType from app.models.emulator import DeviceProvider @@ -51,7 +55,8 @@ from .AutoProxy import AutoProxyTask from .resource_loader import MaaEndResourceLoader, load_maaend_controller_protocol from .ScriptConfig import ScriptConfigTask, maaend_config_base -from .tools import install_recovery, script_update +from .tools import script_update +from .tools.managed_recovery import MaaEndInstallAdapter from .tools.backup_archive import archive_native_backup from .tools.update import ensure_game_updated from .Update import EndfieldUpdateTask @@ -489,57 +494,55 @@ async def main_task(self): if (script_install.workspace(root) / "runtime-interface.json").exists(): await asyncio.to_thread(script_update.assert_idle, root) script_update.recover_runtime_interface(root) - # 通用事务优先自动恢复;失败后必须以原安装目录为对象请求 - # 用户选择。允许人工确认不代表可以忽略待切换目录或进程占用。 journal = script_install.workspace(root) / "transaction.json" if journal.is_symlink(): - raise script_install.InstallUnsafeError( - "MaaEnd 事务记录是符号链接,拒绝恢复" - ) - recovered_by_user = False + raise script_install.InstallUnsafeError("MaaEnd 原生事务路径不安全") + adapter = MaaEndInstallAdapter( + root, config, takeover_enabled=config.Update.TakeoverEnabled + ) + request = ScriptUpdateRequest(str(self.script_info.script_id), "MaaEnd") + recovered_automatically = False try: - await script_install.run_worker( - script_install.recover_install, - root, - lambda: script_update.assert_idle(root), - ) + await adapter.retry_native() except script_install.InstallUnsafeError: if not journal.is_file(): raise - action, snapshot = await install_recovery.ask_install_recovery( - self.task_info.task_id, - root, - managed_name=getattr(self.script_info, "name", "MaaEnd"), - takeover_enabled=config.Update.TakeoverEnabled, + # 不再等待用户选择:只有已验证发行文件与安装回执才可 + # 清理过期 journal;否则重做原生事务回滚,失败保留现场。 + outcome = await auto_recover_script_install( + request, + install_root=root, + inspect=adapter.inspect, + acknowledge=adapter.acknowledge, + reinstall=adapter.retry_native, + ) + await self.script_info.set_log( + f"MaaEnd 原生事务自动恢复:{outcome}" ) - if action == "skip": - result = await install_recovery.skip_stale_transaction( - root, snapshot, takeover_enabled=config.Update.TakeoverEnabled + recovered_automatically = True + # 仅在已经存在 MAS 身份回执时允许自动修复损坏的本体。 + # 用户自选构建与手动模式不使用猜测的架构覆盖安装。 + identity = script_install.record_path(root, "program-identity.json") + if identity.is_file() and not identity.is_symlink(): + state = await adapter.inspect() + if not state.healthy and not state.pending: + outcome = await auto_recover_script_install( + request, + install_root=root, + inspect=adapter.inspect, + acknowledge=adapter.acknowledge, + reinstall=adapter.repair_program, ) await self.script_info.set_log( - f"用户确认跳过 MaaEnd 无法恢复的旧事务,当前版本 {result}" - ) - else: - install_recovery.confirm_snapshot(root, snapshot) - await script_install.run_worker( - script_install.recover_install, - root, - lambda: script_update.assert_idle(root), - ) - await script_update.confirm_current_install( - root, - verify_receipt=config.Update.TakeoverEnabled, + f"MaaEnd 官方完整包自动修复:{outcome}" ) - await self.script_info.set_log("MaaEnd 旧安装事务已重新恢复") - # 显式恢复操作本轮不再重复下载/覆盖新包,但仍须完成 - # 原有资源加载与 MXU 旧包清理阶段。 - recovered_by_user = True + recovered_automatically = True if self.execution_mode == "AutoProxy" and config.Update.TakeoverEnabled: self.maaend_config_dir = root / "config" self.temp_path = Path.cwd() / f"data/{self.script_info.script_id}/Temp" self._recover_previous_run() # 修复在原有资源预检之前,损坏的旧资源不能堵死恢复入口。 - if recovered_by_user: + if recovered_automatically: await self._refresh_after_program_install(root) self._program_update_prepared = True else: From dfb3365b7bae5651790ab738bd7c2e05e64222f0 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:40:05 +0100 Subject: [PATCH 10/29] test(maaend): replace manual prompt suites with public health verification and native journal rollback guards --- tests/task/test_maaend_program_update.py | 175 +++++------------------ 1 file changed, 37 insertions(+), 138 deletions(-) diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index 6c4c3fb6d4..e11ebb0ac3 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -939,170 +939,69 @@ async def test_no_update_prefetch_is_rechecked_at_script_start( @pytest.mark.asyncio -async def test_maaend_failed_transaction_can_be_confirmed_after_manual_repair( - installation, -): - from app.task.MaaEnd.tools import install_recovery +async def test_maaend_stale_journal_verified_by_public_native_adapter(installation): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter - root, _ = installation + root, config = installation work = script_install.workspace(root) - work.mkdir(parents=True) + work.mkdir(parents=True, exist_ok=True) marker = work / "transaction.json" - marker.write_bytes(b"stale-invalid-install-record") - with script_install.installation_access(root): - result = await install_recovery.skip_stale_transaction( - root, marker.read_bytes() - ) - assert result == "2.1.0" + marker.write_bytes(b"stale-journal") + adapter = MaaEndInstallAdapter(root, config, takeover_enabled=False) + state = await adapter.inspect() + assert state.pending and state.healthy and state.updated_confirmed + await adapter.acknowledge() assert not marker.exists() - backups = list(work.glob("maaend-transaction-ack-*.json")) - assert len(backups) == 1 - assert backups[0].read_bytes() == b"stale-invalid-install-record" - await update.confirm_current_install(root) + assert list(work.glob("maaend-transaction-ack-*.json")) @pytest.mark.asyncio -async def test_maaend_backup_and_ready_prevent_any_skip(installation): - from app.task.MaaEnd.tools import install_recovery +async def test_maaend_old_backup_prevents_journal_bypass(installation): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter - root, _ = installation + root, config = installation work = script_install.workspace(root) - work.mkdir(parents=True) + work.mkdir(parents=True, exist_ok=True) marker = work / "transaction.json" - marker.write_bytes(b"stale-invalid-install-record") - original = marker.read_bytes() + marker.write_bytes(b"stale-journal") (work / "previous").mkdir() - with pytest.raises(script_install.InstallUnsafeError, match="备份"): - await install_recovery.skip_stale_transaction(root, original) - (work / "previous").rmdir() - (work / "ready").mkdir() - with pytest.raises(script_install.InstallUnsafeError, match="暂存"): - await install_recovery.skip_stale_transaction(root, original) - assert marker.read_bytes() == original - - -@pytest.mark.asyncio -async def test_maaend_corrupt_executable_or_changed_marker_cannot_skip(installation): - from app.task.MaaEnd.tools import install_recovery - - root, _ = installation - work = script_install.workspace(root) - work.mkdir(parents=True) - marker = work / "transaction.json" - marker.write_bytes(b"original") - with pytest.raises(script_install.InstallUnsafeError, match="变化"): - await install_recovery.skip_stale_transaction(root, b"old marker") - (root / "MaaEnd.exe").write_bytes(b"damage") - with pytest.raises(script_install.InstallUnsafeError, match="不完整"): - await install_recovery.skip_stale_transaction(root, marker.read_bytes()) - assert marker.exists() - - -@pytest.mark.asyncio -async def test_maaend_recovery_prompt_fails_closed_without_frontend( - installation, monkeypatch -): - from app.task.MaaEnd.tools import install_recovery - - root, _ = installation - work = script_install.workspace(root) - work.mkdir(parents=True) - marker = work / "transaction.json" - marker.write_bytes(b"stale") - monkeypatch.setattr( - install_recovery.Publisher, "send", AsyncMock(return_value=False) - ) - with pytest.raises(script_install.InstallUnsafeError, match="主界面"): - await install_recovery.ask_install_recovery( - "task", root, managed_name="MaaEnd 实例 B", takeover_enabled=False - ) - assert marker.read_bytes() == b"stale" - - -@pytest.mark.asyncio -async def test_maaend_recovery_prompt_identifies_target_and_cancellation( - installation, monkeypatch -): - from app.core import Dispatcher - from app.core.ws import protocol - from app.models.schema import WSEnvelope - from app.task.MaaEnd.tools import install_recovery - - root, _ = installation - work = script_install.workspace(root) - work.mkdir(parents=True) - marker = work / "transaction.json" - marker.write_bytes(b"stale") - notices = [] - - async def send(*, id, type, data): - if type == protocol.MAAEND_INSTALL_PROMPT: - notices.append(data) - Dispatcher.dispatch( - WSEnvelope( - id=id, - type=protocol.MAAEND_INSTALL_DECISION, - data={ - "requestId": data["requestId"], - "taskId": data["taskId"], - "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", root, managed_name="MaaEnd B", takeover_enabled=False - ) - assert notices[0]["managedName"] == "MaaEnd B" - assert notices[0]["installPath"] == str(root.resolve()) - assert notices[0]["canSkip"] is True + adapter = MaaEndInstallAdapter(root, config, takeover_enabled=True) + state = await adapter.inspect() + assert state.pending and not state.healthy + with pytest.raises(script_install.InstallUnsafeError): + await adapter.acknowledge() assert marker.exists() @pytest.mark.asyncio -async def test_maaend_invalid_receipt_cannot_be_manually_bypassed( - installation, monkeypatch -): - from app.task.MaaEnd.tools import install_recovery +async def test_maaend_bad_exe_stays_blocked_without_frontend(installation): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter root, config = installation - await update.update_before_run(root, config) - work = script_install.workspace(root) - work.mkdir(parents=True) - marker = work / "transaction.json" - marker.write_bytes(b"unconfirmed") - (root / "resource/data.json").write_bytes(b"damaged") - with pytest.raises(script_install.InstallUnsafeError, match="回执"): - await install_recovery.skip_stale_transaction(root, marker.read_bytes()) - assert marker.is_file() + marker = script_install.workspace(root) / "transaction.json" + marker.write_bytes(b"stale-journal") + (root / "MaaEnd.exe").write_bytes(b"damaged") + adapter = MaaEndInstallAdapter(root, config, takeover_enabled=False) + state = await adapter.inspect() + assert state.pending and not state.healthy + assert marker.exists() @pytest.mark.asyncio -async def test_maaend_manual_mxu_repair_can_ignore_old_mas_receipt_when_takeover_off( +async def test_maaend_repaired_native_build_can_ignore_stale_receipt_without_takeover( installation, ): - from app.task.MaaEnd.tools import install_recovery + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter root, config = installation await update.update_before_run(root, config) (root / "interface.json").write_text( json.dumps({"name": "MaaEnd", "version": "v2.3.0", "mirrorchyan_rid": "MaaEnd"}) ) - work = script_install.workspace(root) - work.mkdir(parents=True, exist_ok=True) - marker = work / "transaction.json" - marker.write_bytes(b"old-transaction") - assert not script_install.verify_installed_files(root, threading.Event()) - with pytest.raises(script_install.InstallUnsafeError, match="回执"): - await install_recovery.skip_stale_transaction( - root, marker.read_bytes(), takeover_enabled=True - ) - result = await install_recovery.skip_stale_transaction( - root, marker.read_bytes(), takeover_enabled=False - ) - assert result == "2.3.0" + marker = script_install.workspace(root) / "transaction.json" + marker.write_bytes(b"stale-journal") + adapter = MaaEndInstallAdapter(root, config, takeover_enabled=False) + state = await adapter.inspect() + assert state.pending and state.healthy + await adapter.acknowledge() assert not marker.exists() - await update.confirm_current_install(root, verify_receipt=False) From 903e81491f43a5c00221483b32f120b3b6573577 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:40:28 +0100 Subject: [PATCH 11/29] refactor(maaend): remove old recovery modals, websocket prompts and user-choice module --- app/core/ws/protocol.py | 4 - app/task/MaaEnd/tools/install_recovery.py | 183 ------------------ frontend/src/App.vue | 2 - frontend/src/bootstrap/realtimeResidents.ts | 8 - .../components/MaaEndInstallRecoveryModal.vue | 147 -------------- frontend/src/i18n/bootstrap/en-US.json | 16 -- frontend/src/i18n/bootstrap/ja-JP.json | 16 -- frontend/src/i18n/bootstrap/zh-CN.json | 16 -- .../src/services/maaendInstallRecovery.ts | 86 -------- frontend/src/services/websocket/types.ts | 20 -- 10 files changed, 498 deletions(-) delete mode 100644 app/task/MaaEnd/tools/install_recovery.py delete mode 100644 frontend/src/components/MaaEndInstallRecoveryModal.vue delete mode 100644 frontend/src/services/maaendInstallRecovery.ts diff --git a/app/core/ws/protocol.py b/app/core/ws/protocol.py index 40f7fdaa0e..045b74b18b 100644 --- a/app/core/ws/protocol.py +++ b/app/core/ws/protocol.py @@ -66,10 +66,6 @@ TASK_SCRIPT_STARTED = "task.script.started" TASK_SCRIPT_FINISHED = "task.script.finished" -MAAEND_INSTALL_PROMPT = "maaend.install.prompt" -MAAEND_INSTALL_PROMPT_CLOSED = "maaend.install.prompt.closed" -MAAEND_INSTALL_DECISION = "maaend.install.decision" - # 应用生命周期与电源(id=Main) BACKEND_SHUTDOWN_READY = "backend.shutdown.ready" # 启动期攒下来、主连接建立后一次性发出的系统通知(id=Main):配置迁移结果之类。 diff --git a/app/task/MaaEnd/tools/install_recovery.py b/app/task/MaaEnd/tools/install_recovery.py deleted file mode 100644 index 4d466b9630..0000000000 --- a/app/task/MaaEnd/tools/install_recovery.py +++ /dev/null @@ -1,183 +0,0 @@ -# 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 - -"""MaaEnd 更新目录切换的未确认事务恢复。 - -先调用通用 recover_install;只有它无法确认且存在实际 journal 才询问。 -任何仍有原始备份/ready 暂存的目录均禁止跳过;不触碰 MXU 的内部 pending。 -""" - -import asyncio -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 script_update - -logger = get_logger("MaaEnd 安装恢复") -_TIMEOUT = 120 -# 完整事务包含三份路径哈希清单;64 KiB 不是实际安装的有效上限。 -_MAX_TRANSACTION_BYTES = 128 * 1024 * 1024 - - -def _state(root: Path) -> tuple[Path, Path, Path]: - directory = script_install.workspace(root) - return ( - directory / "transaction.json", - directory / "previous", - directory / "ready", - ) - - -def confirm_snapshot(root: Path, snapshot: bytes) -> None: - journal, _, _ = _state(root) - if ( - journal.is_symlink() - or not journal.is_file() - or len(snapshot) > _MAX_TRANSACTION_BYTES - or journal.read_bytes() != snapshot - ): - raise script_install.InstallUnsafeError( - "MaaEnd 安装记录在确认期间发生变化,拒绝旧请求" - ) - - -async def ask_install_recovery( - task_id: str, - root: Path, - *, - managed_name: str, - takeover_enabled: bool, -) -> tuple[str, bytes]: - journal, previous, ready = _state(root) - if journal.is_symlink() or not journal.is_file(): - raise script_install.InstallUnsafeError("MaaEnd 事务记录异常,禁止恢复") - snapshot = await asyncio.to_thread(journal.read_bytes) - if len(snapshot) > _MAX_TRANSACTION_BYTES: - raise script_install.InstallUnsafeError("MaaEnd 事务记录过大,禁止自动恢复") - can_skip = not ( - previous.exists() - or previous.is_symlink() - or ready.exists() - or ready.is_symlink() - ) - try: - installed = str((await asyncio.to_thread(script_update.read_install, root))[0]) - except Exception: - 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.MAAEND_INSTALL_DECISION, receive - ) - try: - sent = await Publisher.send( - id=protocol.ID_TASK_MANAGER, - type=protocol.MAAEND_INSTALL_PROMPT, - data={ - "requestId": request_id, - "taskId": task_id, - "managedName": managed_name, - "installPath": str(root.resolve()), - "installedVersion": installed, - "takeoverEnabled": takeover_enabled, - "canSkip": can_skip, - }, - ) - if not sent: - raise script_install.InstallUnsafeError( - "MaaEnd 未完成安装事务尚未恢复,主界面未连接" - ) - try: - action = await asyncio.wait_for(future, timeout=_TIMEOUT) - except TimeoutError as error: - raise script_install.InstallUnsafeError( - "MaaEnd 恢复确认超时,已保留原安装事务" - ) from error - if action == "stop": - raise script_install.InstallUnsafeError( - "用户取消 MaaEnd 恢复,本轮已停止,保留安装事务" - ) - if action == "skip" and not can_skip: - raise script_install.InstallUnsafeError( - "MaaEnd 仍有原始备份或暂存,不能跳过未完成的安装" - ) - await asyncio.to_thread(confirm_snapshot, root, snapshot) - return action, snapshot - finally: - unregister() - try: - await Publisher.send( - id=protocol.ID_TASK_MANAGER, - type=protocol.MAAEND_INSTALL_PROMPT_CLOSED, - data={"requestId": request_id}, - ) - except Exception: - logger.warning("MaaEnd 安装恢复弹窗关闭通知失败") - - -async def skip_stale_transaction( - root: Path, snapshot: bytes, *, takeover_enabled: bool = True -) -> str: - """只归档没有备份/待切换目录的旧 journal;核对原安装与版本回执。""" - marker, previous, ready = _state(root) - await asyncio.to_thread(script_update.assert_idle, root) - confirm_snapshot(root, snapshot) - if any(p.exists() or p.is_symlink() for p in (previous, ready)): - raise script_install.InstallUnsafeError( - "MaaEnd 有旧安装备份或 ready 暂存目录,禁止跳过" - ) - try: - version, arch = await asyncio.to_thread(script_update.read_install, root) - if arch not in ("x86_64", "aarch64"): - raise ValueError("MaaEnd 架构不可识别") - except (ValueError, OSError) as error: - raise script_install.InstallUnsafeError( - "MaaEnd 当前主程序、资源或版本不完整,无法确认手动恢复" - ) from error - receipt = script_install.record_path(root, "installed.json") - if ( - takeover_enabled - and receipt.exists() - and not await script_install.run_worker( - script_install.verify_installed_files, root - ) - ): - raise script_install.InstallUnsafeError( - "MaaEnd 当前文件与 MAS 校验回执不符,不允许跳过;请手动修复" - ) - await asyncio.to_thread(script_update.assert_idle, root) - confirm_snapshot(root, snapshot) - if any(p.exists() or p.is_symlink() for p in (previous, ready)): - raise script_install.InstallUnsafeError( - "MaaEnd 原始备份或暂存目录在恢复过程中发生变化" - ) - backup = marker.with_name(f"maaend-transaction-ack-{uuid4().hex}.json") - marker.replace(backup) - try: - await script_update.confirm_current_install( - root, verify_receipt=takeover_enabled - ) - except BaseException: - if not marker.exists() and backup.exists(): - backup.replace(marker) - raise - logger.warning(f"用户确认 MaaEnd 已手动修复并跳过遗留安装记录:{version}") - return str(version) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 672deb7a8c..543f4338d2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -13,7 +13,6 @@ 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 MaaEndInstallRecoveryModal from './components/MaaEndInstallRecoveryModal.vue' import GlobalPowerCountdown from './components/GlobalPowerCountdown.vue' import AppClosingOverlay from './components/AppClosingOverlay.vue' import BackendStartupOverlay from './components/BackendStartupOverlay.vue' @@ -156,7 +155,6 @@ onMounted(async () => { -
diff --git a/frontend/src/bootstrap/realtimeResidents.ts b/frontend/src/bootstrap/realtimeResidents.ts index 0326fd28d4..0efa1d2859 100644 --- a/frontend/src/bootstrap/realtimeResidents.ts +++ b/frontend/src/bootstrap/realtimeResidents.ts @@ -21,10 +21,6 @@ import { bootstrapVirtualDisplayPrompt, disposeVirtualDisplayPrompt, } from '@/services/virtualDisplayPrompt' -import { - bootstrapMaaEndInstallRecovery, - disposeMaaEndInstallRecovery, -} from '@/services/maaendInstallRecovery' let registered = false @@ -75,10 +71,6 @@ export function bootstrapRealtimeResidents(): void { bootstrap: bootstrapVirtualDisplayPrompt, dispose: disposeVirtualDisplayPrompt, }) - registerResidentResource('maaend-install-recovery', { - bootstrap: bootstrapMaaEndInstallRecovery, - dispose: disposeMaaEndInstallRecovery, - }) registered = true } bootstrapResidentResources() diff --git a/frontend/src/components/MaaEndInstallRecoveryModal.vue b/frontend/src/components/MaaEndInstallRecoveryModal.vue deleted file mode 100644 index a2913799cd..0000000000 --- a/frontend/src/components/MaaEndInstallRecoveryModal.vue +++ /dev/null @@ -1,147 +0,0 @@ - - - - - diff --git a/frontend/src/i18n/bootstrap/en-US.json b/frontend/src/i18n/bootstrap/en-US.json index 73b809fe04..f5abbfe3aa 100644 --- a/frontend/src/i18n/bootstrap/en-US.json +++ b/frontend/src/i18n/bootstrap/en-US.json @@ -3,22 +3,6 @@ "languageSaveFailed": "Failed to save the language setting; reverted to the previous one." }, "comp": { - "maaendRecoveryAcknowledgement": "I repaired this MaaEnd installation and understand the risk of dismissing its incomplete transaction.", - "maaendRecoveryCannotSkip": "A previous backup or staging directory still exists; the installation transaction must be recovered.", - "maaendRecoveryCurrent": "Installed version", - "maaendRecoveryDisabled": "Disabled", - "maaendRecoveryEnabled": "Enabled", - "maaendRecoveryInstallPath": "Installation folder", - "maaendRecoveryManagedName": "Managed profile", - "maaendRecoveryMessage": "An interrupted MaaEnd/MXU installation could not be recovered automatically. Verify the profile and installation folder.", - "maaendRecoveryRetry": "Retry transaction recovery", - "maaendRecoveryRisk": "Skipping requires no saved backup or pending install directory, a recognizable installed application, valid existing integrity receipt and stopped processes. This does not guarantee MXU's own pending state or all resources.", - "maaendRecoverySendFailed": "Could not send recovery decision. Check MAS connectivity.", - "maaendRecoverySkip": "Skip and continue", - "maaendRecoveryStop": "Cancel task", - "maaendRecoveryTakeover": "Managed updates", - "maaendRecoveryTitle": "MaaEnd installation transaction could not be recovered", - "maaendRecoveryUnknown": "Unknown", "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 d800a89871..2b4ed31807 100644 --- a/frontend/src/i18n/bootstrap/ja-JP.json +++ b/frontend/src/i18n/bootstrap/ja-JP.json @@ -3,22 +3,6 @@ "languageSaveFailed": "言語設定の保存に失敗しました。元の言語に戻しました。" }, "comp": { - "maaendRecoveryAcknowledgement": "この MaaEnd インストールを修復し、未完了の更新を無視するリスクを理解しています。", - "maaendRecoveryCannotSkip": "バックアップまたは待機ディレクトリが残っています。更新の復元が必要です。", - "maaendRecoveryCurrent": "現在のバージョン", - "maaendRecoveryDisabled": "無効", - "maaendRecoveryEnabled": "有効", - "maaendRecoveryInstallPath": "インストール先", - "maaendRecoveryManagedName": "管理設定名", - "maaendRecoveryMessage": "MaaEnd/MXU のインストールが中断され自動復元に失敗しました。対象設定とインストール先を確認してください。", - "maaendRecoveryRetry": "トランザクション復元を再試行", - "maaendRecoveryRisk": "バックアップと待機ディレクトリが存在せず、プログラム・既存の検証記録・実行中プロセスを確認できる場合だけスキップ可能です。MXU の状態全体は保証できません。", - "maaendRecoverySendFailed": "選択を送信できませんでした。接続を確認してください。", - "maaendRecoverySkip": "スキップして続行", - "maaendRecoveryStop": "タスク中止", - "maaendRecoveryTakeover": "自動更新", - "maaendRecoveryTitle": "MaaEnd の前回の導入が未完了です", - "maaendRecoveryUnknown": "不明", "backendAboutUpdateWhich": "バックエンドを更新します。更新にはバックエンドの再起動が必要で、実行中のタスクは中断されます。続行しますか?", "backendStarting": "バックエンドを起動しています", "backendUpdateAvailableClick": "バックエンドの更新があります。クリックして更新してください", diff --git a/frontend/src/i18n/bootstrap/zh-CN.json b/frontend/src/i18n/bootstrap/zh-CN.json index 3c97b047e7..eb4f5910e6 100644 --- a/frontend/src/i18n/bootstrap/zh-CN.json +++ b/frontend/src/i18n/bootstrap/zh-CN.json @@ -3,22 +3,6 @@ "languageSaveFailed": "语言设置保存失败,已恢复原语言" }, "comp": { - "maaendRecoveryAcknowledgement": "我已手动修复这份 MaaEnd 安装,并了解忽略安装事务的风险。", - "maaendRecoveryCannotSkip": "仍有上次安装的备份或待切换目录,禁止跳过,必须完成事务恢复。", - "maaendRecoveryCurrent": "当前程序版本", - "maaendRecoveryDisabled": "已关闭", - "maaendRecoveryEnabled": "已启用", - "maaendRecoveryInstallPath": "安装目录", - "maaendRecoveryManagedName": "托管名称", - "maaendRecoveryMessage": "检测到 MaaEnd/MXU 上次安装中断且自动恢复失败。请核对托管与安装目录,再选择安全恢复方式。", - "maaendRecoveryRetry": "重试事务恢复", - "maaendRecoveryRisk": "只有原始备份和待切换目录都不存在、当前程序及资源可识别、安装校验回执符合预期且进程已退出时,才能确认跳过。MXU 原生更新记录与资源完整性并不因此保证。", - "maaendRecoverySendFailed": "无法发送恢复决定,请检查 MAS 连接。", - "maaendRecoverySkip": "跳过并继续", - "maaendRecoveryStop": "取消任务", - "maaendRecoveryTakeover": "更新托管", - "maaendRecoveryTitle": "MaaEnd 上次安装事务无法恢复", - "maaendRecoveryUnknown": "未知", "backendAboutUpdateWhich": "即将更新后端,这需要重启后端程序,你当前正在运行的任务将会被中断。确认继续?", "backendStarting": "正在启动后端", "backendUpdateAvailableClick": "检测到后端更新,点击以更新后端", diff --git a/frontend/src/services/maaendInstallRecovery.ts b/frontend/src/services/maaendInstallRecovery.ts deleted file mode 100644 index f25128b977..0000000000 --- a/frontend/src/services/maaendInstallRecovery.ts +++ /dev/null @@ -1,86 +0,0 @@ -// 应用级 MaaEnd 安装恢复询问:首条主连接之前常驻订阅,避免路由切换漏掉请求。 -// 一次只能处理一个弹窗,后续请求排队;断线或后端关闭请求时立即清除旧选择。 - -import { ref } from 'vue' -import { onConnected, onDisconnected, send } from '@/services/websocket/connection' -import { subscribe, unsubscribe } from '@/services/websocket/subscriptions' -import { - WS_MAAEND_INSTALL_DECISION, - WS_MAAEND_INSTALL_PROMPT, - WS_MAAEND_INSTALL_PROMPT_CLOSED, - WS_ID_TASK_MANAGER, - type WSMaaEndInstallPromptData, -} from '@/services/websocket/types' - -const logger = window.electronAPI.getLogger('MaaEnd安装恢复') -export const maaendInstallPrompts = ref([]) -export const maaendInstallDecisionPending = ref(null) - -let subscriptionIds: string[] = [] -let disposeDisconnect: (() => void) | null = null -let disposeConnect: (() => void) | null = null - -const forgetRequest = (requestId: string) => { - maaendInstallPrompts.value = maaendInstallPrompts.value.filter( - prompt => prompt.requestId !== requestId - ) - if (maaendInstallDecisionPending.value === requestId) { - maaendInstallDecisionPending.value = null - } -} - -export function bootstrapMaaEndInstallRecovery(): void { - if (subscriptionIds.length) return - subscriptionIds = [ - subscribe({ id: WS_ID_TASK_MANAGER, type: WS_MAAEND_INSTALL_PROMPT }, message => { - const prompt = message.data - if ( - !prompt.requestId || - !prompt.taskId || - maaendInstallPrompts.value.some(previous => previous.requestId === prompt.requestId) - ) { - return - } - maaendInstallPrompts.value.push(prompt) - }), - subscribe({ id: WS_ID_TASK_MANAGER, type: WS_MAAEND_INSTALL_PROMPT_CLOSED }, message => { - forgetRequest(message.data.requestId) - }), - ] - disposeDisconnect = onDisconnected(() => { - maaendInstallPrompts.value = [] - maaendInstallDecisionPending.value = null - }) - // reconnectNow 在切换连接代次时不会触发 onDisconnected。 - // 新连接建立后,旧连接的关闭帧可能已丢失;绝不可沿用旧确认请求。 - disposeConnect = onConnected(() => { - maaendInstallPrompts.value = [] - maaendInstallDecisionPending.value = null - }) -} - -export function respondToMaaEndInstallRecovery(action: 'retry' | 'skip' | 'stop'): boolean { - const prompt = maaendInstallPrompts.value[0] - if (!prompt || maaendInstallDecisionPending.value) return false - const sent = send(WS_ID_TASK_MANAGER, WS_MAAEND_INSTALL_DECISION, { - requestId: prompt.requestId, - taskId: prompt.taskId, - action, - }) - if (!sent) { - logger.warn('未能发送 MaaEnd 安装恢复选择') - return false - } - maaendInstallDecisionPending.value = prompt.requestId - return true -} - -export function disposeMaaEndInstallRecovery(): void { - for (const id of subscriptionIds.splice(0)) unsubscribe(id) - disposeDisconnect?.() - disposeDisconnect = null - disposeConnect?.() - disposeConnect = null - maaendInstallPrompts.value = [] - maaendInstallDecisionPending.value = null -} diff --git a/frontend/src/services/websocket/types.ts b/frontend/src/services/websocket/types.ts index c12e7e0631..d7f0005eba 100644 --- a/frontend/src/services/websocket/types.ts +++ b/frontend/src/services/websocket/types.ts @@ -49,9 +49,6 @@ export const WS_TASK_CREATED = 'task.created' // 每次脚本执行(含循环中的每轮),不随快照重放(id=TaskManager)。 export const WS_TASK_SCRIPT_STARTED = 'task.script.started' export const WS_TASK_SCRIPT_FINISHED = 'task.script.finished' -export const WS_MAAEND_INSTALL_PROMPT = 'maaend.install.prompt' -export const WS_MAAEND_INSTALL_PROMPT_CLOSED = 'maaend.install.prompt.closed' -export const WS_MAAEND_INSTALL_DECISION = 'maaend.install.decision' // 应用生命周期与电源(id=Main) export const WS_BACKEND_SHUTDOWN_READY = 'backend.shutdown.ready' @@ -163,21 +160,6 @@ export interface WSTaskScriptFinishedData extends WSTaskScriptStartedData { partialFailure?: 'yes' | 'no' | 'unknown' } -/** MaaEnd 安装事务失败的用户确认请求 */ -export interface WSMaaEndInstallPromptData { - requestId: string - taskId: string - managedName: string - installPath: string - installedVersion: string - takeoverEnabled: boolean - canSkip: boolean -} - -export interface WSMaaEndInstallPromptClosedData { - requestId: string -} - /** 新任务创建通知数据 (id=TaskManager, type=task.created) */ export interface WSTaskCreatedData { trigger?: TaskTriggerReason @@ -347,8 +329,6 @@ interface WSMessageDataMap { [WS_TASK_CREATED]: WSTaskCreatedData [WS_TASK_SCRIPT_STARTED]: WSTaskScriptStartedData [WS_TASK_SCRIPT_FINISHED]: WSTaskScriptFinishedData - [WS_MAAEND_INSTALL_PROMPT]: WSMaaEndInstallPromptData - [WS_MAAEND_INSTALL_PROMPT_CLOSED]: WSMaaEndInstallPromptClosedData [WS_BACKEND_SHUTDOWN_READY]: WSEmptyData [WS_FRONTEND_CLOSE_REQUESTED]: FrontendCloseRequest [WS_FRONTEND_RESTART_REQUESTED]: FrontendCloseRequest From c5ad1d031eb39a4608974c4a4d6d53ef63bb7381 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:41:36 +0100 Subject: [PATCH 12/29] style(maaend): apply CI Ruff formatting --- app/task/MaaEnd/manager.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index 5826fc5a84..33bd8066f0 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -516,9 +516,7 @@ async def main_task(self): acknowledge=adapter.acknowledge, reinstall=adapter.retry_native, ) - await self.script_info.set_log( - f"MaaEnd 原生事务自动恢复:{outcome}" - ) + await self.script_info.set_log(f"MaaEnd 原生事务自动恢复:{outcome}") recovered_automatically = True # 仅在已经存在 MAS 身份回执时允许自动修复损坏的本体。 # 用户自选构建与手动模式不使用猜测的架构覆盖安装。 From b1dfc2c03e8416eac53721a699cfde95a1d5c30a Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:41:40 +0100 Subject: [PATCH 13/29] style(maaend): apply CI Ruff formatting --- app/task/MaaEnd/tools/managed_recovery.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/task/MaaEnd/tools/managed_recovery.py b/app/task/MaaEnd/tools/managed_recovery.py index f2b8e780eb..388db9a872 100644 --- a/app/task/MaaEnd/tools/managed_recovery.py +++ b/app/task/MaaEnd/tools/managed_recovery.py @@ -108,4 +108,3 @@ async def repair_program(self) -> None: self.root, self.config, recovery_identity=snapshot ) await script_update.confirm_current_install(self.root) - From 4565c1501748fa4f62e364d14f975d91f17b960b Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:46:32 +0100 Subject: [PATCH 14/29] test(maaend): initialize native transaction directory in real health regression cases --- tests/task/test_maaend_program_update.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index e11ebb0ac3..dc9713d1d5 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -979,6 +979,7 @@ async def test_maaend_bad_exe_stays_blocked_without_frontend(installation): root, config = installation marker = script_install.workspace(root) / "transaction.json" + marker.parent.mkdir(parents=True, exist_ok=True) marker.write_bytes(b"stale-journal") (root / "MaaEnd.exe").write_bytes(b"damaged") adapter = MaaEndInstallAdapter(root, config, takeover_enabled=False) @@ -999,6 +1000,7 @@ async def test_maaend_repaired_native_build_can_ignore_stale_receipt_without_tak json.dumps({"name": "MaaEnd", "version": "v2.3.0", "mirrorchyan_rid": "MaaEnd"}) ) marker = script_install.workspace(root) / "transaction.json" + marker.parent.mkdir(parents=True, exist_ok=True) marker.write_bytes(b"stale-journal") adapter = MaaEndInstallAdapter(root, config, takeover_enabled=False) state = await adapter.inspect() From e4373e49a1562526909d881cb3c5fd5baf7110e8 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:29:45 +0100 Subject: [PATCH 15/29] fix(maaend): separate native journal recovery from health gate and expose repairable state to #1916 --- app/task/MaaEnd/tools/managed_recovery.py | 33 ++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/app/task/MaaEnd/tools/managed_recovery.py b/app/task/MaaEnd/tools/managed_recovery.py index 388db9a872..f1c9f4dfe8 100644 --- a/app/task/MaaEnd/tools/managed_recovery.py +++ b/app/task/MaaEnd/tools/managed_recovery.py @@ -86,16 +86,33 @@ async def acknowledge(self) -> None: archive.replace(marker) raise - async def retry_native(self) -> None: + async def recover_native(self) -> None: + """仅恢复原生目录事务;安装健康验证留给后续公共恢复入口。""" await script_install.run_worker( script_install.recover_install, self.root, lambda: script_update.assert_idle(self.root), ) + + async def retry_native(self) -> None: + await self.recover_native() await script_update.confirm_current_install( self.root, verify_receipt=self.takeover_enabled ) + async def inspect_repair(self) -> InstallInspection: + """将无 journal、但有可信身份的损坏安装表达为公共待恢复态。""" + state = await self.inspect() + if state.pending or state.healthy: + return state + return InstallInspection( + pending=True, + healthy=False, + installed_version=state.installed_version, + previous_confirmed=False, + updated_confirmed=False, + ) + async def repair_program(self) -> None: identity = script_install.record_path(self.root, "program-identity.json") if identity.is_symlink() or not identity.is_file(): @@ -104,7 +121,15 @@ async def repair_program(self) -> None: ) snapshot = identity.read_bytes() script_update.verify_repair_identity(self.root, snapshot) - await script_update.update_before_run( - self.root, self.config, recovery_identity=snapshot - ) + # 可识别的 MXU 原生升级不应被当作损坏 EXE,更不能降低版本。 + # 仅在原生安装身份本身无法读取时走带身份绑定的强制修复; + # 旧 MAS 回执失效时保留正常更新器的精确版本官方包复核。 + try: + await asyncio.to_thread(script_update.read_install, self.root) + except (OSError, ValueError): + await script_update.update_before_run( + self.root, self.config, recovery_identity=snapshot + ) + else: + await script_update.update_before_run(self.root, self.config) await script_update.confirm_current_install(self.root) From 4fd71a2b9fdc354bbeeea56e1867cb08e2817074 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:30:22 +0100 Subject: [PATCH 16/29] fix(maaend): allow trusted damaged installations into #1916 repair, preserve MXU-upgraded version --- app/task/MaaEnd/manager.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index 33bd8066f0..07ac2cbe6c 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -503,12 +503,15 @@ async def main_task(self): request = ScriptUpdateRequest(str(self.script_info.script_id), "MaaEnd") recovered_automatically = False try: - await adapter.retry_native() + # 原生事务恢复不能以旧 MAS 收据校验作为前置门槛; + # MXU 独立更新后的收据可能旧,但本体仍可正确修复。 + await adapter.recover_native() except script_install.InstallUnsafeError: if not journal.is_file(): raise - # 不再等待用户选择:只有已验证发行文件与安装回执才可 - # 清理过期 journal;否则重做原生事务回滚,失败保留现场。 + if journal.is_file(): + # 只有无可回滚目录、原生安装完整且身份可信时,公共层 + # 才允许清理过期 journal;否则重新尝试原生恢复。 outcome = await auto_recover_script_install( request, install_root=root, @@ -516,21 +519,38 @@ async def main_task(self): acknowledge=adapter.acknowledge, reinstall=adapter.retry_native, ) + if outcome not in {"verified", "reinstalled"}: + raise script_install.InstallUnsafeError( + "MaaEnd 原生事务未完成,拒绝启动" + ) await self.script_info.set_log(f"MaaEnd 原生事务自动恢复:{outcome}") recovered_automatically = True - # 仅在已经存在 MAS 身份回执时允许自动修复损坏的本体。 - # 用户自选构建与手动模式不使用猜测的架构覆盖安装。 + # 关闭接管或配置会话不主动覆盖用户自行维护的 MaaEnd。 + # 仅在已开启托管且存在可信安装身份时才自动修复损坏的发行包。 identity = script_install.record_path(root, "program-identity.json") - if identity.is_file() and not identity.is_symlink(): + if ( + self.execution_mode == "AutoProxy" + and config.Update.TakeoverEnabled + and identity.is_file() + and not identity.is_symlink() + ): state = await adapter.inspect() - if not state.healthy and not state.pending: + if not state.healthy: + if state.pending: + raise script_install.InstallUnsafeError( + "MaaEnd 尚有未完成原生事务,不能重新安装" + ) outcome = await auto_recover_script_install( request, install_root=root, - inspect=adapter.inspect, + inspect=adapter.inspect_repair, acknowledge=adapter.acknowledge, reinstall=adapter.repair_program, ) + if outcome != "reinstalled": + raise script_install.InstallUnsafeError( + "MaaEnd 完整包自动修复尚未确认成功" + ) await self.script_info.set_log( f"MaaEnd 官方完整包自动修复:{outcome}" ) From 13a0e4093024c92ef628ca3479b5152e43a31f88 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:31:36 +0100 Subject: [PATCH 17/29] test(maaend): exercise real manager and #1916 for damaged EXE and MXU-upgraded receipt --- tests/task/test_maaend_program_update.py | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index dc9713d1d5..c3a4277f20 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -1007,3 +1007,87 @@ async def test_maaend_repaired_native_build_can_ignore_stale_receipt_without_tak assert state.pending and state.healthy await adapter.acknowledge() assert not marker.exists() + + +async def _start_real_maaend_manager_for_repair(root: Path, monkeypatch): + """保留真实 main_task / adapter / #1916 公共恢复层,只截断游戏任务。""" + from app.models.config import MaaEndConfig + + home = root.parent / "mas-home" + home.mkdir(exist_ok=True) + monkeypatch.chdir(home) + config = MaaEndConfig.model_validate( + {"Info": {"Path": str(root)}, "Update": {"TakeoverEnabled": True}} + ) + await config.activate() + script_id = config.uid + info = SimpleNamespace( + task_info=SimpleNamespace(mode="AutoProxy", task_id="test"), + script_id=str(script_id), + user_list=[], + status="等待", + log="", + set_log=AsyncMock(), + ) + instance = manager.MaaEndManager(info) + instance.check = AsyncMock(return_value="test stop before game startup") + lock, unlock = AsyncMock(return_value="ticket"), AsyncMock() + monkeypatch.setattr(MaaEndConfig, "try_lock_x", lock) + monkeypatch.setattr(MaaEndConfig, "unlock", unlock) + monkeypatch.setattr(manager.Config, "ScriptConfig", {script_id: config}) + monkeypatch.setattr(manager.MaaEndResourceLoader, "get_cached", Mock()) + monkeypatch.setattr( + manager, "load_maaend_controller_protocol", lambda *args: "Win32" + ) + monkeypatch.setattr(manager, "push_dispatch_log", AsyncMock()) + monkeypatch.setattr(manager.Publisher, "send", AsyncMock()) + return instance, lock, unlock + + +@pytest.mark.asyncio +async def test_manager_repairs_damaged_exe_with_no_native_journal_via_common_entry( + installation, monkeypatch +): + root, existing_config = installation + await update.update_before_run(root, existing_config) + (root / "MaaEnd.exe").write_bytes(b"damaged executable") + assert not (script_install.workspace(root) / "transaction.json").exists() + instance, lock, unlock = await _start_real_maaend_manager_for_repair( + root, monkeypatch + ) + await instance.main_task() + assert update.read_install(root) == (Version("2.2.0"), "x86_64") + assert script_install.verify_installed_files(root, threading.Event()) + assert any("reinstalled" in str(call) for call in instance.script_info.set_log.await_args_list) + lock.assert_awaited_once() + await instance.final_task() + unlock.assert_awaited_once_with("ticket") + + +@pytest.mark.asyncio +async def test_manager_repairs_native_upgrade_receipt_at_exact_newer_version( + installation, tmp_path, monkeypatch +): + root, existing_config = installation + await update.update_before_run(root, existing_config) + native_tag = "v2.3.0-beta.1" + (root / "interface.json").write_text( + json.dumps( + {"name": "MaaEnd", "version": native_tag, "mirrorchyan_rid": "MaaEnd"} + ) + ) + archive, release = _official_release_for_test(tmp_path, native_tag) + exact = AsyncMock(return_value=release) + monkeypatch.setattr(update, "request_release_for_version", exact) + + async def download_exact(_url, destination, **_kwargs): + shutil.copyfile(archive, destination) + + monkeypatch.setattr(update, "download_file", AsyncMock(side_effect=download_exact)) + instance, _, _ = await _start_real_maaend_manager_for_repair(root, monkeypatch) + await instance.main_task() + assert update.read_install(root) == (Version(native_tag), "x86_64") + assert script_install.verify_installed_files(root, threading.Event()) + exact.assert_awaited_once_with(Version(native_tag), native_tag) + await instance.final_task() + From b96db92a84ca1f9eb604c9066bf66194955b3403 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:36:04 +0100 Subject: [PATCH 18/29] style(maaend): apply Ruff format for real manager recovery tests --- tests/task/test_maaend_program_update.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index c3a4277f20..5b7834ba0b 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -1058,7 +1058,10 @@ async def test_manager_repairs_damaged_exe_with_no_native_journal_via_common_ent await instance.main_task() assert update.read_install(root) == (Version("2.2.0"), "x86_64") assert script_install.verify_installed_files(root, threading.Event()) - assert any("reinstalled" in str(call) for call in instance.script_info.set_log.await_args_list) + assert any( + "reinstalled" in str(call) + for call in instance.script_info.set_log.await_args_list + ) lock.assert_awaited_once() await instance.final_task() unlock.assert_awaited_once_with("ticket") @@ -1090,4 +1093,3 @@ async def download_exact(_url, destination, **_kwargs): assert script_install.verify_installed_files(root, threading.Event()) exact.assert_awaited_once_with(Version(native_tag), native_tag) await instance.final_task() - From 9ee89de2385140090ed98f9d1f10c60b3586f267 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:37:05 +0100 Subject: [PATCH 19/29] docs(maaend): remove obsolete prompt wording and describe #1916 automatic recovery --- changelog.d/maaend-transaction-recovery.fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/maaend-transaction-recovery.fix.md b/changelog.d/maaend-transaction-recovery.fix.md index 82e3346e6b..ae04730bb0 100644 --- a/changelog.d/maaend-transaction-recovery.fix.md +++ b/changelog.d/maaend-transaction-recovery.fix.md @@ -1,2 +1,2 @@ project: maaend -为 MaaEnd 安装事务失败提供人工确认恢复入口 +MaaEnd 安装中断后通过公共入口自动恢复事务,并对有可信身份的损坏本体执行官方完整包修复与健康复核 From 9fe9d343dda075d360602f5044bb7cfefb5524c5 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:39:56 +0100 Subject: [PATCH 20/29] docs(maaend): shorten automated recovery changelog to repository 50-character limit --- changelog.d/maaend-transaction-recovery.fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/maaend-transaction-recovery.fix.md b/changelog.d/maaend-transaction-recovery.fix.md index ae04730bb0..aee320393d 100644 --- a/changelog.d/maaend-transaction-recovery.fix.md +++ b/changelog.d/maaend-transaction-recovery.fix.md @@ -1,2 +1,2 @@ project: maaend -MaaEnd 安装中断后通过公共入口自动恢复事务,并对有可信身份的损坏本体执行官方完整包修复与健康复核 +MaaEnd 安装异常时统一恢复事务,并安全修复可信身份的损坏本体 From 461cc4bf2ca5152ef0b78fe75b9c023ab96df2d4 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:48:33 +0100 Subject: [PATCH 21/29] fix(maaend): safely discard precommit ready staging after verifying original install and no native journal --- app/task/MaaEnd/tools/managed_recovery.py | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/app/task/MaaEnd/tools/managed_recovery.py b/app/task/MaaEnd/tools/managed_recovery.py index f1c9f4dfe8..c80171b69b 100644 --- a/app/task/MaaEnd/tools/managed_recovery.py +++ b/app/task/MaaEnd/tools/managed_recovery.py @@ -3,6 +3,8 @@ """MaaEnd 安装状态与修复适配器。公共 #1916 负责决定恢复策略。""" import asyncio +import shutil +import threading from pathlib import Path from uuid import uuid4 @@ -14,6 +16,52 @@ _MAX_TRANSACTION_BYTES = 128 * 1024 * 1024 +def discard_uncommitted_staging( + root: Path, takeover_enabled: bool, cancelled: threading.Event +) -> bool: + """只清理尚未写入事务日志的安装暂存,不接触已开始切换的目录。""" + directory = script_install.workspace(root) + if not directory.exists() and not directory.is_symlink(): + return False + script_install._reject_link(directory) + journal = directory / "transaction.json" + previous = directory / "previous" + ready = directory / "ready" + package = directory / "package" + for path in (journal, previous, ready, package): + if path.exists() or path.is_symlink(): + script_install._reject_link(path) + if journal.exists() or previous.exists() or not ready.exists(): + return False + if not ready.is_dir(): + raise script_install.InstallUnsafeError("MaaEnd 暂存目录类型异常") + script_install._check_cancel(cancelled) + script_update.assert_idle(root) + script_install._reject_link(root) + # 对原安装仍需实际版本/可执行文件/资源和已存在的 MAS 收据复核; + # 不能仅因没有 journal 就把可能损坏的旧版标记为健康。 + script_update.read_install(root) + receipt = script_install.record_path(root, "installed.json") + if takeover_enabled and receipt.exists(): + if not script_install.verify_installed_files(root, cancelled): + raise script_install.InstallUnsafeError( + "MaaEnd 原安装收据校验未通过,保留未提交暂存" + ) + # 删暂存前再检查一次:确认没有任何外部写入开始目录切换。 + script_install._check_cancel(cancelled) + script_update.assert_idle(root) + if journal.exists() or previous.exists(): + raise script_install.InstallUnsafeError("MaaEnd 事务状态已变化,保留暂存") + for path in (ready, package): + if path.exists() or path.is_symlink(): + script_install._reject_link(path) + if not path.is_dir(): + raise script_install.InstallUnsafeError("MaaEnd 更新暂存类型异常") + script_install._check_cancel(cancelled) + shutil.rmtree(path) + return True + + class MaaEndInstallAdapter: def __init__(self, root: Path, config, *, takeover_enabled: bool) -> None: self.root = root @@ -93,6 +141,11 @@ async def recover_native(self) -> None: self.root, lambda: script_update.assert_idle(self.root), ) + # 安装器在 ready 复制后、写 journal 前取消时,原安装未切换; + # 仅清理这种未提交状态。任何 previous/journal 均不可触碰。 + await script_install.run_worker( + discard_uncommitted_staging, self.root, self.takeover_enabled + ) async def retry_native(self) -> None: await self.recover_native() From e83fe724a53cce47911a733b393702e03d754784 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:49:22 +0100 Subject: [PATCH 22/29] test(maaend): reproduce cancelled after ready copy and verify manager retries without touching committed backups --- tests/task/test_maaend_program_update.py | 76 ++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index 5b7834ba0b..6930b73802 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -1093,3 +1093,79 @@ async def download_exact(_url, destination, **_kwargs): assert script_install.verify_installed_files(root, threading.Event()) exact.assert_awaited_once_with(Version(native_tag), native_tag) await instance.final_task() + + +@pytest.mark.asyncio +async def test_manager_recovers_ready_left_before_native_journal( + installation, monkeypatch +): + """真实安装器完成 ready 复制后异常,下一轮真实 manager 可安全清理重跑。""" + root, config = installation + await update.update_before_run(root, config) + original_version = update.read_install(root) + archive = root.parent / "official.zip" + candidate = update.PreparedUpdate( + root.resolve(), + original_version, + original_version[0], + original_version[1], + archive, + archive.stat().st_size, + hashlib.sha256(archive.read_bytes()).hexdigest(), + ) + original_copytree = script_install.shutil.copytree + + def fail_after_ready(*args, **kwargs): + result = original_copytree(*args, **kwargs) + if Path(args[1]).name == "ready": + raise OSError("interrupted after ready copy, before transaction") + return result + + with monkeypatch.context() as patch: + patch.setattr(script_install.shutil, "copytree", fail_after_ready) + with pytest.raises(OSError, match="before transaction"): + await update.apply_update(root, candidate) + + work = script_install.workspace(root) + assert (work / "ready").is_dir() + assert (work / "package").is_dir() + assert not (work / "transaction.json").exists() + assert not (work / "previous").exists() + assert update.read_install(root) == original_version + assert script_install.verify_installed_files(root, threading.Event()) + instance, _, unlock = await _start_real_maaend_manager_for_repair( + root, monkeypatch + ) + await instance.main_task() + assert not (work / "ready").exists() + assert not (work / "package").exists() + assert update.read_install(root) == original_version + assert script_install.verify_installed_files(root, threading.Event()) + await instance.final_task() + unlock.assert_awaited_once_with("ticket") + + +@pytest.mark.asyncio +async def test_maaend_precommit_cleanup_preserves_previous_and_invalid_original( + installation, +): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter + + root, config = installation + await update.update_before_run(root, config) + work = script_install.workspace(root) + work.mkdir(parents=True, exist_ok=True) + (work / "ready").mkdir() + (work / "previous").mkdir() + adapter = MaaEndInstallAdapter(root, config, takeover_enabled=True) + with pytest.raises(script_install.InstallUnsafeError, match="备份存在"): + await adapter.recover_native() + assert (work / "ready").exists() and (work / "previous").exists() + + (work / "previous").rmdir() + (root / "MaaEnd.exe").write_bytes(b"damaged") + with pytest.raises((OSError, ValueError, script_install.InstallUnsafeError)): + await adapter.recover_native() + assert (work / "ready").exists() + assert not (work / "transaction.json").exists() + From 5eb4ed2fe2c5e78ca530433c7c1960b83853859d Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:50:54 +0100 Subject: [PATCH 23/29] style(maaend): satisfy Ruff format on interrupted ready-staging recovery test --- tests/task/test_maaend_program_update.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index 6930b73802..95c7a5a817 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -1133,9 +1133,7 @@ def fail_after_ready(*args, **kwargs): assert not (work / "previous").exists() assert update.read_install(root) == original_version assert script_install.verify_installed_files(root, threading.Event()) - instance, _, unlock = await _start_real_maaend_manager_for_repair( - root, monkeypatch - ) + instance, _, unlock = await _start_real_maaend_manager_for_repair(root, monkeypatch) await instance.main_task() assert not (work / "ready").exists() assert not (work / "package").exists() @@ -1168,4 +1166,3 @@ async def test_maaend_precommit_cleanup_preserves_previous_and_invalid_original( await adapter.recover_native() assert (work / "ready").exists() assert not (work / "transaction.json").exists() - From c5f82bb84081fd75babc419ac04d8299cf2d5192 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:11:41 +0100 Subject: [PATCH 24/29] fix(maaend): let authorized damaged installs resume after precommit staging while preserving strict native transaction guards --- app/task/MaaEnd/tools/managed_recovery.py | 49 ++++++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/app/task/MaaEnd/tools/managed_recovery.py b/app/task/MaaEnd/tools/managed_recovery.py index c80171b69b..652a82c871 100644 --- a/app/task/MaaEnd/tools/managed_recovery.py +++ b/app/task/MaaEnd/tools/managed_recovery.py @@ -17,7 +17,10 @@ def discard_uncommitted_staging( - root: Path, takeover_enabled: bool, cancelled: threading.Event + root: Path, + takeover_enabled: bool, + allow_damaged_repair: bool, + cancelled: threading.Event, ) -> bool: """只清理尚未写入事务日志的安装暂存,不接触已开始切换的目录。""" directory = script_install.workspace(root) @@ -38,15 +41,28 @@ def discard_uncommitted_staging( script_install._check_cancel(cancelled) script_update.assert_idle(root) script_install._reject_link(root) - # 对原安装仍需实际版本/可执行文件/资源和已存在的 MAS 收据复核; - # 不能仅因没有 journal 就把可能损坏的旧版标记为健康。 - script_update.read_install(root) - receipt = script_install.record_path(root, "installed.json") - if takeover_enabled and receipt.exists(): - if not script_install.verify_installed_files(root, cancelled): + # 优先核验健康旧版;损坏本体或旧收据失效时只能在已授权的 + # AutoProxy 托管修复路径里凭 MAS 自有可信身份清理未提交暂存。 + # 清理不代表安装已健康;manager 后续仍须调用公共入口重装并验证。 + try: + script_update.read_install(root) + receipt = script_install.record_path(root, "installed.json") + if takeover_enabled and receipt.exists(): + if not script_install.verify_installed_files(root, cancelled): + raise script_install.InstallUnsafeError( + "MaaEnd 原安装收据校验未通过" + ) + except (OSError, ValueError, script_install.InstallUnsafeError) as error: + if not (takeover_enabled and allow_damaged_repair): + raise + identity = script_install.record_path(root, "program-identity.json") + if identity.is_symlink() or not identity.is_file(): raise script_install.InstallUnsafeError( - "MaaEnd 原安装收据校验未通过,保留未提交暂存" - ) + "MaaEnd 安装损坏且缺少可信 MAS 身份,保留未提交暂存" + ) from error + snapshot = identity.read_bytes() + script_update.verify_repair_identity(root, snapshot) + script_install._check_cancel(cancelled) # 删暂存前再检查一次:确认没有任何外部写入开始目录切换。 script_install._check_cancel(cancelled) script_update.assert_idle(root) @@ -63,10 +79,18 @@ def discard_uncommitted_staging( class MaaEndInstallAdapter: - def __init__(self, root: Path, config, *, takeover_enabled: bool) -> None: + def __init__( + self, + root: Path, + config, + *, + takeover_enabled: bool, + allow_damaged_repair: bool = False, + ) -> None: self.root = root self.config = config self.takeover_enabled = takeover_enabled + self.allow_damaged_repair = allow_damaged_repair async def ensure_idle(self) -> None: await asyncio.to_thread(script_update.assert_idle, self.root) @@ -144,7 +168,10 @@ async def recover_native(self) -> None: # 安装器在 ready 复制后、写 journal 前取消时,原安装未切换; # 仅清理这种未提交状态。任何 previous/journal 均不可触碰。 await script_install.run_worker( - discard_uncommitted_staging, self.root, self.takeover_enabled + discard_uncommitted_staging, + self.root, + self.takeover_enabled, + self.allow_damaged_repair, ) async def retry_native(self) -> None: From 34674852bdcf4cdcc68d7f8a5861c1b3234b03d0 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:11:51 +0100 Subject: [PATCH 25/29] fix(maaend): authorize precommit repair cleanup only for managed AutoProxy execution --- app/task/MaaEnd/manager.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index 07ac2cbe6c..290faa70b3 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -498,7 +498,13 @@ async def main_task(self): if journal.is_symlink(): raise script_install.InstallUnsafeError("MaaEnd 原生事务路径不安全") adapter = MaaEndInstallAdapter( - root, config, takeover_enabled=config.Update.TakeoverEnabled + root, + config, + takeover_enabled=config.Update.TakeoverEnabled, + allow_damaged_repair=( + self.execution_mode == "AutoProxy" + and config.Update.TakeoverEnabled + ), ) request = ScriptUpdateRequest(str(self.script_info.script_id), "MaaEnd") recovered_automatically = False From 198e0432590ba6bcbc380f76e4428aa9cbcef944 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:13:03 +0100 Subject: [PATCH 26/29] test(maaend): verify interrupted damaged EXE and MXU-upgrade repairs through real installer and #1916 manager flow --- tests/task/test_maaend_program_update.py | 61 +++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index 95c7a5a817..87dafe692f 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -1009,6 +1009,37 @@ async def test_maaend_repaired_native_build_can_ignore_stale_receipt_without_tak assert not marker.exists() +async def _interrupt_native_install_after_ready(root: Path, monkeypatch): + """调用真实原生安装器,在写入 transaction.json 前故障注入。""" + archive = root.parent / "official.zip" + original_copytree = script_install.shutil.copytree + + def copy_then_interrupt(source, destination, *args, **kwargs): + result = original_copytree(source, destination, *args, **kwargs) + if Path(destination).name == "ready": + raise OSError("injected failure after ready copy") + return result + + with monkeypatch.context() as patch: + patch.setattr(script_install.shutil, "copytree", copy_then_interrupt) + with pytest.raises(OSError, match="after ready copy"): + await script_install.run_worker( + script_install.install_package, + root, + archive, + archive.stat().st_size, + hashlib.sha256(archive.read_bytes()).hexdigest(), + update.PROTECTED, + lambda path: None, + lambda: None, + ) + workspace = script_install.workspace(root) + assert (workspace / "ready").is_dir() + assert (workspace / "package").is_dir() + assert not (workspace / "transaction.json").exists() + assert not (workspace / "previous").exists() + + async def _start_real_maaend_manager_for_repair(root: Path, monkeypatch): """保留真实 main_task / adapter / #1916 公共恢复层,只截断游戏任务。""" from app.models.config import MaaEndConfig @@ -1051,13 +1082,15 @@ async def test_manager_repairs_damaged_exe_with_no_native_journal_via_common_ent root, existing_config = installation await update.update_before_run(root, existing_config) (root / "MaaEnd.exe").write_bytes(b"damaged executable") - assert not (script_install.workspace(root) / "transaction.json").exists() + await _interrupt_native_install_after_ready(root, monkeypatch) instance, lock, unlock = await _start_real_maaend_manager_for_repair( root, monkeypatch ) await instance.main_task() assert update.read_install(root) == (Version("2.2.0"), "x86_64") assert script_install.verify_installed_files(root, threading.Event()) + assert (root / "config/mxu-MaaEnd.json").read_bytes() == b'{"user-data": true}' + assert not (script_install.workspace(root) / "ready").exists() assert any( "reinstalled" in str(call) for call in instance.script_info.set_log.await_args_list @@ -1087,8 +1120,10 @@ async def download_exact(_url, destination, **_kwargs): shutil.copyfile(archive, destination) monkeypatch.setattr(update, "download_file", AsyncMock(side_effect=download_exact)) + await _interrupt_native_install_after_ready(root, monkeypatch) instance, _, _ = await _start_real_maaend_manager_for_repair(root, monkeypatch) await instance.main_task() + assert not (script_install.workspace(root) / "ready").exists() assert update.read_install(root) == (Version(native_tag), "x86_64") assert script_install.verify_installed_files(root, threading.Event()) exact.assert_awaited_once_with(Version(native_tag), native_tag) @@ -1166,3 +1201,27 @@ async def test_maaend_precommit_cleanup_preserves_previous_and_invalid_original( await adapter.recover_native() assert (work / "ready").exists() assert not (work / "transaction.json").exists() + + +@pytest.mark.asyncio +async def test_precommit_staging_repair_requires_trusted_identity( + installation, monkeypatch +): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter + + root, config = installation + await update.update_before_run(root, config) + (root / "MaaEnd.exe").write_bytes(b"damaged") + await _interrupt_native_install_after_ready(root, monkeypatch) + script_install.record_path(root, "program-identity.json").unlink() + adapter = MaaEndInstallAdapter( + root, + config, + takeover_enabled=True, + allow_damaged_repair=True, + ) + with pytest.raises(script_install.InstallUnsafeError, match="可信 MAS 身份"): + await adapter.recover_native() + assert (script_install.workspace(root) / "ready").exists() + assert (root / "MaaEnd.exe").read_bytes() == b"damaged" + From 8dffb7f736d7a2fad2f090d3e482c3a01e3380d0 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:15:26 +0100 Subject: [PATCH 27/29] style(maaend): apply Ruff format fixes for interrupted repair regression --- app/task/MaaEnd/manager.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index 290faa70b3..51034ffd5f 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -502,8 +502,7 @@ async def main_task(self): config, takeover_enabled=config.Update.TakeoverEnabled, allow_damaged_repair=( - self.execution_mode == "AutoProxy" - and config.Update.TakeoverEnabled + self.execution_mode == "AutoProxy" and config.Update.TakeoverEnabled ), ) request = ScriptUpdateRequest(str(self.script_info.script_id), "MaaEnd") From 026f14d9ed2ec702eab0974ef9ad96c5f2c4174b Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:15:31 +0100 Subject: [PATCH 28/29] style(maaend): apply Ruff format fixes for interrupted repair regression --- app/task/MaaEnd/tools/managed_recovery.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/task/MaaEnd/tools/managed_recovery.py b/app/task/MaaEnd/tools/managed_recovery.py index 652a82c871..9e52b04420 100644 --- a/app/task/MaaEnd/tools/managed_recovery.py +++ b/app/task/MaaEnd/tools/managed_recovery.py @@ -49,9 +49,7 @@ def discard_uncommitted_staging( receipt = script_install.record_path(root, "installed.json") if takeover_enabled and receipt.exists(): if not script_install.verify_installed_files(root, cancelled): - raise script_install.InstallUnsafeError( - "MaaEnd 原安装收据校验未通过" - ) + raise script_install.InstallUnsafeError("MaaEnd 原安装收据校验未通过") except (OSError, ValueError, script_install.InstallUnsafeError) as error: if not (takeover_enabled and allow_damaged_repair): raise From 7bf59e3e0d92f08688ad6702add709e44f5db92f Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:15:36 +0100 Subject: [PATCH 29/29] style(maaend): apply Ruff format fixes for interrupted repair regression --- tests/task/test_maaend_program_update.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index 87dafe692f..07741b5c99 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -1224,4 +1224,3 @@ async def test_precommit_staging_repair_requires_trusted_identity( await adapter.recover_native() assert (script_install.workspace(root) / "ready").exists() assert (root / "MaaEnd.exe").read_bytes() == b"damaged" -