From 87c0cadf4bc4ddcd9cc236423586a4572701b5b7 Mon Sep 17 00:00:00 2001 From: youngslin <2305731910@qq.com> Date: Fri, 13 Nov 2026 07:21:56 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(update):=20=E6=94=AF=E6=8C=81=20MAA=20?= =?UTF-8?q?=E4=B8=8E=20MFW=20=E5=AE=B6=E6=97=8F=E9=80=89=E6=8B=A9=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/config/script.py | 8 + app/task/MAA/manager.py | 17 +- app/task/MAA/tools/fluxdown_update.py | 32 ++- app/task/MAA/tools/resource_update.py | 92 ++++++-- app/task/MAA/tools/software_package.py | 63 ++++-- app/task/MAA/tools/software_update.py | 145 ++++++++++--- app/task/MaaFW/api_service/update.py | 1 + app/task/MaaFW/embedded_manager.py | 8 +- .../tools/core/project_update/updater.py | 47 ++++- changelog.d/incremental-update-mode.feat.md | 2 + frontend/e2e/maa-update.pw.ts | 33 ++- .../electron/services/analyticsCoverage.md | 2 + frontend/src/api/models/M9AConfig_Update.ts | 4 + .../src/api/models/M9AConfig_UpdateInput.ts | 4 + frontend/src/api/models/MSSConfig_Update.ts | 4 + .../src/api/models/MSSConfig_UpdateInput.ts | 4 + frontend/src/api/models/MaaConfig_Update.ts | 4 + .../src/api/models/MaaConfig_UpdateInput.ts | 4 + frontend/src/api/models/MaaFWConfig_Update.ts | 4 + .../src/api/models/MaaFWConfig_UpdateInput.ts | 4 + .../src/composables/useMaaFWScriptConfig.ts | 1 + frontend/src/types/script.ts | 6 +- .../MaaFWScriptEdit/UpdateSettingsSection.vue | 14 +- .../Script/components/MaaUpdateSettings.vue | 10 + .../components/ProgramUpdateSettings.vue | 7 +- .../Script/components/UpdatePackageMode.vue | 30 +++ .../views/EditView/Script/maaScriptDraft.ts | 1 + res/i18n/en.json | 5 +- res/i18n/ja.json | 5 +- res/i18n/zh_CN.json | 5 +- tests/task/test_maa_fluxdown_update.py | 196 +++++++++++++++++- .../task/test_maa_version_download_budget.py | 62 +++++- 32 files changed, 739 insertions(+), 85 deletions(-) create mode 100644 changelog.d/incremental-update-mode.feat.md create mode 100644 frontend/src/views/EditView/Script/components/UpdatePackageMode.vue diff --git a/app/models/config/script.py b/app/models/config/script.py index c95f24f22e..f927e90936 100644 --- a/app/models/config/script.py +++ b/app/models/config/script.py @@ -661,6 +661,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="更新接管一次性迁移版本" ) @@ -2632,6 +2636,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( diff --git a/app/task/MAA/manager.py b/app/task/MAA/manager.py index 910d39cb5a..c46c18627e 100644 --- a/app/task/MAA/manager.py +++ b/app/task/MAA/manager.py @@ -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, @@ -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, @@ -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) @@ -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 diff --git a/app/task/MAA/tools/fluxdown_update.py b/app/task/MAA/tools/fluxdown_update.py index cef3c80c50..98970208cf 100644 --- a/app/task/MAA/tools/fluxdown_update.py +++ b/app/task/MAA/tools/fluxdown_update.py @@ -117,6 +117,7 @@ class PreparedUpdate: version: str package: Path digest: str + package_mode: str def _digest(path: Path) -> str: @@ -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 @@ -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 ): @@ -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) @@ -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) @@ -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 @@ -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 diff --git a/app/task/MAA/tools/resource_update.py b/app/task/MAA/tools/resource_update.py index 7aabc93f94..6590eeffa7 100644 --- a/app/task/MAA/tools/resource_update.py +++ b/app/task/MAA/tools/resource_update.py @@ -353,13 +353,23 @@ async def _fetch_latest_version(current: str) -> datetime: raise _QueryError(f"version_name 无法解析: {name}") from e -async def _request_resource_package(cdk: str) -> tuple[str, datetime | None]: - """申请全量包,返回一次性地址与响应版本;版本损坏由调用方记账后报错。""" +async def _request_resource_package( + 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, ) @@ -372,18 +382,21 @@ async def _request_resource_package(cdk: str) -> tuple[str, datetime | None]: 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" ) @@ -415,7 +428,13 @@ async def report_progress(downloaded: int, total: int, speed: float) -> None: 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(进下载退避)。 @@ -465,7 +484,17 @@ def _build_stage(zip_path: Path, target: datetime, *, github: bool = False) -> N 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) + 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(): @@ -503,7 +532,11 @@ def _build_stage(zip_path: Path, target: datetime, *, github: bool = False) -> N 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) @@ -512,6 +545,7 @@ async def _refresh_stage( _build_stage, zip_path=_STAGE_ZIP, target=target, + base_clock=base_clock, ) # zip 是可弃缓存,删不掉(AV 隔离等)也无妨——下次下载按 "wb" 截断重写 with suppress(OSError): @@ -775,6 +809,7 @@ async def _refresh_resource_stage( progress: _Progress | None, cdk: str, refund_cancelled: bool = False, + incremental: bool = False, ) -> dict[str, object] | None: """按版本每日限额与失败退避刷新缓存;state 由编排器持有并在此记账。""" if ( @@ -806,7 +841,11 @@ async def _refresh_resource_stage( 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 @@ -814,7 +853,21 @@ async def _refresh_resource_stage( _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: # 队列撤销预取不应耗尽当日额度;普通下载失败仍按原规则记账。 @@ -958,9 +1011,13 @@ async def _sweep(progress: _Progress | None = None, cdk: str = "") -> None: ) -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: @@ -1053,6 +1110,7 @@ async def prepare_install_resources( progress: _Progress | None = None, download_only: bool = False, recovery_snapshot: bytes | None = None, + incremental: bool = False, ) -> None: """本体安装之后、账号启动之前补齐资源;调用方已持有安装访问锁。 @@ -1119,6 +1177,11 @@ async def prepare_install_resources( # 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 @@ -1165,6 +1228,7 @@ async def prepare_install_resources( progress=progress, cdk=cdk, refund_cancelled=download_only, + incremental=incremental, ) if not _stage_reusable(stage, target): raise RuntimeError( diff --git a/app/task/MAA/tools/software_package.py b/app/task/MAA/tools/software_package.py index 2a133f8088..7438df5e05 100644 --- a/app/task/MAA/tools/software_package.py +++ b/app/task/MAA/tools/software_package.py @@ -19,12 +19,12 @@ # Contact: DLmaster_361@163.com -"""MAA 本体更新包原语:共享缓存、完整包校验、待更新登记。 +"""MAA 本体更新包原语:共享缓存、包校验、待更新登记。 与 resource_package.py(资源包合并)平行的本体包最小实现,只做三件事: -- 共享缓存:``data/maa_update/software/{os}/{arch}/{channel}/`` 下每个通道 - 架构只保留一个最新有效完整包(package.zip + manifest.json); -- 完整包校验:zip 可读、条目路径安全、根目录无 OTA 清单、关键程序集存在; +- 共享缓存:``data/maa_update/software/{os}/{arch}/{channel}/`` 下保存完整包, + 增量请求按基准版本分目录保存(package.zip + manifest.json); +- 包校验:zip 可读、条目路径安全;完整包要求关键程序集,增量包要求 OTA 清单; - 待更新登记:点读点写新旧两份 MAA 配置的待更新字段,包复制成功后才写 ``Update.Name``/``Update.UpdatePackage``(新版)或 ``Global.VersionUpdate. name``/``package``(旧版),已有合法登记一律保护,值经手语义不过手。 @@ -35,13 +35,15 @@ 路径的 ``MirrorChyanApp<版本>.zip``;登记使用绝对路径(上游 GetPlanned UpdatePackagePath 同款);根目录出现 ``removelist.txt``/``changes.json`` 即 OTA 包(HasOtaMetadata 判据)——启动应用路径不校验包基准版本,本模块 -的拒收是唯一防线,因此只接受完整包。 +需要由登记入口复核基线。全量请求拒收 OTA;增量请求验证清单存在与 ZIP 完整性, +不解释清单语义,由官方更新器应用。 归属判定与合规边界见 software_update.py 模块头。 """ from __future__ import annotations +import hashlib import os import re import shutil @@ -74,9 +76,16 @@ # -------------------------------------------------------------------------- -def software_cache_dir(os_name: str, arch: str, channel: str) -> Path: +def software_cache_dir( + os_name: str, arch: str, channel: str, *, base_version: str = "" +) -> Path: """本体更新共享缓存目录:按系统、架构、通道隔离,互不覆盖。""" - return Path.cwd() / "data" / "maa_update" / "software" / os_name / arch / channel + root = Path.cwd() / "data" / "maa_update" / "software" / os_name / arch / channel + return ( + root / ("base-" + hashlib.sha256(base_version.encode()).hexdigest()[:16]) + if base_version + else root + ) def load_cache_manifest(cache_dir: Path) -> dict[str, str] | None: @@ -91,9 +100,14 @@ def load_cache_manifest(cache_dir: Path) -> dict[str, str] | None: not isinstance(manifest.get(key), str) or not manifest[key] for key in fields ): return None - if manifest["package_type"] != "full": + if manifest["package_type"] not in {"full", "incremental"}: + return None + if manifest["package_type"] == "incremental" and not manifest.get("base_version"): return None - return {key: str(manifest[key]) for key in fields} + return { + **{key: str(manifest[key]) for key in fields}, + "base_version": str(manifest.get("base_version") or ""), + } def cache_zip_matches(cache_dir: Path, manifest: dict[str, str]) -> bool: @@ -124,8 +138,8 @@ def store_cache( ) -def validate_software_zip(zip_path: Path) -> None: - """校验完整包 zip,不满足完整包特征时抛 ValueError(线程内执行)。 +def validate_software_zip(zip_path: Path, *, package_type: str = "full") -> None: + """校验指定类型的 zip,不满足包特征时抛 ValueError(线程内执行)。 - 全条目 CRC 校验(服务端不下发摘要,损坏只能靠包自身完整性兜底); - 条目路径安全:绝对路径、非法 Windows 文件名和尾部空格/点一律拒绝; @@ -139,6 +153,7 @@ def validate_software_zip(zip_path: Path) -> None: names = archive.namelist() essentials: set[str] = set() + ota = False for name in names: normalized = name.replace("\\", "/") segments = [segment for segment in normalized.split("/") if segment] @@ -152,9 +167,15 @@ def validate_software_zip(zip_path: Path) -> None: if not segments: continue if len(segments) == 1 and segments[0].lower() in _OTA_MANIFEST_ENTRIES: - raise ValueError("更新包是 OTA 包(根目录含 OTA 清单),只支持完整包") + ota = True if segments[-1].lower() in _ESSENTIAL_ENTRIES and len(segments) <= 2: essentials.add(segments[-1].lower()) + if package_type == "incremental": + if not ota: + raise ValueError("增量更新包缺少官方 OTA 清单") + return + if ota: + raise ValueError("全量更新请求收到 OTA 包") missing = _ESSENTIAL_ENTRIES - essentials if missing: raise ValueError(f"更新包缺少关键文件: {sorted(missing)}") @@ -248,8 +269,9 @@ def register_pending_update( version: str, package_zip: Path, package_sha256: str, + base_version: str = "", ) -> bool: - """把完整包复制进 MAA 安装目录并登记待更新字段,返回是否登记成功。 + """把已校验的包复制进 MAA 安装目录并登记,增量包另行复核安装基准。 由 software_update.prepare_maa_software_update 在原有 update_maa() 调用 点前调用;登记后仍由 update_maa() 启动 MAA 官方更新链安装。 @@ -265,6 +287,12 @@ def register_pending_update( 幂等:重复调用对同版本包只复制一次、登记值重复写入无副作用。 """ + if base_version: + from .software_update import read_installed_release + + release = read_installed_release(maa_root) + if release is None or release[0] != base_version: + raise ValueError("MAA 增量包基线不匹配") if not version or _ILLEGAL_NAME_RE.search(version): logger.warning(f"MAA 本体更新: 版本名无法用作包文件名,跳过登记: {version!r}") return False @@ -274,7 +302,12 @@ def register_pending_update( ) return False - target = maa_root / f"{_FULL_PACKAGE_PREFIX}{version}.zip" + suffix = ( + "-" + hashlib.sha256(base_version.encode()).hexdigest()[:16] + if base_version + else "" + ) + target = maa_root / f"{_FULL_PACKAGE_PREFIX}{version}{suffix}.zip" if not target.is_absolute(): target = Path(os.path.abspath(target)) @@ -320,6 +353,8 @@ def register_pending_update( _copy_if_changed(package_zip, target, expected_sha256=package_sha256) copied = not target_valid + if base_version and read_installed_release(maa_root) != release: + raise ValueError("复制期间 MAA 增量基线发生变化") write_pending_update(maa_root, version=version, package=str(target)) return True except Exception as e: diff --git a/app/task/MAA/tools/software_update.py b/app/task/MAA/tools/software_update.py index d844e9e141..22372fc110 100644 --- a/app/task/MAA/tools/software_update.py +++ b/app/task/MAA/tools/software_update.py @@ -55,9 +55,9 @@ 传播。同一目标版本按本地日期每天最多尝试一次,出现新版可再次取包,失败 退避仍生效;先免费查版本与复用有效缓存,再申请带 CDK 的一次性下载地址。 -只支持 Mirror 酱完整包:查询不带 current_version(无增量基线,服务端只回 -完整包,对齐上游修复路径语义),响应 update_type 非 full 或包内含 OTA 清单 -一律拒收——上游启动应用路径不校验包基准版本,本模块的拒收是唯一防线。 +默认申请 Mirror 酱完整包;用户选择增量时带实际安装版本作为基线,缓存与 +下载预算按起始版本隔离。增量包下载或校验失败时重新申请全量包,登记前 +复核基线;安装和 OTA 清单解释仍交给官方更新器。 下载与落盘校验全部走异步流式/线程,不阻塞 MAS 主事件循环。 """ @@ -408,11 +408,14 @@ async def migrate_maa_update_channel(config: MaaConfig) -> None: class _Latest(NamedTuple): version: str url: str + package_type: str = "full" + base_version: str = "" -async def _fetch_latest(channel: str, arch: str, cdk: str) -> _Latest: - """按通道架构查询最新完整包。不带 current_version:无增量基线,服务端 - 只回完整包(上游修复路径同款语义)。""" +async def _fetch_latest( + channel: str, arch: str, cdk: str, *, current_version: str = "" +) -> _Latest: + """按通道架构申请更新包;提供 current_version 时允许匹配基线的增量包。""" try: async with asyncio.timeout(_QUERY_TIMEOUT): payload = await _service_update_transport.request_mirror_resource( @@ -423,6 +426,7 @@ async def _fetch_latest(channel: str, arch: str, cdk: str) -> _Latest: "os": "win", "arch": arch, "sp_id": _sp_id(), + **({"current_version": current_version} if current_version else {}), }, proxy=Config.proxy, timeout=_QUERY_TIMEOUT, @@ -437,18 +441,21 @@ async def _fetch_latest(channel: str, arch: str, cdk: str) -> _Latest: url = str(data.get("url") or "") if not version or not url: raise _UpdateError("镜像酱响应缺少版本或下载地址") - if str(data.get("update_type") or "") != "full": - raise _UpdateError( - f"镜像酱未返回完整包(update_type={data.get('update_type')!r}),只支持完整包" - ) - return _Latest(version=version, url=url) + package_type = str(data.get("update_type") or "") + if package_type not in {"full", "incremental"} or ( + package_type == "incremental" and not current_version + ): + raise _UpdateError("镜像酱返回了不匹配的更新包类型") + if current_version and package_type == "full": + logger.info("MAA 无匹配的增量包,使用全量更新") + return _Latest(version, url, package_type, current_version) def _store_verified_cache( cache_dir: Path, part: Path, latest: _Latest, channel: str, arch: str ) -> None: """下载完成后的落盘校验与入缓存(线程内执行)。""" - validate_software_zip(part) + validate_software_zip(part, package_type=latest.package_type) with part.open("rb") as fh: sha256 = hashlib.file_digest(fh, "sha256").hexdigest() store_cache( @@ -459,13 +466,16 @@ def _store_verified_cache( "channel": channel, "os": "win", "arch": arch, - "package_type": "full", + "package_type": latest.package_type, + "base_version": latest.base_version, "source": "mirrorchyan", "downloaded_at": datetime.now(timezone.utc).isoformat(), }, sha256, ) - logger.info(f"MAA 本体完整包已缓存: {latest.version}(win/{arch}/{channel})") + logger.info( + f"MAA 本体更新包已缓存: {latest.version}({latest.package_type},win/{arch}/{channel})" + ) async def _ensure_cache(latest: _Latest, channel: str, arch: str) -> None: @@ -473,7 +483,9 @@ async def _ensure_cache(latest: _Latest, channel: str, arch: str) -> None: 调用方已检查并记账取包额度;下载失败在此记账退避并返回,不外抛。 """ - cache_dir = software_cache_dir(os_name="win", arch=arch, channel=channel) + cache_dir = software_cache_dir( + os_name="win", arch=arch, channel=channel, base_version=latest.base_version + ) manifest = load_cache_manifest(cache_dir) if ( manifest is not None @@ -482,7 +494,11 @@ async def _ensure_cache(latest: _Latest, channel: str, arch: str) -> None: ): logger.info(f"MAA 本体更新: 目标版本 {latest.version} 已在缓存,跳过下载") return - key = _cache_key(arch, channel) + key = _cache_key(arch, channel) + ( + "/" + hashlib.sha256(latest.base_version.encode()).hexdigest()[:16] + if latest.base_version + else "" + ) part = cache_dir / "package.zip.part" try: # Mirror 酱临时授权 URL 显式声明来源并锁定内置下载器,不依赖全局 @@ -502,6 +518,8 @@ async def _ensure_cache(latest: _Latest, channel: str, arch: str) -> None: except asyncio.CancelledError: raise except Exception as e: + if latest.package_type == "incremental": + raise _UpdateError("MAA 增量包下载或校验失败") from e async with _state_lock: state = load_state(_STATE_FILE) state[f"download_fail_until/{key}"] = ( @@ -553,7 +571,13 @@ def _query_key(arch: str, channel: str, cdk: str) -> str: async def _precheck_flow( - maa_root: Path, channel: str, arch: str, cdk: str, *, prefetch: bool = False + maa_root: Path, + channel: str, + arch: str, + cdk: str, + *, + prefetch: bool = False, + incremental: bool = False, ) -> None: """预检查主体:查询 → 版本判断 → 取包入缓存。失败语义见各调用。""" # 不同安装分别判断当前版本,同一缓存的查询/下载串行,后来的旧安装 @@ -561,7 +585,12 @@ async def _precheck_flow( async with _cache_locks.setdefault(_cache_key(arch, channel), asyncio.Lock()): try: await _precheck_inner( - maa_root, channel, arch, cdk, refund_cancelled=prefetch + maa_root, + channel, + arch, + cdk, + refund_cancelled=prefetch, + incremental=incremental, ) except asyncio.CancelledError: raise @@ -587,6 +616,7 @@ async def _precheck_inner( *, target_version: str | None = None, refund_cancelled: bool = False, + incremental: bool = False, ) -> str | None: """持缓存锁检查目标与额度,必要时取包,返回确认的版本供启动入口登记。""" release = read_installed_release(maa_root) @@ -603,7 +633,10 @@ async def _precheck_inner( if compare_versions(target, current) <= 0: return release[0] - cache_dir = software_cache_dir(os_name="win", arch=arch, channel=channel) + baseline = release[0] if incremental else "" + cache_dir = software_cache_dir( + os_name="win", arch=arch, channel=channel, base_version=baseline + ) manifest = load_cache_manifest(cache_dir) cached = parse_version(manifest["version"]) if manifest else None if ( @@ -614,7 +647,9 @@ async def _precheck_inner( ): return manifest["version"] - key = _cache_key(arch, channel) + key = _cache_key(arch, channel) + ( + "/" + hashlib.sha256(baseline.encode()).hexdigest()[:16] if baseline else "" + ) query_key = _query_key(arch, channel, cdk) attempt_field = f"last_download_attempt_at/{key}" version_field = f"last_download_attempt_version/{key}" @@ -636,7 +671,9 @@ async def _precheck_inner( return None # 每次获准取包都现查一次性地址,不复用上轮已使用或已过期的 URL。 - latest = await _fetch_latest(channel=channel, arch=arch, cdk=cdk) + latest = await _fetch_latest( + channel=channel, arch=arch, cdk=cdk, current_version=baseline + ) actual = parse_version(latest.version) attempt_stamp = datetime.now(timezone.utc).isoformat() attempt_version = ( @@ -654,7 +691,19 @@ async def _precheck_inner( try: if actual is None or compare_versions(actual, target) < 0: raise _UpdateError("MAA 本体包未达到已确认的目标版本,请稍后重试") - await _ensure_cache(latest=latest, channel=channel, arch=arch) + try: + await _ensure_cache(latest=latest, channel=channel, arch=arch) + except _UpdateError: + if latest.package_type != "incremental": + raise + logger.warning("MAA 增量更新准备失败,回退全量包") + latest = ( + await _fetch_latest(channel=channel, arch=arch, cdk=cdk) + )._replace(base_version=baseline) + actual = parse_version(latest.version) + if actual is None or compare_versions(actual, target) < 0: + raise _UpdateError("回退的全量包未达到目标版本") + await _ensure_cache(latest=latest, channel=channel, arch=arch) except asyncio.CancelledError: if not refund_cancelled: raise @@ -679,7 +728,14 @@ async def prefetch_maa_software_update(maa_root: Path, config: MaaConfig) -> Non release = read_installed_release(maa_root) channel = config.Update.Channel if enabled and cdk is not None and release is not None and channel is not None: - await _precheck_flow(maa_root, channel, release[1], cdk, prefetch=True) + await _precheck_flow( + maa_root, + channel, + release[1], + cdk, + prefetch=True, + incremental=config.Update.PackageMode == "incremental", + ) def start_maa_software_update_precheck( @@ -719,7 +775,13 @@ def start_maa_software_update_precheck( if existing is not None and not existing.done(): return _precheck_tasks[key] = asyncio.create_task( - _precheck_flow(maa_root, channel, arch, cdk), + _precheck_flow( + maa_root, + channel, + arch, + cdk, + incremental=config.Update.PackageMode == "incremental", + ), name=f"maa-software-update-{key}", ) except Exception as e: @@ -756,7 +818,14 @@ async def prepare_maa_software_update( if channel is None: return key = (_path_key(maa_root), _cache_key(arch, channel)) - cache_dir = software_cache_dir("win", arch, channel) + cache_dir = software_cache_dir( + "win", + arch, + channel, + base_version=version_text + if config.Update.PackageMode == "incremental" + else "", + ) task = _precheck_tasks.get(key) downloads = [ pending @@ -768,6 +837,9 @@ async def prepare_maa_software_update( await asyncio.gather(*(asyncio.shield(pending) for pending in downloads)) if _precheck_tasks.get(key) is task: _precheck_tasks.pop(key, None) + if read_installed_release(maa_root) != release: + logger.warning("MAA 安装在等待下载期间发生变化,请重新检查更新") + return cache_lock = _cache_locks.setdefault(_cache_key(arch, channel), asyncio.Lock()) try: async with asyncio.timeout(_PRECHECK_GRACE): @@ -784,6 +856,12 @@ async def prepare_maa_software_update( logger.warning("MAA 本体更新: 缓存包与清单不一致,作废缓存等待重建") (cache_dir / "manifest.json").unlink(missing_ok=True) return + if ( + manifest["package_type"] == "incremental" + and manifest["base_version"] != version_text + ): + logger.warning("MAA 增量包基线已变化,拒绝登记") + return current = parse_version(version_text) target = parse_version(manifest["version"]) if ( @@ -812,6 +890,9 @@ async def prepare_maa_software_update( version=manifest["version"], package_zip=cache_dir / "package.zip", package_sha256=manifest["sha256"], + base_version=manifest["base_version"] + if manifest["package_type"] == "incremental" + else "", ) if registered: logger.info( @@ -843,7 +924,12 @@ async def has_cached_maa_software_update( if lock.locked(): return False async with lock: - cache_dir = software_cache_dir("win", arch, channel) + cache_dir = software_cache_dir( + "win", + arch, + channel, + base_version=version if config.Update.PackageMode == "incremental" else "", + ) manifest = load_cache_manifest(cache_dir) current = parse_version(version) target = parse_version(manifest["version"]) if manifest else None @@ -883,7 +969,12 @@ async def prepare_maa_software_before_run( return current async with _cache_locks.setdefault(_cache_key(arch, channel), asyncio.Lock()): prepared_version = await _precheck_inner( - maa_root, channel=channel, arch=arch, cdk=cdk, target_version=latest_version + maa_root, + channel=channel, + arch=arch, + cdk=cdk, + target_version=latest_version, + incremental=config.Update.PackageMode == "incremental", ) if prepared_version is None: raise RuntimeError("MAA 更新版本尚未确认,请检查网络后重试") diff --git a/app/task/MaaFW/api_service/update.py b/app/task/MaaFW/api_service/update.py index fee70aa020..0ae9b582f8 100644 --- a/app/task/MaaFW/api_service/update.py +++ b/app/task/MaaFW/api_service/update.py @@ -214,6 +214,7 @@ def _maafw_update_source_config(script_config: RuntimeMaaFWConfig) -> dict[str, "mirror_cdk": credentials.cdk, "channel": credentials.channel, "package_source": credentials.package_source, + "package_mode": script_config.Update.PackageMode, } # 副本里没有 MFW.exe / maafw/ 可扫,外壳家族从导入报告取。 shell_hint = shell_hint_from_report(script_config) diff --git a/app/task/MaaFW/embedded_manager.py b/app/task/MaaFW/embedded_manager.py index 4d002946dd..17b97554f8 100644 --- a/app/task/MaaFW/embedded_manager.py +++ b/app/task/MaaFW/embedded_manager.py @@ -721,6 +721,9 @@ async def prefetch_script_update(self) -> None: from app.task.MaaFW.tools.embedded.view_update import memo_path_factory config = Config.ScriptConfig[uuid.UUID(self.script_info.script_id)] + # 增量依赖启动时的当前载荷,交给运行前更新,避免提前下载全量。 + if config.Update.PackageMode == "incremental": + return if resolve_auto_update_mode(config) == "Off" or follow_source_enabled(config): return credentials = resolve_update_credentials(config) @@ -1030,7 +1033,10 @@ async def _invoke_project_update( del project_path # 视图路径由托管 ID 推出,run_view_update 自己取 send_log = self._threadsafe_update_log() - source_config: dict[str, Any] = {"package_source": credentials.package_source} + source_config: dict[str, Any] = { + "package_source": credentials.package_source, + "package_mode": self.script_config.Update.PackageMode, + } # 副本里没有 MFW.exe / maafw/ 可扫,外壳家族只能从导入报告取;不回填, # M9A 这种同版本同时发 -MXU.zip 与 -MFAA.zip 的项目会选错资产。 shell_hint = shell_hint_from_report(self.script_config) diff --git a/app/task/MaaFW/tools/core/project_update/updater.py b/app/task/MaaFW/tools/core/project_update/updater.py index 8d234ae8c2..4b406b490e 100644 --- a/app/task/MaaFW/tools/core/project_update/updater.py +++ b/app/task/MaaFW/tools/core/project_update/updater.py @@ -53,7 +53,6 @@ from .projection import PROJECTION_REVISION, abandoned_native_runtime_files from .projection_heal import format_size from .range_delta import ( - FORCE_FULL_PACKAGE_ENV, RangeDeltaCancelled, RangeDeltaUnavailable, RangeSpeedGuard, @@ -636,12 +635,12 @@ async def update_maafw_project_if_needed( and not base_missing ): send_update_log("当前版本是本地导入的,改为请求全量包") - force_full = force_full_package() + force_full = ( + force_full_package() or (source_config or {}).get("package_mode") == "full" + ) if force_full: prefer_full = True - send_update_log( - f"已设环境变量 {FORCE_FULL_PACKAGE_ENV}=1:一律请求全量包、不按区间差量" - ) + send_update_log("已选择全量更新:请求全量包、不按区间差量") gate = precheck_gate if base_missing: # 新载荷要以当前载荷为基准建,它的清单不在本机就一定建不成(以前要等下载、 @@ -656,6 +655,11 @@ async def gate(_version: str) -> str: prefetched is not None and prefetched[0] == current_version and not base_missing + and ( + not force_full + or prefetched[1].candidate is None + or prefetched[1].candidate.package_type in {None, "", "full"} + ) ): # 同一配置代次、同一基准下复用已下载候选,避免重签 Mirror 一次性地址。 discovery = prefetched[1] @@ -818,6 +822,14 @@ async def gate(_version: str) -> str: send_update_log(f"{blocker},不按区间差量,下载全量包") else: range_delta = True + registered = False + + async def on_register(result: RegisterResult) -> Any: + nonlocal registered + registered = True + if after_register is not None: + return await after_register(result) + try: apply_result = await apply_maafw_project_update( project_path.resolve(), @@ -829,7 +841,7 @@ async def gate(_version: str) -> str: cancel_event=cancel_event, github_mirror_urls=github_mirror_urls, payload=payload, - after_register=after_register, + after_register=on_register, range_delta=range_delta, ) except Exception as exc: @@ -845,6 +857,29 @@ async def gate(_version: str) -> str: final=True, ) raise + if ( + candidate.package_type == "delta" + and not registered + and isinstance(exc.__cause__, UpdateApplyError) + ): + send_update_log("增量更新失败,当前版本未改动,回退全量更新") + return await update_maafw_project_if_needed( + project_path, + interface_model, + mirror_cdk=mirror_cdk, + channel=channel, + proxy=proxy, + send_log=send_log, + source_config={**(source_config or {}), "package_mode": "full"}, + progress=progress, + post_validate=post_validate, + precheck_gate=precheck_gate, + cancel_event=cancel_event, + github_mirror_urls=github_mirror_urls, + payload=payload, + after_register=after_register, + allow_misnumbered_rollback=allow_misnumbered_rollback, + ) detail = _exception_detail(exc) message = ( detail diff --git a/changelog.d/incremental-update-mode.feat.md b/changelog.d/incremental-update-mode.feat.md new file mode 100644 index 0000000000..a9ce94a2f6 --- /dev/null +++ b/changelog.d/incremental-update-mode.feat.md @@ -0,0 +1,2 @@ +project: update +支持 MAA 与 MFW 家族选择增量更新,并在必要时回退全量 diff --git a/frontend/e2e/maa-update.pw.ts b/frontend/e2e/maa-update.pw.ts index 1bd81daf87..cf3b5349ba 100644 --- a/frontend/e2e/maa-update.pw.ts +++ b/frontend/e2e/maa-update.pw.ts @@ -76,7 +76,7 @@ test.describe('@maa-update configuration and dispatch', () => { .locator('.form-section') .filter({ has: page.getByRole('heading', { name: 'Update takeover', exact: true }) }) await expect(updatePanel).toBeVisible() - await updatePanel.locator('.ant-select-selector').click() + await updatePanel.locator('.ant-select-selector').first().click() const save = page.waitForResponse( response => response.url().endsWith('/api/scripts/update') && @@ -92,7 +92,7 @@ test.describe('@maa-update configuration and dispatch', () => { await updatePanel.getByRole('switch').click() expect((await (await enable).json()).code).toBe(200) await page.reload() - await expect(updatePanel.locator('.ant-select-selection-item')).toHaveText('Beta') + await expect(updatePanel.locator('.ant-select-selection-item').first()).toHaveText('Beta') await expect(updatePanel.getByRole('switch')).toBeChecked() await updatePanel.scrollIntoViewIfNeeded() await app.evidence('maa-update-channel-persisted') @@ -135,3 +135,32 @@ test.describe('@maa-update configuration and dispatch', () => { await app.evidence('maa-update-unconfigured-task-failed') }) }) + +test('@maa-update package mode persists with a risk notice and no CDK', async ({ app, page }) => { + const created = await post(page.request, '/api/scripts/add', { type: 'MAA' }) + await app.goto(`/scripts/${created.scriptId}/edit/maa`) + const panel = page.locator('.form-section').filter({ + has: page.getByRole('heading', { name: 'Update takeover', exact: true }), + }) + const mode = panel.getByRole('combobox', { name: 'Update method', exact: true }) + await expect(mode).toBeDisabled() + await panel.getByRole('switch').click() + await expect(mode).toBeEnabled() + await mode.locator('xpath=ancestor::*[contains(@class, "ant-select-selector")]').click() + const saved = page.waitForResponse( + response => + response.url().endsWith('/api/scripts/update') && + response.request().postDataJSON()?.data?.Update?.PackageMode === 'incremental' + ) + await page + .locator('.ant-select-dropdown:visible') + .getByText('Prefer incremental', { exact: true }) + .click() + expect((await (await saved).json()).code).toBe(200) + await expect(panel.getByText('Incremental updates carry risks', { exact: false })).toBeVisible() + await page.reload() + await expect(panel.getByText('Prefer incremental', { exact: true })).toBeVisible() + await expect(panel.getByText('Incremental updates carry risks', { exact: false })).toBeVisible() + await panel.scrollIntoViewIfNeeded() + await app.evidence('incremental-update-risk-and-persistence') +}) diff --git a/frontend/electron/services/analyticsCoverage.md b/frontend/electron/services/analyticsCoverage.md index 2b05035846..550cc7cdb1 100644 --- a/frontend/electron/services/analyticsCoverage.md +++ b/frontend/electron/services/analyticsCoverage.md @@ -209,3 +209,5 @@ started 未送达时不发送对应 finished。运行 UUID 只留在本机消息 OkNte 日常计划的快捷表格与详细编辑共享当天字段覆写。快捷保存复用计划编辑事件,详细编辑按保存接口结果记录;不采集日常参数或计划标识。 - Okww 原生多账号任务:任务选择保存复用 `useUserApi.updateUser` 的 `config_write_finished`;批次仍按 MAS 用户归属结果,运行结局沿用 `taskAnalytics.ts` 的现有观察边界,不拆分或采集上游账号、任务配置及日志。 + +MAA / MFW 家族全量与增量更新方式:保存沿用 `useScriptApi.updateScript` 的 `config_write_finished`;运行更新沿用现有脚本终态与手动更新观察入口,不新增逐包或回退事件,不采集基线版本、路径或 CDK。 diff --git a/frontend/src/api/models/M9AConfig_Update.ts b/frontend/src/api/models/M9AConfig_Update.ts index b4d1d8d1aa..4a3fe9837e 100644 --- a/frontend/src/api/models/M9AConfig_Update.ts +++ b/frontend/src/api/models/M9AConfig_Update.ts @@ -3,6 +3,10 @@ /* tslint:disable */ /* eslint-disable */ export type M9AConfig_Update = { + /** + * 更新方式:全量或优先增量;增量依赖完整且匹配的基线,必要时回退全量。 + */ + PackageMode?: ('full' | 'incremental' | null); /** * 项目自动更新时机:Off 不更新,BeforeRun 在账号任务开始前更新。旧配置 AfterRun 兼容为运行前更新。更新和环境准备耗时不计入账号任务时限。 */ diff --git a/frontend/src/api/models/M9AConfig_UpdateInput.ts b/frontend/src/api/models/M9AConfig_UpdateInput.ts index a76406e25e..530fa9b0b1 100644 --- a/frontend/src/api/models/M9AConfig_UpdateInput.ts +++ b/frontend/src/api/models/M9AConfig_UpdateInput.ts @@ -3,6 +3,10 @@ /* tslint:disable */ /* eslint-disable */ export type M9AConfig_UpdateInput = { + /** + * 更新方式:全量或优先增量;增量依赖完整且匹配的基线,必要时回退全量。 + */ + PackageMode?: ('full' | 'incremental' | null); /** * 项目自动更新时机:Off 不更新,BeforeRun 在账号任务开始前更新。旧配置 AfterRun 兼容为运行前更新。更新和环境准备耗时不计入账号任务时限。 */ diff --git a/frontend/src/api/models/MSSConfig_Update.ts b/frontend/src/api/models/MSSConfig_Update.ts index d7875cdf80..fbf904d760 100644 --- a/frontend/src/api/models/MSSConfig_Update.ts +++ b/frontend/src/api/models/MSSConfig_Update.ts @@ -3,6 +3,10 @@ /* tslint:disable */ /* eslint-disable */ export type MSSConfig_Update = { + /** + * 更新方式:全量或优先增量;增量依赖完整且匹配的基线,必要时回退全量。 + */ + PackageMode?: ('full' | 'incremental' | null); /** * 项目自动更新时机:Off 不更新,BeforeRun 在账号任务开始前更新。旧配置 AfterRun 兼容为运行前更新。更新和环境准备耗时不计入账号任务时限。 */ diff --git a/frontend/src/api/models/MSSConfig_UpdateInput.ts b/frontend/src/api/models/MSSConfig_UpdateInput.ts index 2680f1a8cb..e8066ba71c 100644 --- a/frontend/src/api/models/MSSConfig_UpdateInput.ts +++ b/frontend/src/api/models/MSSConfig_UpdateInput.ts @@ -3,6 +3,10 @@ /* tslint:disable */ /* eslint-disable */ export type MSSConfig_UpdateInput = { + /** + * 更新方式:全量或优先增量;增量依赖完整且匹配的基线,必要时回退全量。 + */ + PackageMode?: ('full' | 'incremental' | null); /** * 项目自动更新时机:Off 不更新,BeforeRun 在账号任务开始前更新。旧配置 AfterRun 兼容为运行前更新。更新和环境准备耗时不计入账号任务时限。 */ diff --git a/frontend/src/api/models/MaaConfig_Update.ts b/frontend/src/api/models/MaaConfig_Update.ts index bf76f17305..8ae8d00f02 100644 --- a/frontend/src/api/models/MaaConfig_Update.ts +++ b/frontend/src/api/models/MaaConfig_Update.ts @@ -3,6 +3,10 @@ /* tslint:disable */ /* eslint-disable */ export type MaaConfig_Update = { + /** + * 更新方式:全量或优先增量;增量依赖完整且匹配的基线,必要时回退全量。 + */ + PackageMode?: ('full' | 'incremental' | null); /** * 更新接管一次性迁移版本 */ diff --git a/frontend/src/api/models/MaaConfig_UpdateInput.ts b/frontend/src/api/models/MaaConfig_UpdateInput.ts index 626ff17072..a8a41905fb 100644 --- a/frontend/src/api/models/MaaConfig_UpdateInput.ts +++ b/frontend/src/api/models/MaaConfig_UpdateInput.ts @@ -3,6 +3,10 @@ /* tslint:disable */ /* eslint-disable */ export type MaaConfig_UpdateInput = { + /** + * 更新方式:全量或优先增量;增量依赖完整且匹配的基线,必要时回退全量。 + */ + PackageMode?: ('full' | 'incremental' | null); /** * 更新接管一次性迁移版本 */ diff --git a/frontend/src/api/models/MaaFWConfig_Update.ts b/frontend/src/api/models/MaaFWConfig_Update.ts index cf437a9fe5..6c32272e09 100644 --- a/frontend/src/api/models/MaaFWConfig_Update.ts +++ b/frontend/src/api/models/MaaFWConfig_Update.ts @@ -3,6 +3,10 @@ /* tslint:disable */ /* eslint-disable */ export type MaaFWConfig_Update = { + /** + * 更新方式:全量或优先增量;增量依赖完整且匹配的基线,必要时回退全量。 + */ + PackageMode?: ('full' | 'incremental' | null); /** * 项目自动更新时机:Off 不更新,BeforeRun 在账号任务开始前更新。旧配置 AfterRun 兼容为运行前更新。更新和环境准备耗时不计入账号任务时限。 */ diff --git a/frontend/src/api/models/MaaFWConfig_UpdateInput.ts b/frontend/src/api/models/MaaFWConfig_UpdateInput.ts index dd7379ba6a..e5e925cbb5 100644 --- a/frontend/src/api/models/MaaFWConfig_UpdateInput.ts +++ b/frontend/src/api/models/MaaFWConfig_UpdateInput.ts @@ -3,6 +3,10 @@ /* tslint:disable */ /* eslint-disable */ export type MaaFWConfig_UpdateInput = { + /** + * 更新方式:全量或优先增量;增量依赖完整且匹配的基线,必要时回退全量。 + */ + PackageMode?: ('full' | 'incremental' | null); /** * 项目自动更新时机:Off 不更新,BeforeRun 在账号任务开始前更新。旧配置 AfterRun 兼容为运行前更新。更新和环境准备耗时不计入账号任务时限。 */ diff --git a/frontend/src/composables/useMaaFWScriptConfig.ts b/frontend/src/composables/useMaaFWScriptConfig.ts index e42bff265c..9e58d77237 100644 --- a/frontend/src/composables/useMaaFWScriptConfig.ts +++ b/frontend/src/composables/useMaaFWScriptConfig.ts @@ -102,6 +102,7 @@ export const getDefaultMaaFWScriptConfig = (): MaaFWScriptConfig => ({ Hotkeys: '{}', }, Update: { + PackageMode: 'full', AutoUpdateMode: 'BeforeRun', Source: 'GitHub', Channel: 'stable', diff --git a/frontend/src/types/script.ts b/frontend/src/types/script.ts index 4a8a4de5f2..5018f72e28 100644 --- a/frontend/src/types/script.ts +++ b/frontend/src/types/script.ts @@ -33,7 +33,10 @@ export interface MAAScriptConfig { Run: DraftGroup> Emulator: DraftGroup> Update: DraftGroup< - Pick, 'TakeoverEnabled' | 'MirrorChyanCDK' | 'Channel'> + Pick< + NonNullable, + 'TakeoverEnabled' | 'MirrorChyanCDK' | 'Channel' | 'PackageMode' + > > } @@ -172,6 +175,7 @@ export interface MaaFWScriptConfig { } Update: { /** 自动更新时机:不更新 / 运行前 / 运行后。 */ + PackageMode: 'full' | 'incremental' AutoUpdateMode: MaaFWAutoUpdateMode /** 更新包的下载源,由用户显式选择,没有「自动」;默认 GitHub(零配置可用)。 */ Source: 'MirrorChyan' | 'GitHub' diff --git a/frontend/src/views/EditView/Script/MaaFWScriptEdit/UpdateSettingsSection.vue b/frontend/src/views/EditView/Script/MaaFWScriptEdit/UpdateSettingsSection.vue index 5c17c5ef98..5006c07ea5 100644 --- a/frontend/src/views/EditView/Script/MaaFWScriptEdit/UpdateSettingsSection.vue +++ b/frontend/src/views/EditView/Script/MaaFWScriptEdit/UpdateSettingsSection.vue @@ -9,7 +9,14 @@ :message="autoUpdateDisabledMessage" /> - + + + +