Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
809d2f2
fix(maaend): add safe recovery for unconfirmed installation transactions
youngslin Nov 10, 2026
8a51c46
feat(maaend): add native transaction recovery modal and supported arc…
youngslin Nov 10, 2026
bec1a99
test(maaend): cover safe stale transactions and failed recovery prompts
youngslin Nov 10, 2026
0029bfe
fix(maaend): repair recovery manager indentation and modal markup
youngslin Nov 10, 2026
9ded87c
fix(maaend): retain MXU cache cleanup and respect disabled takeover r…
youngslin Nov 10, 2026
0d22c8f
fix(recovery): accept legitimate multi-file installation journals bey…
youngslin Nov 10, 2026
fe50ba9
feat(maaend): port #1910 verified official full-package repair identi…
youngslin Nov 10, 2026
529f1da
feat(maaend): provide #1916 native journal and trusted repair health …
youngslin Nov 10, 2026
62e4a4b
refactor(maaend): use #1916 for stale native transactions and verifie…
youngslin Nov 10, 2026
dfb3365
test(maaend): replace manual prompt suites with public health verific…
youngslin Nov 10, 2026
903e814
refactor(maaend): remove old recovery modals, websocket prompts and u…
youngslin Nov 10, 2026
c5ad1d0
style(maaend): apply CI Ruff formatting
youngslin Nov 10, 2026
b1dfc2c
style(maaend): apply CI Ruff formatting
youngslin Nov 10, 2026
4565c15
test(maaend): initialize native transaction directory in real health …
youngslin Nov 10, 2026
e4373e4
fix(maaend): separate native journal recovery from health gate and ex…
youngslin Nov 10, 2026
4fd71a2
fix(maaend): allow trusted damaged installations into #1916 repair, p…
youngslin Nov 10, 2026
13a0e40
test(maaend): exercise real manager and #1916 for damaged EXE and MXU…
youngslin Nov 10, 2026
b96db92
style(maaend): apply Ruff format for real manager recovery tests
youngslin Nov 10, 2026
9ee89de
docs(maaend): remove obsolete prompt wording and describe #1916 autom…
youngslin Nov 10, 2026
9fe9d34
docs(maaend): shorten automated recovery changelog to repository 50-c…
youngslin Nov 10, 2026
461cc4b
fix(maaend): safely discard precommit ready staging after verifying o…
youngslin Nov 10, 2026
e83fe72
test(maaend): reproduce cancelled after ready copy and verify manager…
youngslin Nov 10, 2026
5eb4ed2
style(maaend): satisfy Ruff format on interrupted ready-staging recov…
youngslin Nov 10, 2026
c5f82bb
fix(maaend): let authorized damaged installs resume after precommit s…
youngslin Nov 10, 2026
3467485
fix(maaend): authorize precommit repair cleanup only for managed Auto…
youngslin Nov 10, 2026
198e043
test(maaend): verify interrupted damaged EXE and MXU-upgrade repairs …
youngslin Nov 10, 2026
8dffb7f
style(maaend): apply Ruff format fixes for interrupted repair regression
youngslin Nov 10, 2026
026f14d
style(maaend): apply Ruff format fixes for interrupted repair regression
youngslin Nov 10, 2026
7bf59e3
style(maaend): apply Ruff format fixes for interrupted repair regression
youngslin Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 80 additions & 6 deletions app/task/MaaEnd/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,6 +56,7 @@
from .resource_loader import MaaEndResourceLoader, load_maaend_controller_protocol
from .ScriptConfig import ScriptConfigTask, maaend_config_base
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
Expand Down Expand Up @@ -335,6 +340,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
)
Expand All @@ -349,7 +359,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 配置。"""
Expand Down Expand Up @@ -485,17 +494,82 @@ 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,
journal = script_install.workspace(root) / "transaction.json"
if journal.is_symlink():
raise script_install.InstallUnsafeError("MaaEnd 原生事务路径不安全")
adapter = MaaEndInstallAdapter(
root,
lambda: script_update.assert_idle(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
try:
# 原生事务恢复不能以旧 MAS 收据校验作为前置门槛;
# MXU 独立更新后的收据可能旧,但本体仍可正确修复。
await adapter.recover_native()
except script_install.InstallUnsafeError:
if not journal.is_file():
raise
if journal.is_file():
# 只有无可回滚目录、原生安装完整且身份可信时,公共层
# 才允许清理过期 journal;否则重新尝试原生恢复。
outcome = await auto_recover_script_install(
request,
install_root=root,
inspect=adapter.inspect,
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
# 关闭接管或配置会话不主动覆盖用户自行维护的 MaaEnd。
# 仅在已开启托管且存在可信安装身份时才自动修复损坏的发行包。
identity = script_install.record_path(root, "program-identity.json")
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:
if state.pending:
raise script_install.InstallUnsafeError(
"MaaEnd 尚有未完成原生事务,不能重新安装"
)
outcome = await auto_recover_script_install(
request,
install_root=root,
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}"
)
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()
# 修复在原有资源预检之前,损坏的旧资源不能堵死恢复入口。
await self._prepare_program_update(root)
if recovered_automatically:
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 回执不应阻断后续配置或运行,也不重算哈希冒充可信。
Expand Down
213 changes: 213 additions & 0 deletions app/task/MaaEnd/tools/managed_recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Copyright © 2025-2026 AUTO-MAS Team
# SPDX-License-Identifier: AGPL-3.0-or-later
"""MaaEnd 安装状态与修复适配器。公共 #1916 负责决定恢复策略。"""

import asyncio
import shutil
import threading
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


def discard_uncommitted_staging(
root: Path,
takeover_enabled: bool,
allow_damaged_repair: 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)
# 优先核验健康旧版;损坏本体或旧收据失效时只能在已授权的
# 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 安装损坏且缺少可信 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)
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,
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)

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 recover_native(self) -> None:
"""仅恢复原生目录事务;安装健康验证留给后续公共恢复入口。"""
await script_install.run_worker(
script_install.recover_install,
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,
self.allow_damaged_repair,
)

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():
raise script_install.InstallUnsafeError(
"当前 MaaEnd 安装损坏且缺少可信架构身份,不能自动重装"
)
snapshot = identity.read_bytes()
script_update.verify_repair_identity(self.root, 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)
Loading
Loading