Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions app/models/config/script.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,10 @@ class RunFields(ConfigGroup):
Run: RunFields = Field(default_factory=RunFields)

class UpdateFields(ConfigGroup):
PackageMode: Literal["full", "incremental"] = Field(
default="full",
description="更新方式:全量或优先增量;增量依赖完整且匹配的基线,必要时回退全量。",
)
TakeoverMigrationVersion: Annotated[int, Field(ge=0, le=1)] = Field(
default=1, description="更新接管一次性迁移版本"
)
Expand Down Expand Up @@ -1283,6 +1287,10 @@ class UpdateFields(ConfigGroup):
default_factory=lambda: False,
description="运行前由 MAS 下载、校验并安装 MaaEnd 官方完整包;不暂停 MXU 自身的更新检查",
)
PackageMode: Literal["full", "incremental"] = Field(
default="full",
description="全量或优先增量;增量有风险,无可信缓存、匹配增量包或校验失败时回退全量",
)
Channel: Literal["stable", "beta"] = Field(
default_factory=lambda: "stable", description="MaaEnd 更新通道"
)
Expand Down Expand Up @@ -2634,6 +2642,10 @@ class TaskFields(ConfigGroup):
Task: TaskFields = Field(default_factory=TaskFields)

class UpdateFields(ConfigGroup):
PackageMode: Literal["full", "incremental"] = Field(
default="full",
description="更新方式:全量或优先增量;增量依赖完整且匹配的基线,必要时回退全量。",
)
AutoUpdateMode: Annotated[
Literal["Off", "BeforeRun", "AfterRun"],
legacy(
Expand Down
17 changes: 14 additions & 3 deletions app/task/MAA/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
from .constants import ARKNIGHTS_PACKAGE_NAME
from .ScriptConfig import ScriptConfigTask
from .tools import update_maa
from .tools.managed_recovery import MAAInstallAdapter
from .tools.backup_archive import (
archive_native_backup,
collect_native_files,
Expand All @@ -75,6 +74,7 @@
query_latest_maa_version,
)
from .tools.game_update import ensure_game_updated
from .tools.managed_recovery import MAAInstallAdapter
from .tools.resource_update import (
_run_write_thread,
acquire_resource_access_lock,
Expand Down Expand Up @@ -652,12 +652,22 @@ async def prefetch_script_update(self) -> None:
)
if isinstance(candidate, PreparedUpdate):
self._github_update_candidate = candidate
await prefetch_resources(root, github=True, cdk=None)
await prefetch_resources(
root,
github=True,
cdk=None,
incremental=config.Update.PackageMode == "incremental",
)
else:
enabled, cdk = resolve_takeover_credentials(config)
if enabled and cdk is not None:
await prefetch_maa_software_update(root, config)
await prefetch_resources(root, github=False, cdk=cdk)
await prefetch_resources(
root,
github=False,
cdk=cdk,
incremental=config.Update.PackageMode == "incremental",
)

async def discard_prefetched_update(self) -> None:
candidate = getattr(self, "_github_update_candidate", None)
Expand Down Expand Up @@ -855,6 +865,7 @@ async def resource_progress(line: str) -> None:
cdk=cdk,
github=github,
progress=resource_progress,
incremental=self.script_config.Update.PackageMode == "incremental",
)
return True

Expand Down
32 changes: 30 additions & 2 deletions app/task/MAA/tools/fluxdown_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class PreparedUpdate:
version: str
package: Path
digest: str
package_mode: str


def _digest(path: Path) -> str:
Expand Down Expand Up @@ -162,6 +163,8 @@ async def prepare_maa_fluxdown_update(
logger.info("MAA 已有待安装包,保留原生更新登记")
raise RuntimeError("MAA 仍有未安装的更新包,请先完成安装")
if prepared is not None:
if config.Update.PackageMode != prepared.package_mode:
raise RuntimeError("预下载后更新方式已变化,请重新检查更新")
if release != prepared.release:
raise RuntimeError("MAA 安装已变化,请重新检查更新")
destination = maa_root / prepared.package.name
Expand All @@ -184,6 +187,7 @@ async def prepare_maa_fluxdown_update(
return None
if (
read_installed_release(maa_root) != release
or config.Update.PackageMode != prepared.package_mode
or has_delegated_update_failure(maa_root)
or read_pending_update(maa_root) != pending_update
):
Expand All @@ -194,6 +198,7 @@ async def prepare_maa_fluxdown_update(
)
return prepared.version
current, arch = release
package_mode = config.Update.PackageMode
channel = config.Update.Channel
latest = await query_latest_maa_version(arch, channel, github=True)
target_version, current_version = parse_version(latest), parse_version(current)
Expand All @@ -209,7 +214,11 @@ async def prepare_maa_fluxdown_update(

# 只使用官方规范包名,不接受清单中的任意下载 URL。
tag = latest if latest.startswith("v") else f"v{latest}"
filename = await _select_package(current, latest, arch, channel)
filename = (
await _select_package(current, latest, arch, channel)
if package_mode == "incremental"
else f"MAA-{tag}-win-{arch}.zip"
)
if download_only:
cache = Path.cwd() / "data" / "maa_github_update"
cache.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -240,6 +249,21 @@ async def prepare_maa_fluxdown_update(
source="GitHub",
)
break
except httpx.HTTPStatusError as error:
if (
not filename.startswith("MAAComponent-OTA-")
or error.response.status_code != 404
or attempt == 2
):
raise
logger.warning("官方 OTA 包已不可用,回退全量更新")
part.unlink(missing_ok=True)
filename = f"MAA-{tag}-win-{arch}.zip"
destination = directory / filename
part = directory / (
f"{filename}.part" if download_only else f".mas-{filename}.part"
)
url = url.rsplit("/", 1)[0] + "/" + filename
except _service_update_fluxdown.FluxdownResumableError:
if attempt == 2:
raise
Expand All @@ -251,10 +275,14 @@ async def prepare_maa_fluxdown_update(
):
logger.info("下载期间更新来源已变更,保留文件但不登记安装")
return None
if config.Update.PackageMode != package_mode:
raise RuntimeError("下载期间更新方式已变化,请重新检查更新")
if download_only:
part.replace(destination)
digest = await _run_write_thread(_digest, destination)
candidate = PreparedUpdate(release, latest, destination, digest)
candidate = PreparedUpdate(
release, latest, destination, digest, package_mode
)
# The candidate manager now owns this verified directory.
retain_download = True
return candidate
Expand Down
96 changes: 82 additions & 14 deletions app/task/MAA/tools/resource_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,13 +353,23 @@
raise _QueryError(f"version_name 无法解析: {name}") from e


async def _request_resource_package(cdk: str) -> tuple[str, datetime | None]:
"""申请全量包,返回一次性地址与响应版本;版本损坏由调用方记账后报错。"""
async def _request_resource_package(

Check warning on line 356 in app/task/MAA/tools/resource_update.py

View workflow job for this annotation

GitHub Actions / 检查是否删掉了他人近期合入的代码

可能回退了 #1775 的改动

这里删掉了 #1775(几毛菜,2026-11-01 10:08 UTC 合入)加入的 3 行(原第 356-357、809 行)。如果不是有意修改,多半是同步分支时带回了旧版本的文件。
cdk: str, *, base_clock: datetime | None = None
) -> tuple[str, datetime | None, bool]:
"""申请更新包,返回地址、版本与增量标记;提供基线时允许增量。"""
try:
async with asyncio.timeout(_QUERY_TIMEOUT):
payload = await _service_update_transport.request_mirror_resource(
"MaaResource",
params={"cdk": cdk, "sp_id": _sp_id()},
params={
"cdk": cdk,
"sp_id": _sp_id(),
**(
{"current_version": _format_clock(base_clock)}
if base_clock
else {}
),
},
proxy=Config.proxy,
timeout=_QUERY_TIMEOUT,
)
Expand All @@ -372,18 +382,21 @@
url = payload["data"].get("url")
if not url:
raise _DownloadError("镜像酱未返回下载地址")
if payload["data"].get("update_type") != "full":
raise _DownloadError("镜像酱未返回全量资源包,跳过以避免多实例重复取包")
kind = payload["data"].get("update_type")
if kind not in {"full", "incremental"} or (
kind == "incremental" and base_clock is None
):
raise _DownloadError("镜像酱返回了不匹配的资源包类型")
try:
version = _parse_clock(str(payload["data"].get("version_name") or ""))
except ValueError:
version = None
return str(url), version
return str(url), version, kind == "incremental"


def _download_line(downloaded: int, total: int, speed: float) -> str:
"""下载进度行:有 Content-Length 时带 x/y MB,速度按量级选单位。"""
label = "下载 Mirror酱 全量资源包"
label = "下载 Mirror酱 资源更新包"
speed_text = (
f"{speed / _MB:.1f} MB/s" if speed >= _MB else f"{speed / 1024:.0f} KB/s"
)
Expand Down Expand Up @@ -415,7 +428,13 @@
raise _DownloadError(f"资源包下载失败: {e}") from e


def _build_stage(zip_path: Path, target: datetime, *, github: bool = False) -> None:
def _build_stage(
zip_path: Path,
target: datetime,
*,
github: bool = False,
base_clock: datetime | None = None,
) -> None:
"""解压 → 校验 → 单调把关 → 换位 → 核账 → manifest。线程内执行;任何
失败都抛 _DownloadError(进下载退避)。

Expand Down Expand Up @@ -465,7 +484,21 @@
f"资源包版本({_format_clock(new_clock)})旧于现有暂存"
f"({_format_clock(old_clock)}),拒绝替换"
)
expected = count_zip_entries(zip_path, prefix=prefix)
if base_clock is not None:
baseline = _load_valid_manifest()
if baseline is None or parse_iso(baseline.get("target")) != base_clock:
raise _DownloadError("资源增量基线已失效,必须重新下载全量包")
complete = stage_tmp / "complete"
shutil.copytree(
_STAGE_DIR / "resource",
complete,
ignore=shutil.ignore_patterns(".gitignore"),
)
merge_package_tree(root, complete)
root = complete
expected = len(resource_tree_hashes(root))
else:
expected = count_zip_entries(zip_path, prefix=prefix)
file_hashes = resource_tree_hashes(root)
_MANIFEST_FILE.unlink(missing_ok=True)
if _STAGE_DIR.exists():
Comment on lines +490 to 504

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Discrepancy in counting .gitignore files during incremental stage validation causes _DownloadError and forces unexpected fallbacks to full downloads.
Severity: MEDIUM

Suggested Fix

Align file counting between expected and actual. Either ignore .gitignore files when calculating actual via rglob, or exclude .gitignore during copytree when building the incremental stage tree.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: app/task/MAA/tools/resource_update.py#L484-L500

Potential issue: In `_build_stage`, the expected file count for incremental packages is
calculated via `len(resource_tree_hashes(root))`, which ignores `.gitignore` files.
However, `actual` is calculated by counting all files in `_STAGE_DIR / 'resource'`,
including `.gitignore` files copied from the baseline tree during `copytree`. If
`.gitignore` files exist in the baseline directory, `actual` exceeds `expected`, raising
`_DownloadError` and causing incremental updates to fail and fall back to full package
downloads every time.

Did we get this right? 👍 / 👎 to inform future reviews.

Expand Down Expand Up @@ -503,7 +536,11 @@


async def _refresh_stage(
target: datetime, url: str, progress: _Progress | None = None
target: datetime,
url: str,
progress: _Progress | None = None,
*,
base_clock: datetime | None = None,
) -> None:
"""下载已取得地址的资源包并重建暂存;失败抛 _DownloadError。"""
await _stream_download(url, progress)
Expand All @@ -512,6 +549,7 @@
_build_stage,
zip_path=_STAGE_ZIP,
target=target,
base_clock=base_clock,
)
# zip 是可弃缓存,删不掉(AV 隔离等)也无妨——下次下载按 "wb" 截断重写
with suppress(OSError):
Expand Down Expand Up @@ -775,6 +813,7 @@
progress: _Progress | None,
cdk: str,
refund_cancelled: bool = False,
incremental: bool = False,
) -> dict[str, object] | None:
"""按版本每日限额与失败退避刷新缓存;state 由编排器持有并在此记账。"""
if (
Expand Down Expand Up @@ -806,15 +845,33 @@
attempt_version: str | None = None
try:
# 凭据被拒、尚未取得下载地址时不消耗当天取包次数;不同 CDK 独立退避。
url, actual = await _request_resource_package(cdk)
# 增量只作用于通过摘要验证的完整共享缓存,不使用任一安装目录作为多实例基线。
base_clock = parse_iso(stage.get("target")) if incremental and stage else None
if base_clock is None and incremental:
await _report(progress, "没有可信资源增量基线,回退全量资源包…")
url, actual, delta = await _request_resource_package(cdk, base_clock=base_clock)
attempt_stamp = datetime.now(timezone.utc).isoformat()
attempt_version = max(target, actual or target).isoformat()
state[attempt_field] = attempt_stamp
state[version_field] = attempt_version
_utils_io.write_file(_STATE_FILE, state)
if actual is None or actual < target:
raise _DownloadError("镜像酱资源包未达到已确认的目标版本,请稍后重试")
await _refresh_stage(target=actual, url=url, progress=progress)
try:
await _refresh_stage(
target=actual,
url=url,
progress=progress,
base_clock=base_clock if delta else None,
)
except _DownloadError:
if not delta:
raise
await _report(progress, "资源增量下载或校验失败,回退全量资源包…")
url, actual, _ = await _request_resource_package(cdk)
if actual is None or actual < target:
raise _DownloadError("回退的资源包未达到目标版本")
await _refresh_stage(target=actual, url=url, progress=progress)
return _load_manifest()
except asyncio.CancelledError:
# 队列撤销预取不应耗尽当日额度;普通下载失败仍按原规则记账。
Expand Down Expand Up @@ -958,9 +1015,13 @@
)


async def prefetch_resources(install: Path, *, github: bool, cdk: str | None) -> None:
async def prefetch_resources(
install: Path, *, github: bool, cdk: str | None, incremental: bool = False
) -> None:
"""共享 staging 的只下载边界,绝不分发到任何安装目录。"""
await prepare_install_resources(install, github=github, cdk=cdk, download_only=True)
await prepare_install_resources(
install, github=github, cdk=cdk, download_only=True, incremental=incremental
)


async def _run_update(cdk: str) -> None:
Expand Down Expand Up @@ -1053,6 +1114,7 @@
progress: _Progress | None = None,
download_only: bool = False,
recovery_snapshot: bytes | None = None,
incremental: bool = False,
) -> None:
"""本体安装之后、账号启动之前补齐资源;调用方已持有安装访问锁。

Expand Down Expand Up @@ -1119,6 +1181,11 @@
# never remove that file until it is safe to do so.
can_cleanup = prefetch_directory is not None
try:
if incremental:
await _report(
progress,
"GitHub 资源源未提供匹配增量包,本次使用全量资源包…",
)
await _report(progress, "启动前下载 GitHub 全量资源包…")
# 下载入口尚未接管前,不能清理上次留下的共享归档。
can_cleanup = True
Expand Down Expand Up @@ -1165,6 +1232,7 @@
progress=progress,
cdk=cdk,
refund_cancelled=download_only,
incremental=incremental,
)
if not _stage_reusable(stage, target):
raise RuntimeError(
Expand Down
Loading
Loading