From 1717d7d58a5c6507f26ab34a4fbc2cb0af9903f5 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:07:27 +0100 Subject: [PATCH 01/19] fix(baah): guard corrupt managed installs and allow explicit verified repair --- app/core/ws/protocol.py | 3 + app/task/BAAH/manager.py | 48 ++++++++++--- app/task/BAAH/tools/install_recovery.py | 88 ++++++++++++++++++++++++ app/task/BAAH/tools/program_update.py | 81 ++++++++++++++++++---- changelog.d/baah-install-recovery.fix.md | 2 + tests/task/test_baah_program_update.py | 70 +++++++++++++++++++ 6 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 app/task/BAAH/tools/install_recovery.py create mode 100644 changelog.d/baah-install-recovery.fix.md diff --git a/app/core/ws/protocol.py b/app/core/ws/protocol.py index 045b74b18b..75ebfed8eb 100644 --- a/app/core/ws/protocol.py +++ b/app/core/ws/protocol.py @@ -65,6 +65,9 @@ # 逐脚本、逐轮的执行边界(id=TaskManager),不随任务快照重放。 TASK_SCRIPT_STARTED = "task.script.started" TASK_SCRIPT_FINISHED = "task.script.finished" +BAAH_INSTALL_PROMPT = "baah.install.prompt" +BAAH_INSTALL_PROMPT_CLOSED = "baah.install.prompt.closed" +BAAH_INSTALL_DECISION = "baah.install.decision" # 应用生命周期与电源(id=Main) BACKEND_SHUTDOWN_READY = "backend.shutdown.ready" diff --git a/app/task/BAAH/manager.py b/app/task/BAAH/manager.py index 64037a595f..8d77b17de1 100644 --- a/app/task/BAAH/manager.py +++ b/app/task/BAAH/manager.py @@ -44,7 +44,7 @@ from app.utils.common.fluxdown_cli import FluxdownCancelUnconfirmed from .AutoProxy import AutoProxyTask -from .tools import program_update +from .tools import install_recovery, program_update logger = get_logger("BAAH 调度器") @@ -99,7 +99,8 @@ async def check(self) -> str: return "未填写 BAAH 主程序路径, 请检查托管配置中的主程序路径设置!" if not path.is_file(): directory = script_install.workspace(path.parent) - if not ((directory / "transaction.json").exists()): + receipt = script_install.record_path(path.parent, "installed.json") + if not (directory / "transaction.json").is_file() and not receipt.is_file(): return "BAAH 主程序不存在,且没有可信的中断安装记录,请检查安装路径" return "Pass" @@ -139,6 +140,42 @@ async def _prepare_program_update(self) -> None: await lock.acquire() self._directory_lock = lock self._install_access.enter_context(script_install.installation_access(root)) + # 先恢复已记录的目录切换事务;这属于文件快照回滚,不等同于跳过安装。 + await script_install.run_worker( + script_install.recover_install, + root, + lambda: program_update.assert_idle(root), + ) + try: + await program_update.confirm_current_install(root) + except (script_install.InstallUnsafeError, OSError, ValueError) as error: + # 没有可信回执的目录不能猜成历史托管安装,也不允许向错误目录写入。 + receipt = script_install.record_path(root, "installed.json") + if receipt.is_symlink() or not receipt.is_file(): + raise script_install.InstallUnsafeError( + "BAAH 当前安装不可确认且缺少 MAS 校验记录,请使用官方安装包修复" + ) from error + action, snapshot = await install_recovery.ask_install_recovery( + self.task_info.task_id, + root, + managed_name=getattr(self.script_info, "name", "BAAH"), + takeover_enabled=self.script_config.Update.TakeoverEnabled, + ) + if action == "retry": + result = await run_script_update( + ScriptUpdateRequest(str(self.script_info.script_id), "BAAH"), + program_update.update_before_run, + root, + self.script_config, + recovery_receipt=snapshot, + ) + await program_update.confirm_current_install(root) + await self.script_info.set_log(f"BAAH 手动修复安装成功:{result}") + self.check_result = "Pass" + return + raise script_install.InstallUnsafeError( + "用户取消 BAAH 安装修复;仍保留原校验记录" + ) if self.script_config.Update.TakeoverEnabled: async def progress(downloaded: int, total: int, speed: float) -> None: @@ -169,13 +206,6 @@ async def progress(downloaded: int, total: int, speed: float) -> None: type=protocol.TASK_NOTICE, data=WSTaskNoticeData(level="warning", message=message), ) - else: - # 即使关闭接管,上轮未确认安装也必须先恢复。 - await script_install.run_worker( - script_install.recover_install, - root, - lambda: program_update.assert_idle(root), - ) self.check_result = "Pass" async def main_task(self): diff --git a/app/task/BAAH/tools/install_recovery.py b/app/task/BAAH/tools/install_recovery.py new file mode 100644 index 0000000000..c91a3aa847 --- /dev/null +++ b/app/task/BAAH/tools/install_recovery.py @@ -0,0 +1,88 @@ +# Copyright © 2025-2026 AUTO-MAS Team +# SPDX-License-Identifier: AGPL-3.0-or-later +"""BAAH 损坏安装恢复:只能明确重装,绝不忽略未知文件完整性。""" + +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 . import program_update + +_TIMEOUT_SECONDS = 120 + + +async def ask_install_recovery( + task_id: str, + root: Path, + *, + managed_name: str, + takeover_enabled: bool, +) -> tuple[str, bytes]: + receipt = script_install.record_path(root, "installed.json") + if receipt.is_symlink() or not receipt.is_file(): + raise script_install.InstallUnsafeError("BAAH 校验回执缺失或不安全,拒绝自动修复") + snapshot = await asyncio.to_thread(receipt.read_bytes) + program_update.verify_recovery_receipt(root, snapshot) + try: + current = str(await asyncio.to_thread(program_update.read_version, root)) + except (OSError, ValueError, TypeError, KeyError): + current = "未知" + + request_id = uuid4().hex + future: asyncio.Future[str] = asyncio.get_running_loop().create_future() + + def receive(envelope) -> None: + data = envelope.data + if ( + data.get("taskId") == task_id + and data.get("requestId") == request_id + and data.get("action") in ("retry", "stop") + and not future.done() + ): + future.set_result(data["action"]) + + unregister = Dispatcher.register( + protocol.ID_TASK_MANAGER, protocol.BAAH_INSTALL_DECISION, receive + ) + try: + sent = await Publisher.send( + id=protocol.ID_TASK_MANAGER, + type=protocol.BAAH_INSTALL_PROMPT, + data={ + "requestId": request_id, + "taskId": task_id, + "managedName": managed_name, + "installPath": str(root.resolve()), + "takeoverEnabled": takeover_enabled, + "installedVersion": current, + "targetVersion": "官方通道最新完整包", + }, + ) + if not sent: + raise script_install.InstallUnsafeError( + "BAAH 安装需要修复,请打开 MAS 主界面选择重新安装" + ) + try: + action = await asyncio.wait_for(future, _TIMEOUT_SECONDS) + except TimeoutError as error: + raise script_install.InstallUnsafeError( + "BAAH 修复确认超时,未修改安装文件" + ) from error + if action == "stop": + raise script_install.InstallUnsafeError("用户取消 BAAH 安装修复") + program_update.verify_recovery_receipt(root, snapshot) + return action, snapshot + finally: + unregister() + try: + await Publisher.send( + id=protocol.ID_TASK_MANAGER, + type=protocol.BAAH_INSTALL_PROMPT_CLOSED, + data={"requestId": request_id}, + ) + except Exception: + pass diff --git a/app/task/BAAH/tools/program_update.py b/app/task/BAAH/tools/program_update.py index 9a0f510670..e0cff6fb4b 100644 --- a/app/task/BAAH/tools/program_update.py +++ b/app/task/BAAH/tools/program_update.py @@ -32,7 +32,7 @@ @dataclass(frozen=True) class PreparedUpdate: root: Path - base_exe_sha256: str + base_exe_sha256: str | None version: Version archive: Path size: int @@ -154,7 +154,11 @@ def verify_package( async def prepare_update( - root: Path, config, *, progress: DownloadProgress | None = None + root: Path, + config, + *, + progress: DownloadProgress | None = None, + recovery_receipt: bytes | None = None, ) -> PreparedUpdate | None: """只读当前版本,在 MAS 数据目录下载并校验;允许与上一轮 BAAH 运行并行。""" channel = config.Update.Channel @@ -164,17 +168,29 @@ async def prepare_update( ) proxy = Config.proxy root = root.resolve() - baseline = await asyncio.to_thread(exe_digest, root) - current = await asyncio.to_thread(read_version, root) + if recovery_receipt is not None: + verify_recovery_receipt(root, recovery_receipt) + try: + baseline = await asyncio.to_thread(exe_digest, root) + current = await asyncio.to_thread(read_version, root) + except (OSError, ValueError, KeyError, TypeError): + if recovery_receipt is None: + raise + baseline = ( + await asyncio.to_thread(exe_digest, root) + if (root / "BAAH.exe").is_file() + else None + ) + current = None release = await request_release(channel) target = Version(release["tag_name"].removeprefix("BAAH")) receipt = script_install.record_path(root, "installed.json") verified = not receipt.exists() or await script_install.run_worker( script_install.verify_installed_files, root ) - if target <= current and verified: + if recovery_receipt is None and target <= current and verified: return None - if target < current: + if current is not None and target < current: raise script_install.InstallUnsafeError( "当前 BAAH 安装未通过校验,不会自动降级,请手动修复" ) @@ -230,16 +246,29 @@ async def prepare_update( progress=progress, ) await script_install.run_worker(verify_package, destination, size, digest) + if recovery_receipt is not None: + verify_recovery_receipt(root, recovery_receipt) return PreparedUpdate(root, baseline, target, destination, size, digest) -async def apply_update(root: Path, prepared: PreparedUpdate) -> None: +async def apply_update( + root: Path, + prepared: PreparedUpdate, + *, + recovery_receipt: bytes | None = None, +) -> None: """调用者持安装锁;重新验证安装身份和包体后,才允许修改安装目录。""" - if ( - root.resolve() != prepared.root - or await asyncio.to_thread(exe_digest, root) != prepared.base_exe_sha256 - ): + if root.resolve() != prepared.root: raise ValueError("BAAH 安装在预下载后发生变化,请重新检查更新") + if prepared.base_exe_sha256 is None: + if recovery_receipt is None or (root / "BAAH.exe").exists(): + raise script_install.InstallUnsafeError( + "BAAH 安装身份发生变化,拒绝覆盖新出现的程序" + ) + elif await asyncio.to_thread(exe_digest, root) != prepared.base_exe_sha256: + raise ValueError("BAAH 安装在预下载后发生变化,请重新检查更新") + if recovery_receipt is not None: + verify_recovery_receipt(root, recovery_receipt) await asyncio.to_thread(assert_idle, root) await script_install.run_worker( verify_package, prepared.archive, prepared.size, prepared.digest @@ -266,15 +295,39 @@ def validate(path: Path) -> None: shutil.rmtree(prepared.archive.parent) +def verify_recovery_receipt(root: Path, snapshot: bytes) -> None: + """只允许用户确认的相同 MAS 安装回执触发损坏本体重装。""" + receipt = script_install.record_path(root, "installed.json") + if ( + receipt.is_symlink() + or not receipt.is_file() + or not snapshot + or len(snapshot) > 1024 * 1024 + or receipt.read_bytes() != snapshot + ): + raise script_install.InstallUnsafeError( + "BAAH 安装校验回执已改变,拒绝继续旧恢复请求" + ) + + async def update_before_run( - root: Path, config, *, progress: DownloadProgress | None = None + root: Path, + config, + *, + progress: DownloadProgress | None = None, + recovery_receipt: bytes | None = None, ) -> str: await script_install.run_worker( script_install.recover_install, root, lambda: assert_idle(root) ) - candidate = await prepare_update(root, config, progress=progress) + if recovery_receipt is not None: + verify_recovery_receipt(root, recovery_receipt) + candidate = await prepare_update( + root, config, progress=progress, recovery_receipt=recovery_receipt + ) if candidate is None: await confirm_current_install(root) return "BAAH 当前版本无需更新" - await apply_update(root, candidate) + await apply_update(root, candidate, recovery_receipt=recovery_receipt) + await confirm_current_install(root) return f"BAAH 已更新至 {candidate.version},完整性校验通过" diff --git a/changelog.d/baah-install-recovery.fix.md b/changelog.d/baah-install-recovery.fix.md new file mode 100644 index 0000000000..ed124c5688 --- /dev/null +++ b/changelog.d/baah-install-recovery.fix.md @@ -0,0 +1,2 @@ +project: baah +损坏的托管安装须由用户明确选择官方完整包修复,禁止绕过校验运行 diff --git a/tests/task/test_baah_program_update.py b/tests/task/test_baah_program_update.py index e3ac55a765..5775562724 100644 --- a/tests/task/test_baah_program_update.py +++ b/tests/task/test_baah_program_update.py @@ -278,3 +278,73 @@ def test_real_idle_whitelist_rejects_baah_but_allows_known_adb( ) with pytest.raises(script_install.InstallUnsafeError): REAL_ASSERT_IDLE(root, allow_background_adb=True) + + +@pytest.mark.asyncio +async def test_explicit_repair_can_reinstall_missing_baah_exe(installation): + root, config, _, _ = installation + original = await update.prepare_update(root, config) + await update.apply_update(root, original) + receipt = script_install.record_path(root, "installed.json") + assert receipt.is_file() + snapshot = receipt.read_bytes() + (root / "BAAH.exe").unlink() + with pytest.raises(script_install.InstallUnsafeError): + await update.confirm_current_install(root) + with pytest.raises(OSError): + await update.prepare_update(root, config) + result = await update.update_before_run(root, config, recovery_receipt=snapshot) + assert "校验通过" in result + assert (root / "BAAH.exe").read_bytes() == pe(b"new") + assert not (script_install.workspace(root) / "transaction.json").exists() + await update.confirm_current_install(root) + + +@pytest.mark.asyncio +async def test_repair_refuses_changed_receipt(installation): + root, config, _, _ = installation + candidate = await update.prepare_update(root, config) + await update.apply_update(root, candidate) + receipt = script_install.record_path(root, "installed.json") + previous = receipt.read_bytes() + receipt.write_text('{"files":{}}') + with pytest.raises(script_install.InstallUnsafeError, match="已改变"): + await update.update_before_run(root, config, recovery_receipt=previous) + assert (root / "BAAH.exe").is_file() + + +@pytest.mark.asyncio +async def test_manager_without_takeover_checks_receipt_integrity( + installation, monkeypatch +): + from app.task.BAAH.tools import install_recovery + + root, config, _, _ = installation + prepared = await update.prepare_update(root, config) + await update.apply_update(root, prepared) + (root / "BAAH.exe").write_bytes(pe(b"corrupt")) + config.Update.TakeoverEnabled = False + + instance = object.__new__(manager.BAAHManager) + instance._install_access = ExitStack() + instance._directory_lock = None + instance.script_config = SimpleNamespace( + Script=SimpleNamespace(BAAHPath=root / "BAAH.exe"), + Update=config.Update, + ) + instance.script_info = SimpleNamespace( + script_id=str(uuid.uuid4()), name="BAAH A", set_log=AsyncMock() + ) + instance.task_info = SimpleNamespace(task_id="task", mode="AutoProxy") + monkeypatch.setattr( + install_recovery, + "ask_install_recovery", + AsyncMock(side_effect=script_install.InstallUnsafeError("没有主界面")), + ) + with pytest.raises(script_install.InstallUnsafeError, match="没有主界面"): + await instance._prepare_program_update() + install_recovery.ask_install_recovery.assert_awaited_once() + assert (script_install.record_path(root, "installed.json")).is_file() + instance._install_access.close() + if instance._directory_lock is not None: + instance._directory_lock.release() From 228b5be02a115589c8d48e072c9b6a2d5db55460 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:08:53 +0100 Subject: [PATCH 02/19] feat(baah): add full-install repair dialog and reconnect-safe decision --- frontend/src/App.vue | 2 + frontend/src/bootstrap/realtimeResidents.ts | 8 ++ .../components/BAAHInstallRecoveryModal.vue | 120 ++++++++++++++++++ frontend/src/i18n/bootstrap/en-US.json | 14 ++ frontend/src/i18n/bootstrap/ja-JP.json | 14 ++ frontend/src/i18n/bootstrap/zh-CN.json | 14 ++ .../src/services/baahInstallRecovery.test.ts | 86 +++++++++++++ frontend/src/services/baahInstallRecovery.ts | 86 +++++++++++++ frontend/src/services/websocket/types.ts | 20 +++ 9 files changed, 364 insertions(+) create mode 100644 frontend/src/components/BAAHInstallRecoveryModal.vue create mode 100644 frontend/src/services/baahInstallRecovery.test.ts create mode 100644 frontend/src/services/baahInstallRecovery.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 543f4338d2..93dbab9de4 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 BAAHInstallRecoveryModal from './components/BAAHInstallRecoveryModal.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..a8aa7e23b6 100644 --- a/frontend/src/bootstrap/realtimeResidents.ts +++ b/frontend/src/bootstrap/realtimeResidents.ts @@ -21,6 +21,10 @@ import { bootstrapVirtualDisplayPrompt, disposeVirtualDisplayPrompt, } from '@/services/virtualDisplayPrompt' +import { + bootstrapBAAHInstallRecovery, + disposeBAAHInstallRecovery, +} from '@/services/baahInstallRecovery' let registered = false @@ -71,6 +75,10 @@ export function bootstrapRealtimeResidents(): void { bootstrap: bootstrapVirtualDisplayPrompt, dispose: disposeVirtualDisplayPrompt, }) + registerResidentResource('baah-install-recovery', { + bootstrap: bootstrapBAAHInstallRecovery, + dispose: disposeBAAHInstallRecovery, + }) registered = true } bootstrapResidentResources() diff --git a/frontend/src/components/BAAHInstallRecoveryModal.vue b/frontend/src/components/BAAHInstallRecoveryModal.vue new file mode 100644 index 0000000000..ef9b547f87 --- /dev/null +++ b/frontend/src/components/BAAHInstallRecoveryModal.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/i18n/bootstrap/en-US.json b/frontend/src/i18n/bootstrap/en-US.json index f5abbfe3aa..6b5e27b629 100644 --- a/frontend/src/i18n/bootstrap/en-US.json +++ b/frontend/src/i18n/bootstrap/en-US.json @@ -3,6 +3,20 @@ "languageSaveFailed": "Failed to save the language setting; reverted to the previous one." }, "comp": { + "baahRecoveryCurrent": "Installed version", + "baahRecoveryDisabled": "Disabled", + "baahRecoveryEnabled": "Enabled", + "baahRecoveryInstallPath": "Installation folder", + "baahRecoveryManagedName": "Managed profile", + "baahRecoveryMessage": "The installed BAAH program may be damaged. Confirm the profile and folder before repairing with a verified full official package, or cancel this run.", + "baahRecoveryRetry": "Download and repair", + "baahRecoveryRisk": "Only a verified full official package can repair this installation. You cannot bypass integrity checks or delete transaction records. Close BAAH and its updater first.", + "baahRecoverySendFailed": "Unable to send the repair choice. Check backend connectivity.", + "baahRecoveryStop": "Cancel this run", + "baahRecoveryTakeover": "Managed updates", + "baahRecoveryTarget": "Repair source", + "baahRecoveryTitle": "BAAH installation failed integrity checks", + "baahRecoveryUnknown": "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..9f0ef9426d 100644 --- a/frontend/src/i18n/bootstrap/ja-JP.json +++ b/frontend/src/i18n/bootstrap/ja-JP.json @@ -3,6 +3,20 @@ "languageSaveFailed": "言語設定の保存に失敗しました。元の言語に戻しました。" }, "comp": { + "baahRecoveryCurrent": "現在のバージョン", + "baahRecoveryDisabled": "無効", + "baahRecoveryEnabled": "有効", + "baahRecoveryInstallPath": "インストール先", + "baahRecoveryManagedName": "管理設定名", + "baahRecoveryMessage": "BAAH が破損している可能性があります。対象設定とフォルダーを確認し、公式完全パッケージで修復するか中止してください。", + "baahRecoveryRetry": "完全パッケージで修復", + "baahRecoveryRisk": "公式の完全パッケージによる再インストールのみ可能です。整合性チェックをスキップしたり、トランザクション記録を削除しないでください。BAAH を終了してください。", + "baahRecoverySendFailed": "修復指示を送信できません。接続を確認してください。", + "baahRecoveryStop": "今回のタスクを中止", + "baahRecoveryTakeover": "自動更新", + "baahRecoveryTarget": "修復ソース", + "baahRecoveryTitle": "BAAH のインストール整合性チェックに失敗", + "baahRecoveryUnknown": "不明", "backendAboutUpdateWhich": "バックエンドを更新します。更新にはバックエンドの再起動が必要で、実行中のタスクは中断されます。続行しますか?", "backendStarting": "バックエンドを起動しています", "backendUpdateAvailableClick": "バックエンドの更新があります。クリックして更新してください", diff --git a/frontend/src/i18n/bootstrap/zh-CN.json b/frontend/src/i18n/bootstrap/zh-CN.json index eb4f5910e6..54912214d6 100644 --- a/frontend/src/i18n/bootstrap/zh-CN.json +++ b/frontend/src/i18n/bootstrap/zh-CN.json @@ -3,6 +3,20 @@ "languageSaveFailed": "语言设置保存失败,已恢复原语言" }, "comp": { + "baahRecoveryCurrent": "当前版本", + "baahRecoveryDisabled": "已关闭", + "baahRecoveryEnabled": "已开启", + "baahRecoveryInstallPath": "安装目录", + "baahRecoveryManagedName": "托管名称", + "baahRecoveryMessage": "BAAH 可能在更新中受损。请确认托管名称和目录,选择安装官方完整包修复,或取消本轮任务。", + "baahRecoveryRetry": "下载完整包并修复", + "baahRecoveryRisk": "本次只支持重新校验并安装完整官方包,不允许跳过损坏校验或手工删除事务记录。请先退出 BAAH 和更新器。", + "baahRecoverySendFailed": "无法发送恢复指令,请检查后端连接。", + "baahRecoveryStop": "取消本次任务", + "baahRecoveryTakeover": "托管更新", + "baahRecoveryTarget": "修复来源", + "baahRecoveryTitle": "BAAH 安装文件未通过完整性检查", + "baahRecoveryUnknown": "未知", "backendAboutUpdateWhich": "即将更新后端,这需要重启后端程序,你当前正在运行的任务将会被中断。确认继续?", "backendStarting": "正在启动后端", "backendUpdateAvailableClick": "检测到后端更新,点击以更新后端", diff --git a/frontend/src/services/baahInstallRecovery.test.ts b/frontend/src/services/baahInstallRecovery.test.ts new file mode 100644 index 0000000000..5bb6a57d9f --- /dev/null +++ b/frontend/src/services/baahInstallRecovery.test.ts @@ -0,0 +1,86 @@ +import { afterEach, expect, it, vi } from 'vite-plus/test' + +const boundary = vi.hoisted(() => ({ + subscriptions: new Map }) => void>(), + connected: [] as Array<() => void>, + disconnected: [] as Array<() => void>, + send: vi.fn(() => true), +})) + +vi.mock('@/services/websocket/subscriptions', () => ({ + subscribe: ( + criteria: { id: string; type: string }, + handler: (message: { data: Record }) => void + ) => { + const key = `${criteria.id}:${criteria.type}` + boundary.subscriptions.set(key, handler) + return key + }, + unsubscribe: (id: string) => { + boundary.subscriptions.delete(id) + }, +})) + +vi.mock('@/services/websocket/connection', () => ({ + send: boundary.send, + onConnected: (listener: () => void) => { + boundary.connected.push(listener) + return () => { + const at = boundary.connected.indexOf(listener) + if (at >= 0) boundary.connected.splice(at, 1) + } + }, + onDisconnected: (listener: () => void) => { + boundary.disconnected.push(listener) + return () => { + const at = boundary.disconnected.indexOf(listener) + if (at >= 0) boundary.disconnected.splice(at, 1) + } + }, +})) + +afterEach(() => { + vi.unstubAllGlobals() + boundary.subscriptions.clear() + boundary.connected.length = 0 + boundary.disconnected.length = 0 + boundary.send.mockClear() +}) + +it('drops stale BAAH install prompt after active websocket reconnect without close event', async () => { + vi.stubGlobal('window', { + electronAPI: { getLogger: () => ({ warn: vi.fn() }) }, + }) + const module = await import('./baahInstallRecovery') + module.bootstrapBAAHInstallRecovery() + const prompt = boundary.subscriptions.get('TaskManager:baah.install.prompt') + expect(prompt).toBeTruthy() + + prompt!({ + data: { + requestId: 'old-request', + taskId: 'task-one', + managedName: 'Example', + installPath: '/sandbox/one', + }, + }) + expect(module.baahInstallPrompts.value).toHaveLength(1) + expect(boundary.connected).toHaveLength(1) + + // Simulate reconnectNow successful: stale socket close was intentionally ignored, + // and backend's final prompt-closed frame never arrived. + boundary.connected[0]() + expect(module.baahInstallPrompts.value).toHaveLength(0) + expect(module.baahInstallDecisionPending.value).toBeNull() + expect(module.respondToBAAHInstallRecovery('retry')).toBe(false) + expect(boundary.send).not.toHaveBeenCalled() + + // A new prompt from the new transport remains actionable. + prompt!({ data: { requestId: 'new-request', taskId: 'task-two' } }) + expect(module.respondToBAAHInstallRecovery('stop')).toBe(true) + expect(boundary.send).toHaveBeenCalledOnce() + + module.disposeBAAHInstallRecovery() + expect(boundary.connected).toHaveLength(0) + expect(boundary.disconnected).toHaveLength(0) +}) diff --git a/frontend/src/services/baahInstallRecovery.ts b/frontend/src/services/baahInstallRecovery.ts new file mode 100644 index 0000000000..7ff8408359 --- /dev/null +++ b/frontend/src/services/baahInstallRecovery.ts @@ -0,0 +1,86 @@ +// 应用级 BAAH 安装恢复询问:首条主连接之前常驻订阅,避免路由切换漏掉请求。 +// 一次只能处理一个弹窗,后续请求排队;断线或后端关闭请求时立即清除旧选择。 + +import { ref } from 'vue' +import { onConnected, onDisconnected, send } from '@/services/websocket/connection' +import { subscribe, unsubscribe } from '@/services/websocket/subscriptions' +import { + WS_BETTERGI_INSTALL_DECISION, + WS_BETTERGI_INSTALL_PROMPT, + WS_BETTERGI_INSTALL_PROMPT_CLOSED, + WS_ID_TASK_MANAGER, + type WSBAAHInstallPromptData, +} from '@/services/websocket/types' + +const logger = window.electronAPI.getLogger('BAAH安装恢复') +export const baahInstallPrompts = ref([]) +export const baahInstallDecisionPending = ref(null) + +let subscriptionIds: string[] = [] +let disposeDisconnect: (() => void) | null = null +let disposeConnect: (() => void) | null = null + +const forgetRequest = (requestId: string) => { + baahInstallPrompts.value = baahInstallPrompts.value.filter( + prompt => prompt.requestId !== requestId + ) + if (baahInstallDecisionPending.value === requestId) { + baahInstallDecisionPending.value = null + } +} + +export function bootstrapBAAHInstallRecovery(): void { + if (subscriptionIds.length) return + subscriptionIds = [ + subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BETTERGI_INSTALL_PROMPT }, message => { + const prompt = message.data + if ( + !prompt.requestId || + !prompt.taskId || + baahInstallPrompts.value.some(previous => previous.requestId === prompt.requestId) + ) { + return + } + baahInstallPrompts.value.push(prompt) + }), + subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BETTERGI_INSTALL_PROMPT_CLOSED }, message => { + forgetRequest(message.data.requestId) + }), + ] + disposeDisconnect = onDisconnected(() => { + baahInstallPrompts.value = [] + baahInstallDecisionPending.value = null + }) + // reconnectNow 在切换连接代次时不会触发 onDisconnected。 + // 新连接建立后,旧连接的关闭帧可能已丢失;绝不可沿用旧确认请求。 + disposeConnect = onConnected(() => { + baahInstallPrompts.value = [] + baahInstallDecisionPending.value = null + }) +} + +export function respondToBAAHInstallRecovery(action: 'retry' | 'stop'): boolean { + const prompt = baahInstallPrompts.value[0] + if (!prompt || baahInstallDecisionPending.value) return false + const sent = send(WS_ID_TASK_MANAGER, WS_BETTERGI_INSTALL_DECISION, { + requestId: prompt.requestId, + taskId: prompt.taskId, + action, + }) + if (!sent) { + logger.warn('未能发送 BAAH 安装恢复选择') + return false + } + baahInstallDecisionPending.value = prompt.requestId + return true +} + +export function disposeBAAHInstallRecovery(): void { + for (const id of subscriptionIds.splice(0)) unsubscribe(id) + disposeDisconnect?.() + disposeDisconnect = null + disposeConnect?.() + disposeConnect = null + baahInstallPrompts.value = [] + baahInstallDecisionPending.value = null +} diff --git a/frontend/src/services/websocket/types.ts b/frontend/src/services/websocket/types.ts index d7f0005eba..c3dbc2c8e5 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_BAAH_INSTALL_PROMPT = 'baah.install.prompt' +export const WS_BAAH_INSTALL_PROMPT_CLOSED = 'baah.install.prompt.closed' +export const WS_BAAH_INSTALL_DECISION = 'baah.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' } +/** BAAH 已有 MAS 校验回执但安装损坏时的用户修复请求 */ +export interface WSBAAHInstallPromptData { + requestId: string + taskId: string + managedName: string + installPath: string + takeoverEnabled: boolean + installedVersion: string + targetVersion: string +} + +export interface WSBAAHInstallPromptClosedData { + 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_BAAH_INSTALL_PROMPT]: WSBAAHInstallPromptData + [WS_BAAH_INSTALL_PROMPT_CLOSED]: WSBAAHInstallPromptClosedData [WS_BACKEND_SHUTDOWN_READY]: WSEmptyData [WS_FRONTEND_CLOSE_REQUESTED]: FrontendCloseRequest [WS_FRONTEND_RESTART_REQUESTED]: FrontendCloseRequest From 1d1e39e54c52272497aaa16eafb00fe78b40b7ad Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:14:34 +0100 Subject: [PATCH 03/19] fix: correct recovery boundaries and test event bindings --- app/task/BAAH/manager.py | 5 +++++ app/task/BAAH/tools/install_recovery.py | 4 +++- app/task/BAAH/tools/program_update.py | 8 +++++++- frontend/src/services/baahInstallRecovery.ts | 12 ++++++------ 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/app/task/BAAH/manager.py b/app/task/BAAH/manager.py index 8d77b17de1..7bf95d3ae8 100644 --- a/app/task/BAAH/manager.py +++ b/app/task/BAAH/manager.py @@ -23,6 +23,7 @@ 按账号逐个拉起 BAAH 自动代理任务,结束时统一组装任务报告并推送。 """ +import asyncio import uuid from contextlib import ExitStack, suppress from datetime import datetime @@ -146,6 +147,10 @@ async def _prepare_program_update(self) -> None: root, lambda: program_update.assert_idle(root), ) + # 进程占用 / 权限不明属于停写门禁,不应误报为程序损坏。 + await asyncio.to_thread( + program_update.assert_idle, root, allow_background_adb=True + ) try: await program_update.confirm_current_install(root) except (script_install.InstallUnsafeError, OSError, ValueError) as error: diff --git a/app/task/BAAH/tools/install_recovery.py b/app/task/BAAH/tools/install_recovery.py index c91a3aa847..76e55e2534 100644 --- a/app/task/BAAH/tools/install_recovery.py +++ b/app/task/BAAH/tools/install_recovery.py @@ -24,7 +24,9 @@ async def ask_install_recovery( ) -> tuple[str, bytes]: receipt = script_install.record_path(root, "installed.json") if receipt.is_symlink() or not receipt.is_file(): - raise script_install.InstallUnsafeError("BAAH 校验回执缺失或不安全,拒绝自动修复") + raise script_install.InstallUnsafeError( + "BAAH 校验回执缺失或不安全,拒绝自动修复" + ) snapshot = await asyncio.to_thread(receipt.read_bytes) program_update.verify_recovery_receipt(root, snapshot) try: diff --git a/app/task/BAAH/tools/program_update.py b/app/task/BAAH/tools/program_update.py index e0cff6fb4b..5fa028da19 100644 --- a/app/task/BAAH/tools/program_update.py +++ b/app/task/BAAH/tools/program_update.py @@ -89,7 +89,13 @@ async def confirm_current_install(root: Path) -> None: await asyncio.to_thread(assert_idle, root, allow_background_adb=True) if (script_install.workspace(root) / "transaction.json").exists(): raise script_install.InstallUnsafeError("BAAH 安装尚未确认,请开启更新托管修复") - if script_install.read_pe_architecture(root / "BAAH.exe") != "x86_64": + try: + architecture = script_install.read_pe_architecture(root / "BAAH.exe") + except (OSError, ValueError) as error: + raise script_install.InstallUnsafeError( + "BAAH 主程序缺失或架构无法识别,需要修复" + ) from error + if architecture != "x86_64": raise script_install.InstallUnsafeError( "BAAH 安装架构不可识别,请使用官方包修复" ) diff --git a/frontend/src/services/baahInstallRecovery.ts b/frontend/src/services/baahInstallRecovery.ts index 7ff8408359..e7eb033c67 100644 --- a/frontend/src/services/baahInstallRecovery.ts +++ b/frontend/src/services/baahInstallRecovery.ts @@ -5,9 +5,9 @@ import { ref } from 'vue' import { onConnected, onDisconnected, send } from '@/services/websocket/connection' import { subscribe, unsubscribe } from '@/services/websocket/subscriptions' import { - WS_BETTERGI_INSTALL_DECISION, - WS_BETTERGI_INSTALL_PROMPT, - WS_BETTERGI_INSTALL_PROMPT_CLOSED, + WS_BAAH_INSTALL_DECISION, + WS_BAAH_INSTALL_PROMPT, + WS_BAAH_INSTALL_PROMPT_CLOSED, WS_ID_TASK_MANAGER, type WSBAAHInstallPromptData, } from '@/services/websocket/types' @@ -32,7 +32,7 @@ const forgetRequest = (requestId: string) => { export function bootstrapBAAHInstallRecovery(): void { if (subscriptionIds.length) return subscriptionIds = [ - subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BETTERGI_INSTALL_PROMPT }, message => { + subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BAAH_INSTALL_PROMPT }, message => { const prompt = message.data if ( !prompt.requestId || @@ -43,7 +43,7 @@ export function bootstrapBAAHInstallRecovery(): void { } baahInstallPrompts.value.push(prompt) }), - subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BETTERGI_INSTALL_PROMPT_CLOSED }, message => { + subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BAAH_INSTALL_PROMPT_CLOSED }, message => { forgetRequest(message.data.requestId) }), ] @@ -62,7 +62,7 @@ export function bootstrapBAAHInstallRecovery(): void { export function respondToBAAHInstallRecovery(action: 'retry' | 'stop'): boolean { const prompt = baahInstallPrompts.value[0] if (!prompt || baahInstallDecisionPending.value) return false - const sent = send(WS_ID_TASK_MANAGER, WS_BETTERGI_INSTALL_DECISION, { + const sent = send(WS_ID_TASK_MANAGER, WS_BAAH_INSTALL_DECISION, { requestId: prompt.requestId, taskId: prompt.taskId, action, From 528b6b82d70609c6150d95ec0afbce867c9c3f38 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:26:27 +0100 Subject: [PATCH 04/19] feat(baah): implement #1916 health and native installer adapter without prompt --- app/task/BAAH/tools/managed_recovery.py | 112 ++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 app/task/BAAH/tools/managed_recovery.py diff --git a/app/task/BAAH/tools/managed_recovery.py b/app/task/BAAH/tools/managed_recovery.py new file mode 100644 index 0000000000..fd48061b46 --- /dev/null +++ b/app/task/BAAH/tools/managed_recovery.py @@ -0,0 +1,112 @@ +# Copyright © 2025-2026 AUTO-MAS Team +# SPDX-License-Identifier: AGPL-3.0-or-later +"""BAAH 官方完整包恢复适配器;公共入口负责恢复策略和任务末尾报告。""" + +import asyncio +from pathlib import Path + +from packaging.version import Version + +from app.core.script_update_recovery import InstallInspection +from app.services import script_install +from app.services.script_install_backup import ManagedInstallBackup + +from . import program_update + + +class BAAHInstallAdapter: + def __init__( + self, + root: Path, + config, + *, + prepared: program_update.PreparedUpdate | None = None, + recovery_receipt: bytes | None = None, + ) -> None: + self.root = root + self.config = config + self.prepared = prepared + self.recovery_receipt = recovery_receipt + + async def ensure_idle(self) -> None: + await asyncio.to_thread(program_update.assert_idle, self.root) + + async def inspect(self) -> InstallInspection: + await self.ensure_idle() + pending_file = script_install.workspace(self.root) / "transaction.json" + if pending_file.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 原生事务标记为链接") + pending = pending_file.exists() + version = None + healthy = False + try: + await program_update.confirm_current_install(self.root) + version = await asyncio.to_thread(program_update.read_version, self.root) + healthy = not pending + except (script_install.InstallUnsafeError, OSError, ValueError, KeyError): + healthy = False + target = self.prepared.version if self.prepared else None + previous = None + if self.prepared is None: + state = ManagedInstallBackup( + self.root, script_id="", script_type="BAAH" + ).read() + if state and state.get("version"): + previous = Version(state["version"]) + elif version is not None and not pending: + previous = version + else: + try: + previous = await asyncio.to_thread( + program_update.read_version, self.root + ) + except (OSError, ValueError, KeyError): + previous = None + if healthy and self.recovery_receipt is not None: + # 损坏安装只有真正完成新安装、旧记录已被更新后才算恢复。 + receipt = script_install.record_path(self.root, "installed.json") + if receipt.read_bytes() == self.recovery_receipt: + healthy = False + return InstallInspection( + pending=pending or (not healthy and self.recovery_receipt is not None), + healthy=healthy, + installed_version=str(version) if version else None, + target_version=str(target) if target else None, + updated_confirmed=bool( + healthy + and ( + (target is not None and version == target) + or (previous is not None and version is not None and version > previous) + ) + ), + previous_confirmed=bool( + healthy + and not pending + and previous is not None + and version == previous + ), + ) + + async def acknowledge(self) -> None: + await self.ensure_idle() + await script_install.run_worker( + script_install.recover_install, + self.root, + lambda: program_update.assert_idle(self.root), + ) + await program_update.confirm_current_install(self.root) + + async def install(self) -> None: + if self.prepared: + await program_update.apply_update(self.root, self.prepared) + else: + await program_update.update_before_run( + self.root, + self.config, + recovery_receipt=self.recovery_receipt, + ) + + async def after_rollback(self) -> None: + await self.ensure_idle() + # 如果安装回执无法匹配已恢复旧版,则不能宣称回滚完成并启动任务。 + await program_update.confirm_current_install(self.root) From 3953a1ab77ecaa482354eab3fbbb87829bd6e8a8 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:26:46 +0100 Subject: [PATCH 05/19] fix(baah): accept verified same-version full reinstall even when original receipt bytes match --- app/task/BAAH/tools/managed_recovery.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/task/BAAH/tools/managed_recovery.py b/app/task/BAAH/tools/managed_recovery.py index fd48061b46..9b9b242874 100644 --- a/app/task/BAAH/tools/managed_recovery.py +++ b/app/task/BAAH/tools/managed_recovery.py @@ -62,11 +62,6 @@ async def inspect(self) -> InstallInspection: ) except (OSError, ValueError, KeyError): previous = None - if healthy and self.recovery_receipt is not None: - # 损坏安装只有真正完成新安装、旧记录已被更新后才算恢复。 - receipt = script_install.record_path(self.root, "installed.json") - if receipt.read_bytes() == self.recovery_receipt: - healthy = False return InstallInspection( pending=pending or (not healthy and self.recovery_receipt is not None), healthy=healthy, From 9c5ed9a825afbabf8f1301c67baa51e25f381c93 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:27:24 +0100 Subject: [PATCH 06/19] refactor(baah): replace user repair modal with #1916 native recovery, snapshot updates and central fallback --- app/task/BAAH/manager.py | 139 +++++++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 51 deletions(-) diff --git a/app/task/BAAH/manager.py b/app/task/BAAH/manager.py index 7bf95d3ae8..16901b8974 100644 --- a/app/task/BAAH/manager.py +++ b/app/task/BAAH/manager.py @@ -24,6 +24,7 @@ """ import asyncio +import shutil import uuid from contextlib import ExitStack, suppress from datetime import datetime @@ -31,7 +32,13 @@ import app.core.notify.report as _node_report from app.config import config_manager from app.core import Config, EmulatorManager, Notify, Publisher -from app.core.script_update import ScriptUpdateRequest, run_script_update +from app.core.script_update import ( + ScriptUpdateRequest, + auto_recover_script_install, + run_managed_script_install, + run_script_update_with_safe_fallback, +) +from app.core.script_update_recovery import DownloadRetryFailed from app.core.ws import protocol from app.models.config import BAAHConfig from app.models.emulator import DeviceBase, DeviceProvider @@ -45,7 +52,9 @@ from app.utils.common.fluxdown_cli import FluxdownCancelUnconfirmed from .AutoProxy import AutoProxyTask -from .tools import install_recovery, program_update +from .tools import program_update +from .tools.managed_recovery import BAAHInstallAdapter +from app.services.script_install_backup import ManagedInstallBackup logger = get_logger("BAAH 调度器") @@ -135,82 +144,110 @@ async def prepare(self): async def _prepare_program_update(self) -> None: root = self.script_config.Script.BAAHPath.parent self.check_result = "BAAH 更新准备未完成" - # 按真实安装路径排队,等待前一 BAAH 运行的全部账号及恢复动作结束; - # 跨 MAS 进程仍由文件锁保障,不改变不同安装目录的并行能力。 lock = update_files.directory_lock(root.resolve()) await lock.acquire() self._directory_lock = lock self._install_access.enter_context(script_install.installation_access(root)) - # 先恢复已记录的目录切换事务;这属于文件快照回滚,不等同于跳过安装。 + request = ScriptUpdateRequest(str(self.script_info.script_id), "BAAH") + backup = ManagedInstallBackup( + root, script_id=request.script_id, script_type="BAAH" + ) + # 原生事务未完成时不能通过清除 journal 绕过正在使用的备份。 await script_install.run_worker( script_install.recover_install, root, lambda: program_update.assert_idle(root), ) - # 进程占用 / 权限不明属于停写门禁,不应误报为程序损坏。 - await asyncio.to_thread( - program_update.assert_idle, root, allow_background_adb=True - ) + await asyncio.to_thread(program_update.assert_idle, root) + + if backup.read() is not None: + adapter = BAAHInstallAdapter(root, self.script_config) + result = await run_managed_script_install( + request, + install_root=root, + inspect=adapter.inspect, + acknowledge=adapter.acknowledge, + install=adapter.install, + ensure_idle=adapter.ensure_idle, + after_rollback=adapter.after_rollback, + ) + await self.script_info.set_log(f"BAAH 上次安装恢复:{result}") + self.check_result = "Pass" + return + try: await program_update.confirm_current_install(root) except (script_install.InstallUnsafeError, OSError, ValueError) as error: - # 没有可信回执的目录不能猜成历史托管安装,也不允许向错误目录写入。 + # 损坏本体只能在已核验的 MAS 安装身份下自动完整重装。 receipt = script_install.record_path(root, "installed.json") if receipt.is_symlink() or not receipt.is_file(): raise script_install.InstallUnsafeError( - "BAAH 当前安装不可确认且缺少 MAS 校验记录,请使用官方安装包修复" + "BAAH 原安装不完整且缺少可信回执,不能自动修复" ) from error - action, snapshot = await install_recovery.ask_install_recovery( - self.task_info.task_id, - root, - managed_name=getattr(self.script_info, "name", "BAAH"), - takeover_enabled=self.script_config.Update.TakeoverEnabled, + snapshot = receipt.read_bytes() + program_update.verify_recovery_receipt(root, snapshot) + adapter = BAAHInstallAdapter( + root, self.script_config, recovery_receipt=snapshot ) - if action == "retry": - result = await run_script_update( - ScriptUpdateRequest(str(self.script_info.script_id), "BAAH"), - program_update.update_before_run, - root, - self.script_config, - recovery_receipt=snapshot, - ) - await program_update.confirm_current_install(root) - await self.script_info.set_log(f"BAAH 手动修复安装成功:{result}") - self.check_result = "Pass" - return - raise script_install.InstallUnsafeError( - "用户取消 BAAH 安装修复;仍保留原校验记录" + result = await auto_recover_script_install( + request, + install_root=root, + inspect=adapter.inspect, + acknowledge=adapter.acknowledge, + reinstall=adapter.install, ) + await self.script_info.set_log(f"BAAH 自动修复:{result}") + self.check_result = "Pass" + return + if self.script_config.Update.TakeoverEnabled: async def progress(downloaded: int, total: int, speed: float) -> None: await self.script_info.set_log( - f"BAAH 更新下载:{downloaded / 1024**2:.1f} / {total / 1024**2:.1f} MB" + f"BAAH 下载:{downloaded / 1024**2:.1f} / {total / 1024**2:.1f} MB" ) - try: - await self.script_info.set_log( - await run_script_update( - ScriptUpdateRequest(self.script_info.script_id, "BAAH"), - program_update.update_before_run, - root, - self.script_config, - progress=progress, + async def prepare(): + try: + return await program_update.prepare_update( + root, self.script_config, progress=progress ) + except (script_install.InstallUnsafeError, FluxdownCancelUnconfirmed): + raise + except Exception as error: + raise DownloadRetryFailed( + source="BAAH GitHub/MirrorChyan", + reason=f"BAAH 更新准备失败({type(error).__name__})", + ) from error + + candidate = await run_script_update_with_safe_fallback( + request, + prepare, + root, + BAAHInstallAdapter(root, self.script_config).inspect, + ) + if isinstance(candidate, program_update.PreparedUpdate): + adapter = BAAHInstallAdapter( + root, self.script_config, prepared=candidate ) - except (script_install.InstallUnsafeError, FluxdownCancelUnconfirmed): - raise - except Exception as error: - await program_update.confirm_current_install(root) - message = f"BAAH 更新准备失败,沿用当前安装继续任务:{error}" - await self.script_info.set_log(message) - logger.warning(message) - with suppress(Exception): - await Publisher.send( - id=self.task_info.task_id, - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData(level="warning", message=message), + try: + result = await run_managed_script_install( + request, + install_root=root, + inspect=adapter.inspect, + acknowledge=adapter.acknowledge, + install=adapter.install, + ensure_idle=adapter.ensure_idle, + after_rollback=adapter.after_rollback, ) + finally: + await adapter.ensure_idle() + if candidate.archive.parent.exists(): + shutil.rmtree(candidate.archive.parent) + else: + await program_update.confirm_current_install(root) + result = candidate or "BAAH 当前无需更新" + await self.script_info.set_log(str(result)) self.check_result = "Pass" async def main_task(self): From f5976c0557ecd714fb9d8bb7e7a8df5e37e10f11 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:28:05 +0100 Subject: [PATCH 07/19] feat(baah): automatically acknowledge only fully verified stale native transactions through public entry --- app/task/BAAH/tools/managed_recovery.py | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/app/task/BAAH/tools/managed_recovery.py b/app/task/BAAH/tools/managed_recovery.py index 9b9b242874..ce219612dc 100644 --- a/app/task/BAAH/tools/managed_recovery.py +++ b/app/task/BAAH/tools/managed_recovery.py @@ -4,6 +4,7 @@ import asyncio from pathlib import Path +from uuid import uuid4 from packaging.version import Version @@ -105,3 +106,66 @@ async def after_rollback(self) -> None: await self.ensure_idle() # 如果安装回执无法匹配已恢复旧版,则不能宣称回滚完成并启动任务。 await program_update.confirm_current_install(self.root) + + +async def inspect_stale_transaction(root: Path) -> InstallInspection: + """只接受原安装回执已验证、目录切换已结束的遗留事务。""" + await asyncio.to_thread(program_update.assert_idle, root) + directory = script_install.workspace(root) + marker = directory / "transaction.json" + previous = directory / "previous" + ready = directory / "ready" + if any(path.is_symlink() for path in (marker, previous, ready)): + raise script_install.InstallUnsafeError("BAAH 恢复目录包含符号链接") + pending = marker.exists() + if previous.exists() or ready.exists(): + return InstallInspection(pending=pending, healthy=False) + receipt = script_install.record_path(root, "installed.json") + if not receipt.is_file() or receipt.is_symlink(): + return InstallInspection(pending=pending, healthy=False) + try: + arch = await asyncio.to_thread( + script_install.read_pe_architecture, root / "BAAH.exe" + ) + version = await asyncio.to_thread(program_update.read_version, root) + verified = await script_install.run_worker( + script_install.verify_installed_files, root + ) + except (OSError, ValueError, KeyError): + return InstallInspection(pending=pending, healthy=False) + healthy = arch == "x86_64" and verified + return InstallInspection( + pending=pending, + healthy=healthy, + installed_version=str(version), + updated_confirmed=healthy, + previous_confirmed=healthy and not pending, + ) + + +async def acknowledge_stale_transaction(root: Path) -> None: + """经公共入口确认后归档旧 journal,不接受有 backup/ready 的回滚事务。""" + state = await inspect_stale_transaction(root) + if not (state.pending and state.healthy and state.updated_confirmed): + raise script_install.InstallUnsafeError("旧 BAAH 安装事务无法确认完整") + directory = script_install.workspace(root) + marker = directory / "transaction.json" + snapshot = marker.read_bytes() + if not snapshot or len(snapshot) > 128 * 1024 * 1024: + raise script_install.InstallUnsafeError("旧 BAAH 事务内容无效") + await asyncio.to_thread(program_update.assert_idle, root) + if marker.is_symlink() or marker.read_bytes() != snapshot: + raise script_install.InstallUnsafeError("BAAH 事务记录已变化") + if any( + path.exists() or path.is_symlink() + for path in (directory / "previous", directory / "ready") + ): + raise script_install.InstallUnsafeError("BAAH 仍有可恢复的旧版目录") + archive = marker.with_name(f"baah-transaction-ack-{uuid4().hex}.json") + marker.replace(archive) + try: + await program_update.confirm_current_install(root) + except BaseException: + if not marker.exists() and archive.exists(): + archive.replace(marker) + raise From c29e087cc3f2882a5dc974468f8ced2b6ead25c4 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:28:24 +0100 Subject: [PATCH 08/19] feat(baah): send stale transaction recovery through #1916 and preserve incomplete backups --- app/task/BAAH/manager.py | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/app/task/BAAH/manager.py b/app/task/BAAH/manager.py index 16901b8974..ea55122975 100644 --- a/app/task/BAAH/manager.py +++ b/app/task/BAAH/manager.py @@ -53,7 +53,11 @@ from .AutoProxy import AutoProxyTask from .tools import program_update -from .tools.managed_recovery import BAAHInstallAdapter +from .tools.managed_recovery import ( + BAAHInstallAdapter, + acknowledge_stale_transaction, + inspect_stale_transaction, +) from app.services.script_install_backup import ManagedInstallBackup logger = get_logger("BAAH 调度器") @@ -153,11 +157,34 @@ async def _prepare_program_update(self) -> None: root, script_id=request.script_id, script_type="BAAH" ) # 原生事务未完成时不能通过清除 journal 绕过正在使用的备份。 - await script_install.run_worker( - script_install.recover_install, - root, - lambda: program_update.assert_idle(root), - ) + try: + await script_install.run_worker( + script_install.recover_install, + root, + lambda: program_update.assert_idle(root), + ) + except (script_install.InstallUnsafeError, OSError, ValueError): + marker = script_install.workspace(root) / "transaction.json" + if marker.is_symlink() or not marker.is_file(): + raise + + async def retry_native_rollback(): + await script_install.run_worker( + script_install.recover_install, + root, + lambda: program_update.assert_idle(root), + ) + + outcome = await auto_recover_script_install( + request, + install_root=root, + inspect=lambda: inspect_stale_transaction(root), + acknowledge=lambda: acknowledge_stale_transaction(root), + reinstall=retry_native_rollback, + ) + await self.script_info.set_log( + f"BAAH 原生安装事务由公共入口恢复:{outcome}" + ) await asyncio.to_thread(program_update.assert_idle, root) if backup.read() is not None: From c1b78b0f901338a5eeff5a8b982a2cda413fa5fe Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:29:04 +0100 Subject: [PATCH 09/19] test(baah): replace recovery modal assertions with automatic #1916 repair and trusted journal checks --- tests/task/test_baah_program_update.py | 68 +++++++++++++++++++++----- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/tests/task/test_baah_program_update.py b/tests/task/test_baah_program_update.py index 5775562724..a89aac7bc4 100644 --- a/tests/task/test_baah_program_update.py +++ b/tests/task/test_baah_program_update.py @@ -245,6 +245,9 @@ async def test_second_baah_config_waits_for_same_directory(installation, monkeyp Update=config.Update, ) instance.check_result = "Pass" + instance.script_info = SimpleNamespace( + script_id=str(uuid.uuid4()), set_log=AsyncMock() + ) # 第一个任务在整个用户运行期间独占目录;第二个任务要等待。 await one._prepare_program_update() @@ -314,11 +317,9 @@ async def test_repair_refuses_changed_receipt(installation): @pytest.mark.asyncio -async def test_manager_without_takeover_checks_receipt_integrity( +async def test_manager_without_takeover_repairs_corrupt_receipt_via_common_entry( installation, monkeypatch ): - from app.task.BAAH.tools import install_recovery - root, config, _, _ = installation prepared = await update.prepare_update(root, config) await update.apply_update(root, prepared) @@ -336,15 +337,58 @@ async def test_manager_without_takeover_checks_receipt_integrity( script_id=str(uuid.uuid4()), name="BAAH A", set_log=AsyncMock() ) instance.task_info = SimpleNamespace(task_id="task", mode="AutoProxy") - monkeypatch.setattr( - install_recovery, - "ask_install_recovery", - AsyncMock(side_effect=script_install.InstallUnsafeError("没有主界面")), - ) - with pytest.raises(script_install.InstallUnsafeError, match="没有主界面"): - await instance._prepare_program_update() - install_recovery.ask_install_recovery.assert_awaited_once() - assert (script_install.record_path(root, "installed.json")).is_file() + common = AsyncMock(return_value="reinstalled") + monkeypatch.setattr(manager, "auto_recover_script_install", common) + await instance._prepare_program_update() + common.assert_awaited_once() instance._install_access.close() if instance._directory_lock is not None: instance._directory_lock.release() + + +@pytest.mark.asyncio +async def test_baah_stale_journal_automatically_requires_verified_receipt( + installation, monkeypatch +): + from app.task.BAAH.tools.managed_recovery import inspect_stale_transaction + + root, config, _, _ = installation + candidate = await update.prepare_update(root, config) + await update.apply_update(root, candidate) + marker = script_install.workspace(root) / "transaction.json" + marker.write_bytes(b'{"state":"completed"}') + state = await inspect_stale_transaction(root) + assert state.pending and state.healthy and state.updated_confirmed + (root / "BAAH.exe").write_bytes(b"bad") + unhealthy = await inspect_stale_transaction(root) + assert unhealthy.pending and not unhealthy.healthy + + +@pytest.mark.asyncio +async def test_baah_corrupt_install_has_no_gui_dependency( + installation, monkeypatch +): + root, config, _, _ = installation + candidate = await update.prepare_update(root, config) + await update.apply_update(root, candidate) + (root / "BAAH.exe").unlink() + config.Update.TakeoverEnabled = False + manager_mock = object.__new__(manager.BAAHManager) + manager_mock._install_access = ExitStack() + manager_mock._directory_lock = None + manager_mock.script_config = SimpleNamespace( + Script=SimpleNamespace(BAAHPath=root / "BAAH.exe"), + Update=config.Update, + ) + manager_mock.script_info = SimpleNamespace( + script_id=str(uuid.uuid4()), name="BAAH", set_log=AsyncMock() + ) + manager_mock.task_info = SimpleNamespace(task_id="task", mode="AutoProxy") + monkeypatch.setattr( + manager, "auto_recover_script_install", AsyncMock(return_value="reinstalled") + ) + await manager_mock._prepare_program_update() + manager.auto_recover_script_install.assert_awaited_once() + manager_mock._install_access.close() + if manager_mock._directory_lock is not None: + manager_mock._directory_lock.release() From dc4a3811078d09701ce432da83ca663dd1380159 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:29:26 +0100 Subject: [PATCH 10/19] refactor(baah): remove legacy recovery modal, WebSocket protocol and frontend subscriptions --- app/core/ws/protocol.py | 3 - app/task/BAAH/tools/install_recovery.py | 90 ------------- frontend/src/App.vue | 2 - frontend/src/bootstrap/realtimeResidents.ts | 8 -- .../components/BAAHInstallRecoveryModal.vue | 120 ------------------ frontend/src/i18n/bootstrap/en-US.json | 14 -- frontend/src/i18n/bootstrap/ja-JP.json | 14 -- frontend/src/i18n/bootstrap/zh-CN.json | 14 -- .../src/services/baahInstallRecovery.test.ts | 86 ------------- frontend/src/services/baahInstallRecovery.ts | 86 ------------- frontend/src/services/websocket/types.ts | 20 --- 11 files changed, 457 deletions(-) delete mode 100644 app/task/BAAH/tools/install_recovery.py delete mode 100644 frontend/src/components/BAAHInstallRecoveryModal.vue delete mode 100644 frontend/src/services/baahInstallRecovery.test.ts delete mode 100644 frontend/src/services/baahInstallRecovery.ts diff --git a/app/core/ws/protocol.py b/app/core/ws/protocol.py index 75ebfed8eb..045b74b18b 100644 --- a/app/core/ws/protocol.py +++ b/app/core/ws/protocol.py @@ -65,9 +65,6 @@ # 逐脚本、逐轮的执行边界(id=TaskManager),不随任务快照重放。 TASK_SCRIPT_STARTED = "task.script.started" TASK_SCRIPT_FINISHED = "task.script.finished" -BAAH_INSTALL_PROMPT = "baah.install.prompt" -BAAH_INSTALL_PROMPT_CLOSED = "baah.install.prompt.closed" -BAAH_INSTALL_DECISION = "baah.install.decision" # 应用生命周期与电源(id=Main) BACKEND_SHUTDOWN_READY = "backend.shutdown.ready" diff --git a/app/task/BAAH/tools/install_recovery.py b/app/task/BAAH/tools/install_recovery.py deleted file mode 100644 index 76e55e2534..0000000000 --- a/app/task/BAAH/tools/install_recovery.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright © 2025-2026 AUTO-MAS Team -# SPDX-License-Identifier: AGPL-3.0-or-later -"""BAAH 损坏安装恢复:只能明确重装,绝不忽略未知文件完整性。""" - -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 . import program_update - -_TIMEOUT_SECONDS = 120 - - -async def ask_install_recovery( - task_id: str, - root: Path, - *, - managed_name: str, - takeover_enabled: bool, -) -> tuple[str, bytes]: - receipt = script_install.record_path(root, "installed.json") - if receipt.is_symlink() or not receipt.is_file(): - raise script_install.InstallUnsafeError( - "BAAH 校验回执缺失或不安全,拒绝自动修复" - ) - snapshot = await asyncio.to_thread(receipt.read_bytes) - program_update.verify_recovery_receipt(root, snapshot) - try: - current = str(await asyncio.to_thread(program_update.read_version, root)) - except (OSError, ValueError, TypeError, KeyError): - current = "未知" - - request_id = uuid4().hex - future: asyncio.Future[str] = asyncio.get_running_loop().create_future() - - def receive(envelope) -> None: - data = envelope.data - if ( - data.get("taskId") == task_id - and data.get("requestId") == request_id - and data.get("action") in ("retry", "stop") - and not future.done() - ): - future.set_result(data["action"]) - - unregister = Dispatcher.register( - protocol.ID_TASK_MANAGER, protocol.BAAH_INSTALL_DECISION, receive - ) - try: - sent = await Publisher.send( - id=protocol.ID_TASK_MANAGER, - type=protocol.BAAH_INSTALL_PROMPT, - data={ - "requestId": request_id, - "taskId": task_id, - "managedName": managed_name, - "installPath": str(root.resolve()), - "takeoverEnabled": takeover_enabled, - "installedVersion": current, - "targetVersion": "官方通道最新完整包", - }, - ) - if not sent: - raise script_install.InstallUnsafeError( - "BAAH 安装需要修复,请打开 MAS 主界面选择重新安装" - ) - try: - action = await asyncio.wait_for(future, _TIMEOUT_SECONDS) - except TimeoutError as error: - raise script_install.InstallUnsafeError( - "BAAH 修复确认超时,未修改安装文件" - ) from error - if action == "stop": - raise script_install.InstallUnsafeError("用户取消 BAAH 安装修复") - program_update.verify_recovery_receipt(root, snapshot) - return action, snapshot - finally: - unregister() - try: - await Publisher.send( - id=protocol.ID_TASK_MANAGER, - type=protocol.BAAH_INSTALL_PROMPT_CLOSED, - data={"requestId": request_id}, - ) - except Exception: - pass diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 93dbab9de4..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 BAAHInstallRecoveryModal from './components/BAAHInstallRecoveryModal.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 a8aa7e23b6..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 { - bootstrapBAAHInstallRecovery, - disposeBAAHInstallRecovery, -} from '@/services/baahInstallRecovery' let registered = false @@ -75,10 +71,6 @@ export function bootstrapRealtimeResidents(): void { bootstrap: bootstrapVirtualDisplayPrompt, dispose: disposeVirtualDisplayPrompt, }) - registerResidentResource('baah-install-recovery', { - bootstrap: bootstrapBAAHInstallRecovery, - dispose: disposeBAAHInstallRecovery, - }) registered = true } bootstrapResidentResources() diff --git a/frontend/src/components/BAAHInstallRecoveryModal.vue b/frontend/src/components/BAAHInstallRecoveryModal.vue deleted file mode 100644 index ef9b547f87..0000000000 --- a/frontend/src/components/BAAHInstallRecoveryModal.vue +++ /dev/null @@ -1,120 +0,0 @@ - - - - - diff --git a/frontend/src/i18n/bootstrap/en-US.json b/frontend/src/i18n/bootstrap/en-US.json index 6b5e27b629..f5abbfe3aa 100644 --- a/frontend/src/i18n/bootstrap/en-US.json +++ b/frontend/src/i18n/bootstrap/en-US.json @@ -3,20 +3,6 @@ "languageSaveFailed": "Failed to save the language setting; reverted to the previous one." }, "comp": { - "baahRecoveryCurrent": "Installed version", - "baahRecoveryDisabled": "Disabled", - "baahRecoveryEnabled": "Enabled", - "baahRecoveryInstallPath": "Installation folder", - "baahRecoveryManagedName": "Managed profile", - "baahRecoveryMessage": "The installed BAAH program may be damaged. Confirm the profile and folder before repairing with a verified full official package, or cancel this run.", - "baahRecoveryRetry": "Download and repair", - "baahRecoveryRisk": "Only a verified full official package can repair this installation. You cannot bypass integrity checks or delete transaction records. Close BAAH and its updater first.", - "baahRecoverySendFailed": "Unable to send the repair choice. Check backend connectivity.", - "baahRecoveryStop": "Cancel this run", - "baahRecoveryTakeover": "Managed updates", - "baahRecoveryTarget": "Repair source", - "baahRecoveryTitle": "BAAH installation failed integrity checks", - "baahRecoveryUnknown": "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 9f0ef9426d..2b4ed31807 100644 --- a/frontend/src/i18n/bootstrap/ja-JP.json +++ b/frontend/src/i18n/bootstrap/ja-JP.json @@ -3,20 +3,6 @@ "languageSaveFailed": "言語設定の保存に失敗しました。元の言語に戻しました。" }, "comp": { - "baahRecoveryCurrent": "現在のバージョン", - "baahRecoveryDisabled": "無効", - "baahRecoveryEnabled": "有効", - "baahRecoveryInstallPath": "インストール先", - "baahRecoveryManagedName": "管理設定名", - "baahRecoveryMessage": "BAAH が破損している可能性があります。対象設定とフォルダーを確認し、公式完全パッケージで修復するか中止してください。", - "baahRecoveryRetry": "完全パッケージで修復", - "baahRecoveryRisk": "公式の完全パッケージによる再インストールのみ可能です。整合性チェックをスキップしたり、トランザクション記録を削除しないでください。BAAH を終了してください。", - "baahRecoverySendFailed": "修復指示を送信できません。接続を確認してください。", - "baahRecoveryStop": "今回のタスクを中止", - "baahRecoveryTakeover": "自動更新", - "baahRecoveryTarget": "修復ソース", - "baahRecoveryTitle": "BAAH のインストール整合性チェックに失敗", - "baahRecoveryUnknown": "不明", "backendAboutUpdateWhich": "バックエンドを更新します。更新にはバックエンドの再起動が必要で、実行中のタスクは中断されます。続行しますか?", "backendStarting": "バックエンドを起動しています", "backendUpdateAvailableClick": "バックエンドの更新があります。クリックして更新してください", diff --git a/frontend/src/i18n/bootstrap/zh-CN.json b/frontend/src/i18n/bootstrap/zh-CN.json index 54912214d6..eb4f5910e6 100644 --- a/frontend/src/i18n/bootstrap/zh-CN.json +++ b/frontend/src/i18n/bootstrap/zh-CN.json @@ -3,20 +3,6 @@ "languageSaveFailed": "语言设置保存失败,已恢复原语言" }, "comp": { - "baahRecoveryCurrent": "当前版本", - "baahRecoveryDisabled": "已关闭", - "baahRecoveryEnabled": "已开启", - "baahRecoveryInstallPath": "安装目录", - "baahRecoveryManagedName": "托管名称", - "baahRecoveryMessage": "BAAH 可能在更新中受损。请确认托管名称和目录,选择安装官方完整包修复,或取消本轮任务。", - "baahRecoveryRetry": "下载完整包并修复", - "baahRecoveryRisk": "本次只支持重新校验并安装完整官方包,不允许跳过损坏校验或手工删除事务记录。请先退出 BAAH 和更新器。", - "baahRecoverySendFailed": "无法发送恢复指令,请检查后端连接。", - "baahRecoveryStop": "取消本次任务", - "baahRecoveryTakeover": "托管更新", - "baahRecoveryTarget": "修复来源", - "baahRecoveryTitle": "BAAH 安装文件未通过完整性检查", - "baahRecoveryUnknown": "未知", "backendAboutUpdateWhich": "即将更新后端,这需要重启后端程序,你当前正在运行的任务将会被中断。确认继续?", "backendStarting": "正在启动后端", "backendUpdateAvailableClick": "检测到后端更新,点击以更新后端", diff --git a/frontend/src/services/baahInstallRecovery.test.ts b/frontend/src/services/baahInstallRecovery.test.ts deleted file mode 100644 index 5bb6a57d9f..0000000000 --- a/frontend/src/services/baahInstallRecovery.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { afterEach, expect, it, vi } from 'vite-plus/test' - -const boundary = vi.hoisted(() => ({ - subscriptions: new Map }) => void>(), - connected: [] as Array<() => void>, - disconnected: [] as Array<() => void>, - send: vi.fn(() => true), -})) - -vi.mock('@/services/websocket/subscriptions', () => ({ - subscribe: ( - criteria: { id: string; type: string }, - handler: (message: { data: Record }) => void - ) => { - const key = `${criteria.id}:${criteria.type}` - boundary.subscriptions.set(key, handler) - return key - }, - unsubscribe: (id: string) => { - boundary.subscriptions.delete(id) - }, -})) - -vi.mock('@/services/websocket/connection', () => ({ - send: boundary.send, - onConnected: (listener: () => void) => { - boundary.connected.push(listener) - return () => { - const at = boundary.connected.indexOf(listener) - if (at >= 0) boundary.connected.splice(at, 1) - } - }, - onDisconnected: (listener: () => void) => { - boundary.disconnected.push(listener) - return () => { - const at = boundary.disconnected.indexOf(listener) - if (at >= 0) boundary.disconnected.splice(at, 1) - } - }, -})) - -afterEach(() => { - vi.unstubAllGlobals() - boundary.subscriptions.clear() - boundary.connected.length = 0 - boundary.disconnected.length = 0 - boundary.send.mockClear() -}) - -it('drops stale BAAH install prompt after active websocket reconnect without close event', async () => { - vi.stubGlobal('window', { - electronAPI: { getLogger: () => ({ warn: vi.fn() }) }, - }) - const module = await import('./baahInstallRecovery') - module.bootstrapBAAHInstallRecovery() - const prompt = boundary.subscriptions.get('TaskManager:baah.install.prompt') - expect(prompt).toBeTruthy() - - prompt!({ - data: { - requestId: 'old-request', - taskId: 'task-one', - managedName: 'Example', - installPath: '/sandbox/one', - }, - }) - expect(module.baahInstallPrompts.value).toHaveLength(1) - expect(boundary.connected).toHaveLength(1) - - // Simulate reconnectNow successful: stale socket close was intentionally ignored, - // and backend's final prompt-closed frame never arrived. - boundary.connected[0]() - expect(module.baahInstallPrompts.value).toHaveLength(0) - expect(module.baahInstallDecisionPending.value).toBeNull() - expect(module.respondToBAAHInstallRecovery('retry')).toBe(false) - expect(boundary.send).not.toHaveBeenCalled() - - // A new prompt from the new transport remains actionable. - prompt!({ data: { requestId: 'new-request', taskId: 'task-two' } }) - expect(module.respondToBAAHInstallRecovery('stop')).toBe(true) - expect(boundary.send).toHaveBeenCalledOnce() - - module.disposeBAAHInstallRecovery() - expect(boundary.connected).toHaveLength(0) - expect(boundary.disconnected).toHaveLength(0) -}) diff --git a/frontend/src/services/baahInstallRecovery.ts b/frontend/src/services/baahInstallRecovery.ts deleted file mode 100644 index e7eb033c67..0000000000 --- a/frontend/src/services/baahInstallRecovery.ts +++ /dev/null @@ -1,86 +0,0 @@ -// 应用级 BAAH 安装恢复询问:首条主连接之前常驻订阅,避免路由切换漏掉请求。 -// 一次只能处理一个弹窗,后续请求排队;断线或后端关闭请求时立即清除旧选择。 - -import { ref } from 'vue' -import { onConnected, onDisconnected, send } from '@/services/websocket/connection' -import { subscribe, unsubscribe } from '@/services/websocket/subscriptions' -import { - WS_BAAH_INSTALL_DECISION, - WS_BAAH_INSTALL_PROMPT, - WS_BAAH_INSTALL_PROMPT_CLOSED, - WS_ID_TASK_MANAGER, - type WSBAAHInstallPromptData, -} from '@/services/websocket/types' - -const logger = window.electronAPI.getLogger('BAAH安装恢复') -export const baahInstallPrompts = ref([]) -export const baahInstallDecisionPending = ref(null) - -let subscriptionIds: string[] = [] -let disposeDisconnect: (() => void) | null = null -let disposeConnect: (() => void) | null = null - -const forgetRequest = (requestId: string) => { - baahInstallPrompts.value = baahInstallPrompts.value.filter( - prompt => prompt.requestId !== requestId - ) - if (baahInstallDecisionPending.value === requestId) { - baahInstallDecisionPending.value = null - } -} - -export function bootstrapBAAHInstallRecovery(): void { - if (subscriptionIds.length) return - subscriptionIds = [ - subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BAAH_INSTALL_PROMPT }, message => { - const prompt = message.data - if ( - !prompt.requestId || - !prompt.taskId || - baahInstallPrompts.value.some(previous => previous.requestId === prompt.requestId) - ) { - return - } - baahInstallPrompts.value.push(prompt) - }), - subscribe({ id: WS_ID_TASK_MANAGER, type: WS_BAAH_INSTALL_PROMPT_CLOSED }, message => { - forgetRequest(message.data.requestId) - }), - ] - disposeDisconnect = onDisconnected(() => { - baahInstallPrompts.value = [] - baahInstallDecisionPending.value = null - }) - // reconnectNow 在切换连接代次时不会触发 onDisconnected。 - // 新连接建立后,旧连接的关闭帧可能已丢失;绝不可沿用旧确认请求。 - disposeConnect = onConnected(() => { - baahInstallPrompts.value = [] - baahInstallDecisionPending.value = null - }) -} - -export function respondToBAAHInstallRecovery(action: 'retry' | 'stop'): boolean { - const prompt = baahInstallPrompts.value[0] - if (!prompt || baahInstallDecisionPending.value) return false - const sent = send(WS_ID_TASK_MANAGER, WS_BAAH_INSTALL_DECISION, { - requestId: prompt.requestId, - taskId: prompt.taskId, - action, - }) - if (!sent) { - logger.warn('未能发送 BAAH 安装恢复选择') - return false - } - baahInstallDecisionPending.value = prompt.requestId - return true -} - -export function disposeBAAHInstallRecovery(): void { - for (const id of subscriptionIds.splice(0)) unsubscribe(id) - disposeDisconnect?.() - disposeDisconnect = null - disposeConnect?.() - disposeConnect = null - baahInstallPrompts.value = [] - baahInstallDecisionPending.value = null -} diff --git a/frontend/src/services/websocket/types.ts b/frontend/src/services/websocket/types.ts index c3dbc2c8e5..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_BAAH_INSTALL_PROMPT = 'baah.install.prompt' -export const WS_BAAH_INSTALL_PROMPT_CLOSED = 'baah.install.prompt.closed' -export const WS_BAAH_INSTALL_DECISION = 'baah.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' } -/** BAAH 已有 MAS 校验回执但安装损坏时的用户修复请求 */ -export interface WSBAAHInstallPromptData { - requestId: string - taskId: string - managedName: string - installPath: string - takeoverEnabled: boolean - installedVersion: string - targetVersion: string -} - -export interface WSBAAHInstallPromptClosedData { - 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_BAAH_INSTALL_PROMPT]: WSBAAHInstallPromptData - [WS_BAAH_INSTALL_PROMPT_CLOSED]: WSBAAHInstallPromptClosedData [WS_BACKEND_SHUTDOWN_READY]: WSEmptyData [WS_FRONTEND_CLOSE_REQUESTED]: FrontendCloseRequest [WS_FRONTEND_RESTART_REQUESTED]: FrontendCloseRequest From 1a3ec20b3f07c471d77ccf6e0d1687ea2ba573ff Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:32:39 +0100 Subject: [PATCH 11/19] style(baah): apply latest Ruff findings --- app/task/BAAH/tools/managed_recovery.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/task/BAAH/tools/managed_recovery.py b/app/task/BAAH/tools/managed_recovery.py index ce219612dc..5b64a6c84c 100644 --- a/app/task/BAAH/tools/managed_recovery.py +++ b/app/task/BAAH/tools/managed_recovery.py @@ -72,14 +72,15 @@ async def inspect(self) -> InstallInspection: healthy and ( (target is not None and version == target) - or (previous is not None and version is not None and version > previous) + or ( + previous is not None + and version is not None + and version > previous + ) ) ), previous_confirmed=bool( - healthy - and not pending - and previous is not None - and version == previous + healthy and not pending and previous is not None and version == previous ), ) From 94a0c2a23a81ae53fd881c48a14a86ee75a0268b Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:32:42 +0100 Subject: [PATCH 12/19] style(baah): apply latest Ruff findings --- tests/task/test_baah_program_update.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/task/test_baah_program_update.py b/tests/task/test_baah_program_update.py index a89aac7bc4..6cf0bac3ca 100644 --- a/tests/task/test_baah_program_update.py +++ b/tests/task/test_baah_program_update.py @@ -365,9 +365,7 @@ async def test_baah_stale_journal_automatically_requires_verified_receipt( @pytest.mark.asyncio -async def test_baah_corrupt_install_has_no_gui_dependency( - installation, monkeypatch -): +async def test_baah_corrupt_install_has_no_gui_dependency(installation, monkeypatch): root, config, _, _ = installation candidate = await update.prepare_update(root, config) await update.apply_update(root, candidate) From 0f0d978f2bc5285ecc827b785c76cd0d44003924 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:41:03 +0100 Subject: [PATCH 13/19] test(baah): create native transaction workspace before writing pending journal --- tests/task/test_baah_program_update.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/task/test_baah_program_update.py b/tests/task/test_baah_program_update.py index 6cf0bac3ca..8eef08c8ed 100644 --- a/tests/task/test_baah_program_update.py +++ b/tests/task/test_baah_program_update.py @@ -356,6 +356,7 @@ async def test_baah_stale_journal_automatically_requires_verified_receipt( candidate = await update.prepare_update(root, config) await update.apply_update(root, candidate) marker = script_install.workspace(root) / "transaction.json" + marker.parent.mkdir(parents=True, exist_ok=True) marker.write_bytes(b'{"state":"completed"}') state = await inspect_stale_transaction(root) assert state.pending and state.healthy and state.updated_confirmed From 1501556331220ea1ddaa3247fb4f63319feeefca Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:50:19 +0100 Subject: [PATCH 14/19] fix(baah): snapshot and restore MAS receipts outside install root alongside #1916 rollback; keep read-only ADB whitelist --- app/task/BAAH/tools/managed_recovery.py | 103 +++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/app/task/BAAH/tools/managed_recovery.py b/app/task/BAAH/tools/managed_recovery.py index 5b64a6c84c..1aa214f42d 100644 --- a/app/task/BAAH/tools/managed_recovery.py +++ b/app/task/BAAH/tools/managed_recovery.py @@ -3,6 +3,9 @@ """BAAH 官方完整包恢复适配器;公共入口负责恢复策略和任务末尾报告。""" import asyncio +import base64 +import hashlib +import os from pathlib import Path from uuid import uuid4 @@ -11,6 +14,7 @@ from app.core.script_update_recovery import InstallInspection from app.services import script_install from app.services.script_install_backup import ManagedInstallBackup +from app.utils import io as io_utils from . import program_update @@ -33,7 +37,10 @@ async def ensure_idle(self) -> None: await asyncio.to_thread(program_update.assert_idle, self.root) async def inspect(self) -> InstallInspection: - await self.ensure_idle() + # 只读健康检查允许 BAAH 自带 adb server 持续运行。 + await asyncio.to_thread( + program_update.assert_idle, self.root, allow_background_adb=True + ) pending_file = script_install.workspace(self.root) / "transaction.json" if pending_file.is_symlink(): raise script_install.InstallUnsafeError("BAAH 原生事务标记为链接") @@ -105,7 +112,8 @@ async def install(self) -> None: async def after_rollback(self) -> None: await self.ensure_idle() - # 如果安装回执无法匹配已恢复旧版,则不能宣称回滚完成并启动任务。 + await asyncio.to_thread(restore_original_receipts, self.root) + # 回退前的原 MAS 收据已同步恢复,仍要重新验证目录哈希。 await program_update.confirm_current_install(self.root) @@ -170,3 +178,94 @@ async def acknowledge_stale_transaction(root: Path) -> None: if not marker.exists() and archive.exists(): archive.replace(marker) raise + + +_RECEIPT_NAMES = ("installed.json", "baah-version.json") +_RECEIPT_BACKUP = "baah-managed-receipts.json" + + +def receipt_backup_path(root: Path) -> Path: + return script_install.workspace(root) / _RECEIPT_BACKUP + + +def _read_receipt_backup(root: Path) -> dict: + marker = receipt_backup_path(root) + if marker.is_symlink() or not marker.is_file(): + raise script_install.InstallUnsafeError("BAAH 更新前外置收据备份缺失或被替换") + data = io_utils.read_dict_file(marker) + if ( + data.get("root") != str(root.resolve()) + or data.get("schema") != 1 + or not isinstance(data.get("receipts"), dict) + or set(data["receipts"]) != set(_RECEIPT_NAMES) + ): + raise script_install.InstallUnsafeError("BAAH 外置收据备份身份不匹配") + return data["receipts"] + + +def snapshot_original_receipts(root: Path) -> None: + """必须在公共备份创建/任何原生安装写盘之前调用,且仅允许单次建立。""" + marker = receipt_backup_path(root) + if marker.exists() or marker.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 尚有未结算的外置收据备份") + receipts = {} + for name in _RECEIPT_NAMES: + path = script_install.record_path(root, name) + if path.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 收据目录包含链接") + content = path.read_bytes() if path.is_file() else None + if content is not None and len(content) > 32 * 1024 * 1024: + raise script_install.InstallUnsafeError("BAAH 原安装收据异常大") + receipts[name] = ( + None + if content is None + else { + "data": base64.b64encode(content).decode("ascii"), + "sha256": hashlib.sha256(content).hexdigest(), + } + ) + marker.parent.mkdir(parents=True, exist_ok=True) + io_utils.write_file( + marker, + { + "schema": 1, + "root": str(root.resolve()), + "receipts": receipts, + }, + ) + + +def restore_original_receipts(root: Path) -> None: + """公共备份恢复原程序目录之后,再恢复 MAS 自有的两份外置收据。""" + receipts = _read_receipt_backup(root) + for name in _RECEIPT_NAMES: + path = script_install.record_path(root, name) + if path.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 收据目标是符号链接") + entry = receipts[name] + if entry is None: + path.unlink(missing_ok=True) + continue + try: + content = base64.b64decode(entry["data"], validate=True) + if hashlib.sha256(content).hexdigest() != entry["sha256"]: + raise ValueError("digest mismatch") + except (KeyError, TypeError, ValueError) as error: + raise script_install.InstallUnsafeError( + "BAAH 更新前原始收据校验失败,拒绝回滚" + ) from error + path.parent.mkdir(parents=True, exist_ok=True) + staged = path.with_name(f"{path.name}.restore-{os.getpid()}.tmp") + if staged.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 收据暂存路径不安全") + staged.write_bytes(content) + os.replace(staged, path) + + +def cleanup_original_receipts(root: Path) -> None: + marker = receipt_backup_path(root) + if marker.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 备份文件被重定向") + if marker.exists(): + _read_receipt_backup(root) + marker.unlink() From 1cbfb59f2bfb54f6778b96aae30ab312aad0028a Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:50:43 +0100 Subject: [PATCH 15/19] fix(baah): stage external receipt backup before public install and settle only when managed snapshot is complete; allow read-only adb --- app/task/BAAH/manager.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/task/BAAH/manager.py b/app/task/BAAH/manager.py index ea55122975..c265ca343b 100644 --- a/app/task/BAAH/manager.py +++ b/app/task/BAAH/manager.py @@ -57,6 +57,9 @@ BAAHInstallAdapter, acknowledge_stale_transaction, inspect_stale_transaction, + snapshot_original_receipts, + cleanup_original_receipts, + receipt_backup_path, ) from app.services.script_install_backup import ManagedInstallBackup @@ -185,7 +188,10 @@ async def retry_native_rollback(): await self.script_info.set_log( f"BAAH 原生安装事务由公共入口恢复:{outcome}" ) - await asyncio.to_thread(program_update.assert_idle, root) + # 检查旧安装不写目录,可以保留 BAAH 内置的 adb server。 + await asyncio.to_thread( + program_update.assert_idle, root, allow_background_adb=True + ) if backup.read() is not None: adapter = BAAHInstallAdapter(root, self.script_config) @@ -198,12 +204,15 @@ async def retry_native_rollback(): ensure_idle=adapter.ensure_idle, after_rollback=adapter.after_rollback, ) + await asyncio.to_thread(cleanup_original_receipts, root) await self.script_info.set_log(f"BAAH 上次安装恢复:{result}") self.check_result = "Pass" return try: await program_update.confirm_current_install(root) + if receipt_backup_path(root).exists(): + await asyncio.to_thread(cleanup_original_receipts, root) except (script_install.InstallUnsafeError, OSError, ValueError) as error: # 损坏本体只能在已核验的 MAS 安装身份下自动完整重装。 receipt = script_install.record_path(root, "installed.json") @@ -257,6 +266,7 @@ async def prepare(): adapter = BAAHInstallAdapter( root, self.script_config, prepared=candidate ) + await asyncio.to_thread(snapshot_original_receipts, root) try: result = await run_managed_script_install( request, @@ -271,6 +281,8 @@ async def prepare(): await adapter.ensure_idle() if candidate.archive.parent.exists(): shutil.rmtree(candidate.archive.parent) + if backup.read() is None: + await asyncio.to_thread(cleanup_original_receipts, root) else: await program_update.confirm_current_install(root) result = candidate or "BAAH 当前无需更新" From d45ff86f2cdd33916f17ccc31e81d1cd926f3ac7 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:06:23 +0100 Subject: [PATCH 16/19] fix(baah): register receipt snapshot/restore/cleanup workers with installation lock across cancellation --- app/task/BAAH/tools/managed_recovery.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app/task/BAAH/tools/managed_recovery.py b/app/task/BAAH/tools/managed_recovery.py index 1aa214f42d..161cfb4e28 100644 --- a/app/task/BAAH/tools/managed_recovery.py +++ b/app/task/BAAH/tools/managed_recovery.py @@ -6,6 +6,7 @@ import base64 import hashlib import os +import threading from pathlib import Path from uuid import uuid4 @@ -112,7 +113,8 @@ async def install(self) -> None: async def after_rollback(self) -> None: await self.ensure_idle() - await asyncio.to_thread(restore_original_receipts, self.root) + # 注册到安装访问生命周期;取消不会让外置收据线程越过目录锁。 + await script_install.run_worker(restore_original_receipts, self.root) # 回退前的原 MAS 收据已同步恢复,仍要重新验证目录哈希。 await program_update.confirm_current_install(self.root) @@ -203,8 +205,10 @@ def _read_receipt_backup(root: Path) -> dict: return data["receipts"] -def snapshot_original_receipts(root: Path) -> None: - """必须在公共备份创建/任何原生安装写盘之前调用,且仅允许单次建立。""" +def snapshot_original_receipts(root: Path, cancelled: threading.Event) -> None: + """公共备份创建前快照收据;文件线程归 installation_access 管理。""" + if cancelled.is_set(): + raise InterruptedError("BAAH 收据快照已取消") marker = receipt_backup_path(root) if marker.exists() or marker.is_symlink(): raise script_install.InstallUnsafeError("BAAH 尚有未结算的外置收据备份") @@ -224,6 +228,8 @@ def snapshot_original_receipts(root: Path) -> None: "sha256": hashlib.sha256(content).hexdigest(), } ) + if cancelled.is_set(): + raise InterruptedError("BAAH 收据快照已取消") marker.parent.mkdir(parents=True, exist_ok=True) io_utils.write_file( marker, @@ -235,8 +241,10 @@ def snapshot_original_receipts(root: Path) -> None: ) -def restore_original_receipts(root: Path) -> None: - """公共备份恢复原程序目录之后,再恢复 MAS 自有的两份外置收据。""" +def restore_original_receipts(root: Path, cancelled: threading.Event) -> None: + """回滚时完整恢复外置收据;启动后即使取消也必须完成写盘。""" + # 线程会被 run_worker 登记;不在两张收据之间响应取消,避免半恢复。 + _ = cancelled receipts = _read_receipt_backup(root) for name in _RECEIPT_NAMES: path = script_install.record_path(root, name) @@ -262,10 +270,12 @@ def restore_original_receipts(root: Path) -> None: os.replace(staged, path) -def cleanup_original_receipts(root: Path) -> None: +def cleanup_original_receipts(root: Path, cancelled: threading.Event) -> None: marker = receipt_backup_path(root) if marker.is_symlink(): raise script_install.InstallUnsafeError("BAAH 备份文件被重定向") if marker.exists(): _read_receipt_backup(root) + if cancelled.is_set(): + raise InterruptedError("BAAH 收据备份清理已取消") marker.unlink() From 70c7da8413620f89a23ab498a248a624b02a1593 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:06:40 +0100 Subject: [PATCH 17/19] fix(baah): retain installation lock until all receipt workers finish after cancellation --- app/task/BAAH/manager.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/task/BAAH/manager.py b/app/task/BAAH/manager.py index c265ca343b..08048a8db2 100644 --- a/app/task/BAAH/manager.py +++ b/app/task/BAAH/manager.py @@ -204,7 +204,7 @@ async def retry_native_rollback(): ensure_idle=adapter.ensure_idle, after_rollback=adapter.after_rollback, ) - await asyncio.to_thread(cleanup_original_receipts, root) + await script_install.run_worker(cleanup_original_receipts, root) await self.script_info.set_log(f"BAAH 上次安装恢复:{result}") self.check_result = "Pass" return @@ -212,7 +212,7 @@ async def retry_native_rollback(): try: await program_update.confirm_current_install(root) if receipt_backup_path(root).exists(): - await asyncio.to_thread(cleanup_original_receipts, root) + await script_install.run_worker(cleanup_original_receipts, root) except (script_install.InstallUnsafeError, OSError, ValueError) as error: # 损坏本体只能在已核验的 MAS 安装身份下自动完整重装。 receipt = script_install.record_path(root, "installed.json") @@ -266,7 +266,7 @@ async def prepare(): adapter = BAAHInstallAdapter( root, self.script_config, prepared=candidate ) - await asyncio.to_thread(snapshot_original_receipts, root) + await script_install.run_worker(snapshot_original_receipts, root) try: result = await run_managed_script_install( request, @@ -282,7 +282,7 @@ async def prepare(): if candidate.archive.parent.exists(): shutil.rmtree(candidate.archive.parent) if backup.read() is None: - await asyncio.to_thread(cleanup_original_receipts, root) + await script_install.run_worker(cleanup_original_receipts, root) else: await program_update.confirm_current_install(root) result = candidate or "BAAH 当前无需更新" From 6d698b01752c97e0f93aaea1ebff05aab41e6963 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:07:02 +0100 Subject: [PATCH 18/19] test(baah): cancellation must retain cross-process installation access while receipt cleanup thread is alive --- tests/task/test_baah_program_update.py | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/task/test_baah_program_update.py b/tests/task/test_baah_program_update.py index 8eef08c8ed..6c8c0a99c4 100644 --- a/tests/task/test_baah_program_update.py +++ b/tests/task/test_baah_program_update.py @@ -1,5 +1,6 @@ """BAAH 下载/安装分离及配置保护;网络与真实程序进程在边界隔离。""" +import asyncio import hashlib import json import shutil @@ -391,3 +392,45 @@ async def test_baah_corrupt_install_has_no_gui_dependency(installation, monkeypa manager_mock._install_access.close() if manager_mock._directory_lock is not None: manager_mock._directory_lock.release() + +@pytest.mark.asyncio +async def test_receipt_cleanup_cancellation_retains_installation_lock( + installation, monkeypatch +): + from app.task.BAAH.tools import managed_recovery + + root, _, _, _ = installation + managed_recovery.snapshot_original_receipts(root, threading.Event()) + entered = threading.Event() + release = threading.Event() + original = managed_recovery.cleanup_original_receipts + + def slow_cleanup(install_root, cancelled): + entered.set() + if not release.wait(5): + raise RuntimeError("test cleanup did not resume") + return original(install_root, cancelled) + + with script_install.installation_access(root): + pending = asyncio.create_task( + script_install.run_worker(slow_cleanup, root) + ) + await asyncio.wait_for(asyncio.to_thread(entered.wait, 2), 3) + pending.cancel() + # Second cancellation is possible while the first is waiting for + # worker termination. run_worker must still retain installation ownership. + pending.cancel() + await asyncio.sleep(0) + with pytest.raises(script_install.InstallUnsafeError): + with script_install.installation_access(root): + pass + release.set() + with pytest.raises(asyncio.CancelledError): + await pending + await asyncio.sleep(0) + assert managed_recovery.receipt_backup_path(root).exists() + with script_install.installation_access(root): + await script_install.run_worker( + managed_recovery.cleanup_original_receipts, root + ) + assert not managed_recovery.receipt_backup_path(root).exists() From 3e6a29c7280f5532a7063886e721311728af8be9 Mon Sep 17 00:00:00 2001 From: youngslin <121206610+youngslin@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:19:29 +0100 Subject: [PATCH 19/19] style(baah): resolve Ruff format CI for cancellation regression test --- tests/task/test_baah_program_update.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/task/test_baah_program_update.py b/tests/task/test_baah_program_update.py index 6c8c0a99c4..f7126e5e09 100644 --- a/tests/task/test_baah_program_update.py +++ b/tests/task/test_baah_program_update.py @@ -393,6 +393,7 @@ async def test_baah_corrupt_install_has_no_gui_dependency(installation, monkeypa if manager_mock._directory_lock is not None: manager_mock._directory_lock.release() + @pytest.mark.asyncio async def test_receipt_cleanup_cancellation_retains_installation_lock( installation, monkeypatch @@ -412,9 +413,7 @@ def slow_cleanup(install_root, cancelled): return original(install_root, cancelled) with script_install.installation_access(root): - pending = asyncio.create_task( - script_install.run_worker(slow_cleanup, root) - ) + pending = asyncio.create_task(script_install.run_worker(slow_cleanup, root)) await asyncio.wait_for(asyncio.to_thread(entered.wait, 2), 3) pending.cancel() # Second cancellation is possible while the first is waiting for