36 Commits
Author SHA1 Message Date
AskaEth d68523b02a Merge branch 'feat/m156-termxmerge-merge-termux-glibc-into'
Dev Guards / 架构与合规守门(同 main 口径) (push) Blocked by required conditions
Dev Guards / fmt (rustfmt --check) (push) Waiting to run
Dev Guards / clippy (ubuntu) (push) Waiting to run
Guards / core 零 UI 守门 (push) Blocked by required conditions
Guards / presenter 零 UI 守门 (push) Blocked by required conditions
Guards / design 零第三方依赖守门 (push) Blocked by required conditions
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Blocked by required conditions
Guards / 文件行数 ≤400 守门 (push) Blocked by required conditions
Guards / 依赖许可与来源(cargo-deny) (push) Blocked by required conditions
2026-11-07 09:38:20 +08:00
AskaEth 3b3430a93e docs(termux): 对外口径升级——CPU 软渲染 + 自编 Mesa GPU 两条路径各真机验证一次(用户拍板 A,四处同步) 2026-11-07 09:33:24 +08:00
AskaEth cb3ae8556b docs(core): install.rs 模块文档补上 Termux 分支与平台参数不早退
拆分后模块文档只描述归档链,读起来像「Termux 也走这条」;补一行说明 Termux 的系统包
分支在链之前,以及 `AdoptiumTarget::host()` 失败已改成每家一条降级原因(不再整体早退)。
2026-11-07 09:33:24 +08:00
AskaEth 88ac9d5aca docs(termux): 适配文档头部与合并指南对齐实际合并(15 提交 / --no-ff / 两处 PC 侧修复)
- 头部:`14 个提交,HEAD 38d3024` 是写文档那一刻的自我指涉,永远不可能对——改成
  `15 个提交,分叉点 87ab878,合并前 HEAD e987e02`。
- 合并指南第 1 步原来写 `git rebase origin/dev`,实际按用户令走 `--no-ff` 合并
  (原 15 个提交一字未改地留在历史里,便于按组 revert),改为记录既成事实与
  零文本冲突的交集(Cargo.lock / 两个 README / app-iced 的 Cargo.toml)。
- 新增第 6 条:PC 门禁发现、已就地修掉的两处——cfg(unix) 测试导入未 gate 导致
  Windows clippy 判错;两个文件越 400 行红线后的拆分。这两条在 Termux 上跑门禁看不出来,
  写进合并指南,免得下一个人以为「本机绿了就等于 PC 绿」。
2026-11-07 09:33:24 +08:00
AskaEth 4dbeb07220 refactor(core): 拆分越 400 行红线的 install.rs 与 install/libraries.rs
合并 termux-glibc 后两个文件顶破了「单文件 ≤400 行」(guards.yml 的 file-size):
`game/java/install.rs` 379 → 456、`game/install/libraries.rs` 258 → 409。

按步骤拆,纯搬运、零行为变化:

- `install/termux_install.rs`(新):Termux 系统包那条路。它与归档链不共用任何一步
  (不查版本元数据、不下载、不解压、不校验 sha256),原来只能是一段嵌在 install() 中间的
  if。返回 `Outcome::Done`/`Skipped` 并用 Box 装 `InstalledJdk`(否则 large_enum_variant:
  392 vs 24 字节)。
- `install/one.rs`(新):回退链里「一家」的机械步骤——下载 → 解压 → 探测 + 幂等复用,
  连同 `Prepared` 与 `note()`。
- `install/libraries/arm64_natives.rs`(新):Linux arm64 的 natives 改写与 Maven 兜底
  (两个常量 + 改写/拼址两个函数)。用例仍留在 libraries.rs 的 tests 里,它们从 `plan()`
  的出口断言,不碰私有步骤。

拆分后:install.rs 319、one.rs 100、termux_install.rs 104、libraries.rs 348、
arm64_natives.rs 72;全仓受检文件(*.rs/*.md/*.toml/*.yml/*.json/*.py/*.sh)复算
最大 400 行,红线内。
2026-11-07 09:33:24 +08:00
AskaEth c82e39d0eb fix(core): Windows 上 clippy 报 discover.rs 两个 cfg(unix) 测试导入未使用
这两个 Termux 扫描用例挂着 `#[cfg(unix)]`,但它们的 `use`(`standard_roots_in`、
`platform::paths::MapEnv`)写在 mod tests 顶部没跟着 gate:

- Termux/Linux 上 cfg(unix) 成立 → 有用例 → 导入有使用者 → clippy 干净(作者本机 475 测试
  通过是真的);
- Windows 上那两个用例不编译 → 导入无使用者 → `-D warnings` 直接判错
  (`unused import` ×2,`could not compile unlucky-core (lib test)`)。

把两个 `use` 同样 gate 成 `#[cfg(unix)]`。代价是这两个用例在 Windows 上不编译不执行,
覆盖由 Linux CI 承担(它们断言的本来就是 Unix 路径)。
2026-11-07 09:33:23 +08:00
AskaEth 161aec52f4 fix(app-iced): 新增的 unlucky-core 依赖版本对齐 0.2.2(合并后语义冲突)
termux-glibc 分支写这条依赖时 workspace 版本还是 0.2.1;M154 之后其余内部依赖
都已被抬到 0.2.2。caret 约束下 0.2.1 仍能解析到 0.2.2,所以不会构建失败,
但版本对齐是 M154 的既定契约,留一条 0.2.1 会让下一个读到该文件的人以为有例外。
2026-11-07 09:33:23 +08:00
AskaEth 41019d9cae docs(termux): 根目录新增适配方案与合并指南
TERMUX-ADAPTATION.md:方案(平台识别/JDK/字体/横屏/GPU/MC)、逐文件改动表、
兼容性说明(Termux gate、x86_64 不变)、合并步骤与 CI 预期、验证矩阵、
后续路线与按组回滚策略。README.termux 顶部链过去。
2026-11-07 09:33:23 +08:00
AskaEth fe472e36a5 docs(termux): README 校正——GPU 默认、横屏截图尺寸、数据根说明
- 构建描述从 CPU-only 改为 GPU 构建(wgpu + CPU 兜底)
- 窗口证据改为横屏 1657x1067、首帧 ~60 ms
- 快速开始不再强制 --software-renderer:默认 auto,驱动缺失自动降级
- 补数据根说明(Portable 按 exe 目录,--data-dir 暂忽略)
2026-11-07 09:33:23 +08:00
AskaEth 394faad611 docs(termux): MC 1.20.4 真机跑通结论与剩余项
- README:MC 标 ✅(glibc JRE + arm64 natives + softpipe);后续收敛为
  zink/DRI3、剪贴板/IME、narrator/音频
- 构建文档 §4.2 补真机启动命令、窗口尺寸与日志位置、非致命项
2026-11-07 09:33:23 +08:00
AskaEth 23c2abebc2 feat(termux): launch 侧同样改写 arm64 natives;真机跑通 MC 1.20.4
- Library::linux_arm64_native_name 收敛改写规则,install 与 launch 共用
- presenter/CLI 的 classpath 组装:1.19+ 的独立 natives 条目按 arm64 坐标
  找文件并进 classpath;≤1.18 的 natives 表条目仍归 natives::collect
- 真机验证:ull install 1.20.4(740MB)→ glibc Temurin 21 + arm64 natives +
  softpipe GL,MC 主菜单在 termux-x11 中渲染(窗口 854x480)
- 已知非致命:narrator flite 缺失、LWJGL JNI 版本告警、oshi cpuVendor NPE
2026-11-07 09:33:23 +08:00
AskaEth 0e7ae44923 fix(termux): 现代清单的 LWJGL natives 条目也改写为 arm64
1.20.x 把 natives 作为独立库条目(org.lwjgl:lwjgl:3.3.2:natives-linux),
不走 natives 表。Linux/aarch64 上按坐标改写成 natives-linux-arm64,
URL 兜底到阿里云 Maven(Mojang 对 arm64 一律 404)。
真机 1.20.4 的 dry-run:7 个 natives 全部变成 arm64;x86_64 不变。
2026-11-07 09:33:22 +08:00
AskaEth 7eed6c6261 feat(termux): MC 启动接线——Linux arm64 natives + softpipe 子进程环境
- Library::native_classifier:Linux/aarch64 上把 LWJGL3 的 natives-linux
  改写成 natives-linux-arm64(Mojang 清单只有 x86_64)
- install::libraries:arm64 分类器在清单里没有下载信息时,按 Maven 相对路径
  兜底到阿里云镜像(实测 Mojang libraries 404、Aliyun 200);
  单测覆盖 arm64 兜底与 x86_64 不变
- launch::defaults:Termux glibc 构建启动 MC 时默认带
  MESA_LOADER_DRIVER_OVERRIDE=swrast / LIBGL_ALWAYS_SOFTWARE=1(用户可覆盖)
- 文档:JRE patchelf 一次性步骤与接线现状;README 同步
- 验证:patched Temurin java 直接执行;LWJGL arm64+GLFW 探针 gl-ok
2026-11-07 09:33:22 +08:00
AskaEth 02ed09ae49 docs(termux): 补 MC 运行时结论(glibc JRE + LWJGL arm64 + softpipe GL)
- §4.2:Temurin 21 JRE 在 glibc 下跑通;AWT/X11 正常;
  LWJGL 3.3.3 arm64 + GLFW + GLX/softpipe 端到端探针通过(GL 3.3)
- zink 仍卡 DRI3;softpipe 可用(软件 GL)
- README 同步:MC 标 🟡(栈已验证,启动器接线待做),后续改为接线 + DRI3
2026-11-07 09:33:22 +08:00
AskaEth 173a18b842 feat(termux): glibc 构建自动启用自编 Mesa 26 turnip ICD(GPU 打通)
- app/boot/termux_gpu:glibc 构建 + Termux 且随附 ICD 存在时,在探测前设
  VK_ICD_FILENAMES/VK_DRIVER_FILES;用户显式设置不覆盖
- 文档:Mesa 26.2.6 + KGSL 的完整构建/安装/验证步骤(含 shebang、
  --wrap-mode=nofallback、C++/链接参数三处坑)
- 实测:wgpu 选中 Adreno 840(turnip Mesa 26.2.6),截图首帧 61 ms
- README.termux:GPU 标 ✅,MC 标 🟡(glibc JRE+AWT 已验证,GLX 待 GL 构建)
2026-11-07 09:33:21 +08:00
AskaEth bbb316ca08 docs(termux): GPU 结论落地——glibc 侧被驱动版本挡住
实测结论:
- glibc Vulkan loader + 自写 ICD 能加载
- Termux glibc Mesa 24.2.6 不认识 Adreno 840(chip 0x44050A31 unsupported)
- Arch vulkan-freedreno 26.2.6 能加载但 Linux/DRM 构建找不到设备(缺 KGSL,
  /dev/dri 对 untrusted_app 被 SELinux 拒绝)
- GPU 变体会打印具名错误并自动降级 tiny-skia

README.termux 同步:GPU 标 ⛔、MC 走 glibc JVM 的结论与后续路线
2026-11-07 09:33:21 +08:00
AskaEth c434dbe335 feat(termux): 横屏自适应窗口、安装后清理 apt 工作区、补分支 README
- app/boot/termux_window:Termux 上默认窗口按 X 屏幕等比自适应
  (横屏铺满、竖屏不溢出,最大 2×;日志 source=termux-screen),
  ULL_WINDOW_SIZE 覆盖与 1180×760 验收基准不变;5 条单测
- termux::install 成功后清理 $PREFIX/tmp/ull-java-termux(一次安装约 100 MB)
- 构建文档补 clippy/rustfmt 组件、cargo-clippy 目标被带成 android 的坑、
  门禁命令、横屏行为与 openjdk-17 实测
- 新增 README.termux.md(分支定位/已验证矩阵/快速开始/边界/路线图),
  两份主 README 的 Termux 条目链过去
2026-11-07 09:33:21 +08:00
AskaEth 7b971dc75d fix(java): Termux 幂等安装报「未走网络」,vendors 在 Termux 上展示 termux 行
- termux::install 返回 (home, reused),复用时 downloaded=None,
  CLI 不再显示「0 字节 / 0 个文件」
- java vendors 在 Termux 上把 termux 插到顺序最前(仅展示,默认回退序不变)
2026-11-07 09:33:20 +08:00
AskaEth 48d1ee6616 feat(java): Termux 上自动安装系统 openjdk(内置镜像 + 隔离 apt 状态)
- 新增 game::java::termux:内置 SAU/TUNA/USTC/官方四个 termux-main 镜像,
  按顺序 apt-get update+install;lists/cache 隔离在 $PREFIX/tmp/ull-java-termux,
  不改用户 apt 配置;装完从 $PREFIX/lib/jvm 定位,幂等(已装则不走网络)
- JdkVendor 增加 termux(17/21/25),--vendor termux 只走系统包;
  Termux 上缺省安装先试系统包,失败再走归档链并带出每一跳原因
- standard_roots 已能扫 $PREFIX/lib/jvm(上一条提交),装完即被 java list 发现
- CLI/presenter 的 vendor 展示与帮助文案同步;设置白名单与契约测试同步
- 文档补 Termux 上的 Java 行为
2026-11-07 09:33:19 +08:00
AskaEth 1e0fc44fed docs(packaging): 记录 Termux glibc 构建与 termux-x11 运行配方
新增 packaging/docs/termux-glibc-build.md:glibc 工具链(Rust gnu 组件 +
patchelf + clang/lld + Debian libgcc crt)、构建环境(LD_PRELOAD/CA/USTC 镜像)、
termux-x11 运行与截图取证、GPU 现状与已知边界。
支持矩阵四处同步:Termux 行从「GUI 未验证」改为「源码自建、CPU 渲染单机验证过」,
仍标注实验级、不在发布矩阵内。
2026-11-07 09:33:19 +08:00
AskaEth 4582a025db fix(fonts): Termux 上注册 Android 系统 CJK 字体,消除中文方框
glibc 构建跑在 Termux 里时 target_os 仍是 linux,fontdb 只扫 Linux 常规
字体目录,/system/fonts 不在其中,中文实例名与界面文案全渲染成方框。
在字体求值入口按运行时识别 Termux,把 NotoSansCJK 等候选注册进
cosmic-text 的字体库(OnceLock 只加载一次,找不到就静默放过)。
app-iced 直接依赖 unlucky-core 复用平台识别,不再本地复制环境判断。
2026-11-07 09:33:18 +08:00
AskaEth e090c67297 feat(platform): 识别 Termux 宿主并把 $PREFIX/lib/jvm 纳入 JDK 扫描
- 新增 platform::termux:用 TERMUX_VERSION / 默认 PREFIX 识别 Termux,
  推导 $PREFIX 与 $PREFIX/lib/jvm,不把路径写死进调用方
- java::discover 的 standard_roots 在 Unix 分支追加 Termux 的 JVM 根,
  环境可注入(standard_roots_in),补上「Termux 装了 openjdk 但没设
  JAVA_HOME/PATH 就发现不了」的盲点
- 单测覆盖识别、负例与扫描结果
2026-11-07 09:33:18 +08:00
AskaEth ac2e163eb8 Merge branch 'feat/m156guard-fix-bash-e-silent-kill-in-guar'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-07 09:20:21 +08:00
AskaEth aa42496561 fix(ci): M156 修 guards.yml 三处 bash -e 静默自杀(presenter-no-ui 从未绿过)
M155 钉死:guards.yml 的 `hits=$(… | grep -iE …)` 在 grep 零命中(exit 1)时被
runner 默认的 `bash -e {0}` 拖红整步,且脚本里 `set -uo pipefail` 关不掉 -e,
所以一条 `::error::` 都打不出来 —— presenter-no-ui 自 M89 引入起从未绿过一次。

- presenter 依赖树 step:`hits=` 补 `|| true`(零命中回到绿路,真命中仍红)
- core 依赖树 step:裸 `tree=$(cargo tree …)` 改 `if ! tree=$(…)` 守卫,
  与 presenter / design 两处对称,cargo tree 失败时打注解而不是静默退出
- unsafe-scope:`actual=$(git grep …)` 末尾补 `|| true`,零匹配不再中止整步
- 文件头加一条警示:「本文件所有 run: 都在 bash -e 下,裸管道/裸赋值会被静默杀死」

验证(M155 方法:step 脚本从 yml 原样切出、`bash -e` 执行):
presenter 零命中 改前 exit1/0 注解 → 改后 exit0 打 OK;真命中仍 exit1/2 注解;
core cargo tree 失败 改前 exit101/0 注解 → 改后 exit1/1 注解;unsafe 零匹配
改前 exit1/0 注解 → 改后 exit1/2 注解;两处正常路径均仍绿。pyyaml 可解析,398 行。
2026-11-07 09:15:10 +08:00
AskaEth 4bb1591aca Merge branch 'feat/m152-packlast-finish-modpack-import-expo'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-07 08:34:11 +08:00
AskaEth be280c16a3 fix(core): ZIP64 第二道闸不再重复累计预检总量(复审 P2-1)
复审在 tip 54c2d75 上实证的缺陷:`container()` 把 `preflight()` 返回的**全量**声明尺寸
当成写循环累加基数,循环里又对每个文件 `+= bytes.len()`,于是真实总量只要过半阈值
(~2.15 GiB)就被误拒,而且拒在内容读出来之后——"闸在内容进内存之前"的性质同时失效。

两处修:
1. 记账拆成独立类型 `Written`,**从清单 JSON 长度起算**,与预检返回值无关;预检的返回值
   现在只给测试与诊断看(`let _ =`,文档写明不许当基数)。
2. 内容改成 1 MiB 缓冲流式写进 zip(`COPY_CHUNK`),不再 `std::fs::read` 整份摊在内存里。
   第二道闸看的是"已写字节数",与内存占用解耦;顺带把"拿预检 total 当基数"这类写法
   在内存侧也变成无害的小错而不是几个 GiB 的读。

测试(按塔台资源令,默认套件不再真写 2.5 GB):
- `write.rs` 新增 `the_container_does_not_double_count_the_preflight_total`:把阈值注入成
  几十字节(`container`/`preflight`/`Written` 都收 `limit`,生产路径恒为
  `zip::ZIP64_BYTES_THR`),构造"正确实现 70 < 90 通过 / 重复累计 110 ≥ 90 被拒"的窗口,
  **0.01 秒**钉住 P2-1;实测把 bug 写回去即红。
- `write.rs` 新增 `the_second_gate_counts_from_the_manifest_length_not_the_preflight_total`:
  直接对 `Written` 断言 2.5 GB 不拒、撞阈值必拒。
- `zip64.rs` 的 2.5 GB 稀疏文件端到端用例保留但 `#[ignore]`(真跑要 ~70 秒 deflate 与一大片
  内存/页面文件,C 盘页面文件已被上一轮撑到 34.8G),实测过一次成功:条目声明尺寸原样
  2 500 000 000、产物只有几 MB。跑法写在该用例的文档里。

门禁:fmt 0 / clippy -p unlucky-core --all-targets 0 / cargo test -p unlucky-core 1033 passed
0 failed(lib 480 passed + 1 ignored)。
2026-11-07 08:22:47 +08:00
AskaEth d728ed836f feat(core): M152 整合包收尾——同路径遮蔽逐文件上报 + ZIP64 拒写预检
导入侧把「同名文件冲突」从整包一个策略推进到逐文件事实:同一实例路径被本包多份声明
命中时(跨侧 overrides/client-overrides,或同侧重复),按叠层顺序(Both → Client)定
胜者,其余标 PlanOverride::shadowed(winner / winner_dir / same_bytes),逐条留
pack.import.note.override_shadowed 注记;phase 只遍历未被遮蔽的条目,不再白读字节,
报告里也不再出现"两条 Installed、前一条的哈希其实没落地"。

不新增 FileAction/ConflictReason 变体:这三个枚举被 crates/presenter 的 convert.rs
穷尽 match,而那个 crate 不在本 purpose 的作用域内,所以走既有的 ImportIssue 通道
(与 server-overrides/ 被跳过同一条)。

导出侧那条 ZIP64 闸原来够不着:先 std::fs::read 整个覆盖文件进内存再累加判断,真要
导出 4 GiB 文件的实例,内存先炸、用户永远看不到那句 pack_limits。改成写前按
metadata().len() 做声明尺寸预检,超阈值在任何字节进内存之前就报;写循环里的增量检查
留作第二道。写 zip 的实现搬到 format/write.rs 以守 400 行。

测试:pack/tests/zip64.rs(稀疏文件真跑到 ZIP64 分支,含累计口径与对照)、
tests/import_layering.rs(同路径不同哈希 / 同哈希不同路径 / 叠层顺序 / 基线记胜者)、
unknown_fields.rs 补第 4 条假边(mrpack files[].extra + 未知哈希算法)。
五条变异各自变红(遮蔽 no-op 6 红、按哈希判重 6 红、翻转叠层 2 红、错误码改 io 3 红、
预检不累计则预检单测红且端到端用例从 0.01s 变成 95s/约 4.5 GiB 内存)。
2026-11-07 08:22:47 +08:00
AskaEth 37de00b07e Merge branch 'feat/m154-verb022-bump-workspace-version-to-0'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-07 08:16:39 +08:00
AskaEth 06fe12d811 chore(release): M154 版本升到 0.2.2(第一个稳定通道版本,D51)
- 根 Cargo.toml `[workspace.package] version` 0.2.1 → 0.2.2;`crates/{agent,app-iced,cli,palette-extract,
  presenter}` 里 9 处 intra-workspace path 依赖的 version 约束一并对齐(M137 既定做法)。`crates/app` 是
  workspace `exclude` 的 Slint 退役树,manifest 不参与解析,原样保留。
- Cargo.lock 由 cargo 再生成:只有 8 个 `unlucky-*` 成员条目从 0.2.1 变 0.2.2,第三方依赖(含版本号恰好
  也是 0.2.1 的 hexf-parse / windows-link / windows-threading / xkeysym / yazi)一字未动。这一步不是可选:
  CI 与发布流水线全走 `--locked`(ci.yml:236,274 / release-build.yml:135,142 / make_artifacts.py:50),
  lock 不跟就是「tag 一打就红」。
- packaging 文档里的**示例 tag** 跟到 v0.2.2:docs/artifacts-and-version.md 两处(命名规则示例 + 「tag 说
  X、产物里写 Y」的事故示例)、docs/build-and-ci.md 三处(`--version v…`、两次 `gh workflow run --ref v…`)、
  docs/profiles-and-measurements.md 的「同一槽位现在叫 `unlucky-launcher_0.2.2_windows_x64.exe`」、
  make_manifest.py 四处(Usage 示例 / `normalize_version` docstring / `--version` 的 argparse help)。
- **历史注记一律不动**:docs/runtime-deps.md 的「v0.2.1 首次真跑即红在工具链体检」(M140 事故记录)、
  `.github/workflows/release.yml:71` 同一事故的另一处记录、`crates/core` 更新模块测试里的样本版本串
  (`checker_cases.rs` / `version_cases.rs`,是解析器测试夹具,不是发布事实)、`microsoft.rs:224` 的
  `21.0.13.0.2.1` 负例。
- README.md / README.zh-CN.md、packaging/release-notes.md 核实后**无需改动**:三份文件里没有任何写死的
  版本号——下载表是 `<version>` / `<版本>` 占位符(M153 刚统一的口径,含非发布档位标「暂未发布」的行,
  原样保留未回退),release-notes 正文是 `{{VERSION}}` / `{{SUMS}}` 模板占位符。

验证:`cargo check -p unlucky-cli --all-targets --locked` 过(编译日志里 unlucky-core/agent/cli 均报 v0.2.2)、
`cargo test -p unlucky-cli --lib --locked` 42 passed 0 failed、`cargo metadata --locked --offline` 过(全
workspace manifest + lock 一致)、`python3 packaging/make_manifest.py --self-test` ok、
`ull --version` 实跑输出 `ull 0.2.2(unlucky-cli)`。
2026-11-07 08:13:29 +08:00
AskaEth c38fbe4068 Merge branch 'feat/m150-ghrsrc-implement-githubreleasesourc'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-07 08:06:31 +08:00
AskaEth f001b93942 feat(core): M150 新增 GitHub Releases 更新源(D50)
D50 定案 GitHub Releases 为唯一更新源,清单地址固定为
`releases/latest/download/manifest.json`。此前 update 模块只有对拍用的
DirSource,这次把真实源补上。

- `update::github::GitHubReleaseSource` 实现 `UpdateSource`:清单从
  `<base>/<owner>/<repo>/releases/latest/download/manifest.json` 取,
  产物按 D49 命名规则(文件名)拼到同一条 latest/download 路径下;清单里
  已写完整 http(s) 地址的原样使用。
- 网络全走 `core::download`,不新造栈:包体交给引擎 `download()`(镜像展开
  → 地区判定 → 健康度排序 → 退避 → 续传 → 原子落盘),清单走引擎的
  `plan()` + 共享 client 逐候选取文本(与 `game::java` 的一锤子 GET 同一手法)。
  两条路径都吃到 mirrors.json 与地区档案里生效的 gh-proxy 前缀。
- 可配置面只有 owner/repo(默认 AskaEth/Unlucky-Launcher)与 base(默认
  https://github.com,测试指向本地假服务);渠道只有 stable 有清单,
  beta/alpha/nightly 返回 Ok(None)(D50「先只发 stable」),清单 404 也算
  「还没发布」而不是错误。
- `UpdateErrorKind` 新增 `Network`:远端取字节失败(连不上/超时/5xx/候选全挂)
  与本地 IO 分开——前者可重试,后者重试无意义。引擎把逐源失败压成
  AllSourcesFailed,本源从失败摘要里还原「所有源都 404」为 SourceMissing
  (产物没上传不该被报成网络抖动)。
- 校验仍只有一处:包体请求只带 expected_size,长度/摘要双检留在
  `update::verify`(与 DirSource 同口径)。
- 单测 13 条,全程离线(假 HTTP 服务在 127.0.0.1):固定清单地址、产物 URL
  解析、自定义 base/owner/repo、清单 404/非 JSON/字段非法/渠道不符/5xx 各自
  归到白名单分类、产物下载、产物 404 → SourceMissing、镜像优先下 gh-proxy
  前缀(清单与产物都验)、与 DirSource 同清单同产物选择结果一致。
2026-11-07 08:06:22 +08:00
AskaEth afdbc9fd51 Merge branch 'feat/m153-docsweep-close-forgotten-doc-findin'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-07 08:00:04 +08:00
AskaEth 7946e5b804 docs(readme,cli,packaging): M153 清账四条被排忘的文档类 finding
① README 两版下载表:linux-x64 / linux-arm64 / macos-arm64 / macos-x64 四行补「not yet published /
暂未发布」标记(与 M143 的 windows-arm64 行同款措辞),表后改成「本版只发布 windows-x64」的口径,
并说明发布 job 只留 PUBLISH_SLOTS 里的槽位(现住 release-publish.yml);顺带把「produced by the CI
matrix」那句改成「矩阵里有、默认发布跑不编、all_slots=true 才编来取证」。

② packaging/make_artifacts.py 两处 docstring 还写 windows-arm64 是 cross-built:按 M140 后的原生
windows-11-arm 事实改写。(release-notes.md 的同一说法在 M139/M142 已改掉,本次核实无残留,未再动。)

③ cli 缺 id 的 hint:删掉「(id 不进仓库)」并改成与 cli/help.rs 一致的「官方构建已内置、环境变量优先、
自建时构建期注入」;Secret 透传的事实留在同处代码注释。

④ README 两版文件清单表:release.yml 从「six-slot build / 六槽位构建」改成「两个 Windows 槽位原生
构建 → 只发布 windows-x64;all_slots=true 才整表六槽位取证」。

纯文案,不动任何构建或运行时逻辑。
2026-11-07 07:57:32 +08:00
AskaEth 5c96227881 Merge branch 'feat/m151-relmanif-generate-update-manifest-i'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-07 07:55:17 +08:00
AskaEth f9607c1246 docs(packaging): M151 复审 P2-1 两处未决注释按 D51 定案改写(方案甲/六件套/v0.2.2 起适用) 2026-11-07 07:52:50 +08:00
AskaEth e3ee20bdcd feat(packaging): M151 发布流水线生成更新清单 manifest.json(D50)
清单挂 GitHub Release 资产,固定文件名 manifest.json(D50)。字段形状的权威是 M146 的
crates/core/src/update/manifest.rs(ReleaseManifest,deny_unknown_fields + 逐字段校验),
生成器只负责把那套形状填对,不另立 schema。

同源,不双写:packaging/make_manifest.py 的每个 sha256 直接读 Release 自己的
SHA256SUMS.txt,size 取自同一批文件的 stat();产物名里的版本段必须等于 --version,
tag 与 Cargo.toml 漂了就没有产物可对,当场失败。CI 在「裁剪发布白名单后重算
SHA256SUMS」之后调它,所以清单描述的是真正要发的资产,不是构建出来的全部。
make_artifacts.py 只加 --manifest 开关,用子进程调同一份脚本(本地跑也产出
manifest.json),本文件继续是产物命名的唯一真相源。

只放启动器产物:DTO 的 Artifact 没有角色字段,Release::artifact_for() 取第一个
target 命中的产物,把 ull 并列写进去会让「覆盖哪个文件」取决于数组顺序;ull 照旧进
SHA256SUMS.txt 与 Release。

url 两种形态:默认裸文件名(DirSource 口径,本地/内网用);CI 传 --asset-base-url
拼绝对地址并钉 tag(不是 latest),清单里每条都指向与它自报版本相符的字节。

自验:--self-test 用 packaging/tests/manifest.golden.json 逐字节对拍(覆盖裸 exe 与
tar.gz 两种形态,并钉住「ull 不进清单」),再跑 14 条负例证明严格解析不是摆设;
发布步会先跑它再产文件。真 DTO 对拍已在本机用 M146 的 ReleaseManifest 实测:
golden 解析通过(2 个产物、min_supported、force 均正确落位),14 条篡改全部被拒。

未决(报告带利弊,未自行改发布默认值):releases/latest/download/* 只指向非 prerelease
的最新 Release,而发布默认 prerelease=true。

Refs: D50, M146
2026-11-06 22:15:58 +08:00
47 changed files with 2913 additions and 394 deletions

No files matched your search

+14 -3
View File
@@ -1,5 +1,9 @@
name: Guards name: Guards
# ⚠ 本文件所有 `run:` 都在 `bash -e {0}` 下跑(GitHub 未指定 shell 时的默认值),脚本里的
# `set -uo pipefail` **关不掉 -e**:裸管道 / 裸赋值 `x=$(… | grep …)` 零命中时会把整步静默杀死,
# 一条 `::error::` 都不打(M142 在 release-build.yml 栽过,M156 在这里补账)。探测一律 `if ! x=$(…)` 或 `$(… || true)`。
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error): # 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查 # 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层 # 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
@@ -54,7 +58,11 @@ jobs:
run: | run: |
set -uo pipefail set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
tree=$(cargo tree -p unlucky-core --edges normal --prefix none) # 与下方 presenter / design 的 `if ! tree=$(…)` 对称:cargo tree 自己失败(改名 / 解析失败)时裸赋值会被 -e 静默杀死,只剩 exit code 1 无注解。
if ! tree=$(cargo tree -p unlucky-core --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-core 执行失败 —— crate 是否仍叫 unlucky-core?"
exit 1
fi
echo "$tree" echo "$tree"
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许" echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
@@ -109,7 +117,9 @@ jobs:
exit 1 exit 1
fi fi
echo "$tree" echo "$tree"
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden") # 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头):
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
if [ -n "$hits" ]; then if [ -n "$hits" ]; then
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:" echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
printf '%s\n' "$hits" | sed 's/^/ - /' printf '%s\n' "$hits" | sed 's/^/ - /'
@@ -318,9 +328,10 @@ jobs:
} }
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开, # 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。 # 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
# `git grep` 零匹配 exit 1 + pipefail → 裸赋值同样会被 -e 静默杀死(见文件头)。
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
if scan "$f"; then printf '%s\n' "$f"; fi if scan "$f"; then printf '%s\n' "$f"; fi
done | LC_ALL=C sort) done | LC_ALL=C sort || true)
echo "期望(恰好这一个):$EXPECTED" echo "期望(恰好这一个):$EXPECTED"
echo "实际扫到的集合:" echo "实际扫到的集合:"
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
+27 -1
View File
@@ -94,6 +94,27 @@ jobs:
sha256sum -c SHA256SUMS.txt sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt cat SHA256SUMS.txt
- name: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
shell: bash
env:
TAG: ${{ github.ref_name }}
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
run: |
set -euo pipefail
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
python3 packaging/make_manifest.py --self-test
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
# 后者在 Release 正文里已有,两处各写一份迟早漂。
python3 packaging/make_manifest.py \
--dist dist \
--version "$TAG" \
--channel stable \
--asset-base-url "$RELEASE_BASE/$TAG" \
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- name: 生成 Release 说明(支持矩阵模板) - name: 生成 Release 说明(支持矩阵模板)
shell: bash shell: bash
env: env:
@@ -141,7 +162,12 @@ jobs:
;; ;;
esac esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。 # Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md) # manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
# D51 定案(2026-11-06,方案甲):GitHub 的 `releases/latest/download/*` 只指向**非
# prerelease** 的最新 Release。稳定版发布时显式 dispatch `prerelease=false`(latest 随之生效,
# 客户端地址不变);push tag 默认仍 prerelease(视为预发布观察,不进稳定通道)。
# 发版 checklist 第六件 = 确认 prerelease 标记与通道一致;v0.2.2 起首个适用。
assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。 # 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
gh release upload "$TAG" "${assets[@]}" --clobber gh release upload "$TAG" "${assets[@]}" --clobber
Generated
+8 -8
View File
@@ -4982,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]] [[package]]
name = "unlucky-agent" name = "unlucky-agent"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
@@ -4997,7 +4997,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-app-iced" name = "unlucky-app-iced"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"iced", "iced",
"image", "image",
@@ -5011,7 +5011,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-cli" name = "unlucky-cli"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -5022,7 +5022,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-core" name = "unlucky-core"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"flate2", "flate2",
"futures-util", "futures-util",
@@ -5041,15 +5041,15 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-design" name = "unlucky-design"
version = "0.2.1" version = "0.2.2"
[[package]] [[package]]
name = "unlucky-i18n" name = "unlucky-i18n"
version = "0.2.1" version = "0.2.2"
[[package]] [[package]]
name = "unlucky-palette-extract" name = "unlucky-palette-extract"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"image", "image",
"unlucky-design", "unlucky-design",
@@ -5057,7 +5057,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-presenter" name = "unlucky-presenter"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
+1 -1
View File
@@ -30,7 +30,7 @@ members = [
exclude = ["crates/app"] exclude = ["crates/app"]
[workspace.package] [workspace.package]
version = "0.2.1" version = "0.2.2"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"] authors = ["AskaEth <i@yeij.top>"]
+19 -17
View File
@@ -173,10 +173,11 @@ This list is the reason to believe the rest. None of it is hidden behind marketi
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
management from the GUI is the instance-wizard work still outstanding, and settings change as you management from the GUI is the instance-wizard work still outstanding, and settings change as you
edit them rather than on a Save. edit them rather than on a Save.
- **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`, - **Linux and macOS artifacts are unverified, and this release does not even build them.** The
`linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not reference machine is Windows; the `linux-x64`, `linux-arm64`, `macos-arm64` and `macos-x64` slots
been downloaded and run end to end. Treat them as untested output, not as "supported because the sit in the CI matrix but the default release run skips them, and they have never been downloaded and
matrix lists them". run end to end. `gh workflow run release.yml -f all_slots=true` builds them for evidence. Treat them
as untested output, not as "supported because the matrix lists them".
- **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why - **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why
the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts
them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS
@@ -195,7 +196,7 @@ This list is the reason to believe the rest. None of it is hidden behind marketi
- **No native file chooser.** Import takes drag-and-drop or a pasted path. - **No native file chooser.** Import takes drag-and-drop or a pasted path.
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is - **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes: possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
[`README.termux.md`](README.termux.md)); it was verified once on CPU rendering only. [`README.termux.md`](README.termux.md)); it was verified on-device twice: once on CPU software rendering, once with GPU acceleration via a self-built Mesa/turnip stack (see the evidence in README.termux.md).
## Download and run ## Download and run
@@ -208,19 +209,20 @@ Grab the file for your platform from
|------|-------|-------| |------|-------|-------|
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run | | `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below | | `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below |
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory | | `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory — **not yet published** |
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same | | `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same — **not yet published** |
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same | | `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not yet published** |
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same | | `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not yet published** |
| every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset | | every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly | | all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
`windows-arm64` is the one row documented but **not in the current release**: the CI ARM64 runner is **This release publishes `windows-x64` and nothing else.** The publish job keeps only the slots named
missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress. in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before
The row stays because the architecture is still supported and the slot still builds (unpublished) in it writes `SHA256SUMS.txt`, so a row marked **not yet published** is a naming rule, not a downloadable
the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in asset. Of those rows `windows-arm64` is the one CI still builds — natively, on GitHub's
`.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the `windows-11-arm` runner, for evidence — but that slot's toolchain check has not gone green yet, so its
only downloadable Windows asset. `.exe` stays out of the release. The row stays because the architecture is still supported; it rejoins
the download list once the build is green.
Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level
directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
@@ -294,7 +296,7 @@ merge. The macOS probe is not there yet — manual dispatch only, result recorde
|----------|---------| |----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard | | `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
| `.github/workflows/release.yml` | tag `v*` → six-slot build → single-file artifacts + `SHA256SUMS.txt` → GitHub Release | | `.github/workflows/release.yml` | tag `v*` → build both Windows slots (`windows-x64` + `windows-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` only; `-f all_slots=true` builds all six slots for evidence) |
## Architecture and module map ## Architecture and module map
+15 -13
View File
@@ -140,8 +140,9 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。 - **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改 设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。 ——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
- **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、 - **Linux 与 macOS 产物未验证,而且本版连构建都不跑。** 参考机是 Windows;`linux-x64`、`linux-arm64`、
`macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是 `macos-arm64`、`macos-x64` 四档只躺在 CI 矩阵里,默认的发布流程会跳过它们,也还没被下载下来端到端跑过;
`gh workflow run release.yml -f all_slots=true` 才编它们来取证。把它们当**未经测试的产出**,而不是
「矩阵里有就算支持」。 「矩阵里有就算支持」。
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置 - **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉 (macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉
@@ -157,7 +158,7 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
文件是被静默覆盖的。 文件是被静默覆盖的。
- **内容库的安装进度是阶段级**,不是字节级。 - **内容库的安装进度是阶段级**,不是字节级。
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。 - **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),目前只在 CPU 渲染下验证过一次。 - **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。
## 下载与运行 ## 下载与运行
@@ -169,17 +170,18 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|------|------|------| |------|------|------|
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 | | `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 | | `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 |
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 | | `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 —— **暂未发布** |
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 | | `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **暂未发布** |
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 | | `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— **暂未发布** |
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 | | `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— **暂未发布** |
| 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 | | 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 | | 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
`windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的 **本版只发布 `windows-x64`。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进 (`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`, **暂未发布** 的行是命名规则,不是本版可下载的资产。这几行里 `windows-arm64` 是 CI 唯一真跑的那档:M140 起在
它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。 GitHub 原生 `windows-11-arm` runner 上编来取证,但该槽位的工具链体检还没绿,所以它的 `.exe` 不进 Release;
行不删是因为架构仍要支持,绿了就把键加回 `PUBLISH_SLOTS`。
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe` UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款 (一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
@@ -240,7 +242,7 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
|----------|------| |----------|------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 | | `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 六槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release | | `.github/workflows/release.yml` | tag `v*` → 构建两个 Windows 槽位(`windows-x64` + `windows-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(只发布 `windows-x64`;`-f all_slots=true` 才整表六槽位取证) |
## 架构与模块地图 ## 架构与模块地图
+15 -3
View File
@@ -1,6 +1,6 @@
# Termux 适配:方案、改动清单与合并指南 # Termux 适配:方案、改动清单与合并指南
> 分支:`termux-glibc`(基于 `origin/dev`,14 个提交,HEAD `38d3024`) > 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。 > 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
## TL;DR ## TL;DR
@@ -97,8 +97,11 @@
## 合并指南 ## 合并指南
1. `git fetch origin && git rebase origin/dev`(分支基于 `7d8b3b9` 之后的 dev; 1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
dev 若前进,先 rebase 再推)。 (M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里,
按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。
与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README /
`crates/app-iced/Cargo.toml`)。
2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` / 2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` /
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。 `test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
3. 本机已知、**不影响 PC CI** 的三条失败: 3. 本机已知、**不影响 PC CI** 的三条失败:
@@ -107,6 +110,15 @@
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂) - `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。 4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。 5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。
6. **PC 门禁发现的两处、已在 M156 就地修掉**(都只在 Windows/PC 侧才暴露,
所以在 Termux 上跑门禁看不出来):
- `game/java/discover.rs` 的两个 `#[cfg(unix)]` 测试,导入没跟着 gate
→ Windows 上 `-D warnings` 判「未使用导入」。已把两个 `use` 同样 gate 成 `#[cfg(unix)]`;
代价是这两个用例在 Windows 上不编译(由 Linux CI 覆盖)。
- 本分支把 `game/java/install.rs`(456 行)与 `game/install/libraries.rs`(409 行)
顶过了 400 行红线(`guards.yml` 的 `file-size`)。已按步骤拆出
`install/termux_install.rs`、`install/one.rs`、`install/libraries/arm64_natives.rs`,
行为不变、用例原样留在原处。
## 验证矩阵(本机) ## 验证矩阵(本机)
+1 -1
View File
@@ -13,7 +13,7 @@ publish.workspace = true
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现 # 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。 # 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.2" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), # 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。 # 工具结果 / 审计记录 / 事件流都用 serde derive。
+5 -5
View File
@@ -22,15 +22,15 @@ path = "src/lib.rs"
[dependencies] [dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.1" } unlucky-design = { path = "../design", version = "0.2.2" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。 # 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.2" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— # M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 # 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。 # 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", version = "0.2.1" } unlucky-presenter = { path = "../presenter", version = "0.2.2" }
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。 # UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`), # 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
@@ -60,11 +60,11 @@ image = { version = "0.25", default-features = false, features = [
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli → # 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull` # core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。 # 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
unlucky-cli = { path = "../cli", version = "0.2.1" } unlucky-cli = { path = "../cli", version = "0.2.2" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced, # M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。 # 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
unlucky-i18n = { path = "../i18n", version = "0.2.1" } unlucky-i18n = { path = "../i18n", version = "0.2.2" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`, # M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` / # 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
+2 -2
View File
@@ -22,10 +22,10 @@ path = "src/lib.rs"
# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线, # 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线,
# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。 # 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.2" }
# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影, # agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影,
# core 侧五个端口在 src/commands/agent/ports/ 装配)。 # core 侧五个端口在 src/commands/agent/ports/ 装配)。
unlucky-agent = { path = "../agent", version = "0.2.1" } unlucky-agent = { path = "../agent", version = "0.2.2" }
# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。 # `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
+5 -3
View File
@@ -84,10 +84,12 @@ pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError {
AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()), AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()),
// M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路: // M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路:
// 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。 // 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。
// M149:官方发布构建已把 id 烘进二进制(真值走仓库 Secret,不进 git 文件),所以能走到这个
// hint 的只可能是自建且没注入 id 的二进制——文案与 `cli/help.rs` 对齐:官方构建已内置。
AuthError::Unsupported(_) => Some(format!( AuthError::Unsupported(_) => Some(format!(
"先配 Azure 公共客户端 id(id 不进仓库):\ "官方构建已内置微软登录所需的客户端 id,装完即可用;自建构建得自己给:\
`{env}=<id> %prog% accounts add msa`;\ 运行期 `{env}=<id> %prog% accounts add msa`(优先于内置值);\
想让二进制自带就构建时注入 `{env}=<id> cargo build`。环境变量优先于构建期注入。", 或构建时注入 `{env}=<id> cargo build` 让二进制自带。",
env = unlucky_core::auth::microsoft::CLIENT_ID_ENV env = unlucky_core::auth::microsoft::CLIENT_ID_ENV
)), )),
AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()), AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
+5 -2
View File
@@ -18,7 +18,10 @@
//! //!
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义; //! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧 //! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
//! 优先级落地,不自创排序。 //! 优先级落地,不自创排序。**同一实例路径被本包多份声明命中时**(跨侧或同侧重复),
//! 逐条算清楚:胜者按上面这条规则定,被遮蔽的那条不读字节、只留一条
//! `pack.import.note.override_shadowed` 注记说明「谁赢了 / 两份内容是否逐字节相同」
//! (见 [`plan::Shadowed`])。一路径一行,不再出现"两条 Installed、前一条没落地"。
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API //! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为** //! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。 //! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
@@ -77,7 +80,7 @@ pub use fetch::{
}; };
pub use merge::{MergeAction, MergeDecision}; pub use merge::{MergeAction, MergeDecision};
pub use pipeline::baseline_relative; pub use pipeline::baseline_relative;
pub use plan::{ImportPlan, PlanOverride, PlanRemote, SuffixVariant}; pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
pub use report::{ pub use report::{
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue, keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile, ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
+12 -2
View File
@@ -22,6 +22,11 @@ use crate::download::verify::{self, digest_bytes};
use crate::download::HashAlgo; use crate::download::HashAlgo;
/// 阶段一:覆盖条目落暂存 + 三方合并。 /// 阶段一:覆盖条目落暂存 + 三方合并。
///
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
/// "两条 Installed、前一条的哈希其实没落地"。
pub(crate) async fn extract_overrides( pub(crate) async fn extract_overrides(
request: &ImportRequest<'_>, request: &ImportRequest<'_>,
archive: &PackArchive, archive: &PackArchive,
@@ -31,9 +36,14 @@ pub(crate) async fn extract_overrides(
report: &mut ImportReport, report: &mut ImportReport,
next: &mut PackBaseline, next: &mut PackBaseline,
) -> Result<(), ImportError> { ) -> Result<(), ImportError> {
let total = plan.overrides.len(); let live: Vec<&super::plan::PlanOverride> = plan
.overrides
.iter()
.filter(|entry| entry.shadowed.is_none())
.collect();
let total = live.len();
let target = staging.target().to_path_buf(); let target = staging.target().to_path_buf();
for (index, entry) in plan.overrides.iter().enumerate() { for (index, entry) in live.iter().enumerate() {
tell( tell(
&request.progress, &request.progress,
ImportStage::Extract, ImportStage::Extract,
+11 -13
View File
@@ -17,19 +17,24 @@
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径 //! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径 //! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。 //! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
//! **同一实例路径被多份声明命中时逐条算清楚**([`PlanOverride::shadowed`]):胜者按上面
//! 这条规则定,被遮蔽的那条不再读字节、只留一条 `NOTE_OVERRIDE_SHADOWED` 注记说明
//! "谁赢了"和"两份内容是否逐字节相同"。这是"整包一个策略"到"逐文件事实"的那一步。
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删 //! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在 //! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
//! [`paths`]。 //! [`paths`]。
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary` //! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。 //! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
mod layer;
pub mod paths; pub mod paths;
pub use layer::Shadowed;
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant}; pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant};
use super::super::pack::{ use super::super::pack::{
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest, curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
PackOverrides, PackSide, PackSide,
}; };
use super::error::ImportError; use super::error::ImportError;
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent}; use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
@@ -51,6 +56,8 @@ pub struct PlanOverride {
pub disabled: bool, pub disabled: bool,
/// 可选注记(`.disabled` / `.old`)。 /// 可选注记(`.disabled` / `.old`)。
pub note: Option<ImportIssue>, pub note: Option<ImportIssue>,
/// 被同包另一条声明遮蔽时的事实(`None` = 本条目就是这条路径的最终胜者)。
pub shadowed: Option<Shadowed>,
} }
/// 一条要补全/下载的远端条目。 /// 一条要补全/下载的远端条目。
@@ -136,7 +143,7 @@ impl ImportPlan {
let mut overrides: Vec<PlanOverride> = Vec::new(); let mut overrides: Vec<PlanOverride> = Vec::new();
let mut skipped_server_files = 0_usize; let mut skipped_server_files = 0_usize;
for group in ordered_overrides(&manifest) { for group in layer::ordered(&manifest) {
if group.side == PackSide::Server { if group.side == PackSide::Server {
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源: // Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。 // install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
@@ -153,9 +160,11 @@ impl ImportPlan {
size: file.size, size: file.size,
disabled: variant.is_disabled(), disabled: variant.is_disabled(),
note: suffix_note(variant, &file.path), note: suffix_note(variant, &file.path),
shadowed: None,
}); });
} }
} }
layer::resolve(&mut overrides, &mut notes);
if skipped_server_files > 0 { if skipped_server_files > 0 {
notes.push(ImportIssue::new( notes.push(ImportIssue::new(
keys::NOTE_SERVER_OVERRIDES_SKIPPED, keys::NOTE_SERVER_OVERRIDES_SKIPPED,
@@ -232,17 +241,6 @@ impl ImportPlan {
} }
} }
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
fn ordered_overrides(manifest: &PackManifest) -> Vec<&PackOverrides> {
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
groups.sort_by_key(|group| match group.side {
PackSide::Both => 0_u8,
PackSide::Client => 1,
PackSide::Server => 2,
});
groups
}
/// 选一个加载器并翻译成 [`LoaderTarget`]。 /// 选一个加载器并翻译成 [`LoaderTarget`]。
fn select_loader( fn select_loader(
manifest: &PackManifest, manifest: &PackManifest,
+208
View File
@@ -0,0 +1,208 @@
//! 覆盖目录的**叠层顺序**与**同路径遮蔽**:导入计划里"逐文件"那一半的判据(纯计算,无 IO)。
//!
//! 拆成单独一个文件有两个原因:①`plan.rs` 要守 400 行;②这一层的两条规则(先按侧排、
//! 再按路径结算胜负)是导入器里唯一"我们自己定的确定性规则",值得放在一处被单独读完。
use std::collections::HashMap;
use super::super::report::{keys, FileSource, ImportIssue};
use super::PlanOverride;
use crate::addon::pack::{PackManifest, PackOverrides, PackSide};
/// 「这条覆盖条目被同一份包里的另一条声明遮蔽」。
///
/// 为什么不是新的 `FileAction` 变体:`FileAction` / `ConflictReason` / `ConflictChoice` 三个
/// 枚举被 `crates/presenter/src/host/addon/pack/convert.rs` **穷尽 match**(那个 crate 不在
/// `addon` 的作用域里)。所以"遮蔽"这件事走 [`ImportIssue`] 的 note 通道,与
/// `server-overrides/ 被跳过` 完全同一条通道。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shadowed {
/// 胜出那条的来源侧。
pub winner: FileSource,
/// 胜出那条的包内目录名(人读用,例如 `client-overrides`)。
pub winner_dir: String,
/// 两条声明的字节是否完全相同(`true` = 遮蔽不丢内容,只是重复了一份)。
pub same_bytes: bool,
}
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
pub(super) fn ordered(manifest: &PackManifest) -> Vec<&PackOverrides> {
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
groups.sort_by_key(|group| match group.side {
PackSide::Both => 0_u8,
PackSide::Client => 1,
PackSide::Server => 2,
});
groups
}
/// 同一实例路径被本包多份声明命中时:**叠层顺序里最后一条胜**,其余标记为遮蔽并逐条注记。
///
/// # 这是"逐文件"而不是"整包一个策略"的那一步
///
/// research/18 §3.3 的 A 级结论:mrpack 的 `overrides/` 与 `client-overrides/` 之间,
/// **规范层面只有极少数固定路径有优先级**,其余按 zip 条目顺序落盘;HMCL 连顺序都相反。
/// 上一版导入器对此的处理是"按侧排序后逐条写"——结果是对的,但**报告是错的**:同一路径
/// 会出现两条 `Installed`,其中前一条的哈希根本没落地,也没人说得清谁遮蔽了谁。
///
/// 现在把这层算清楚,逐条给出两个事实:
///
/// 1. **赢了的是哪一侧**(叠层顺序 = [`ordered`] 的 `Both → Client`,同组内按路径排序,
/// 所以与 zip 条目顺序无关——那是我们自己定死的确定性规则,不是规范);
/// 2. **两份内容是否逐字节相同**(相同 = 重复声明,遮蔽不丢内容;不同 = 真的换了一份)。
///
/// 判据只看模型里的 `sha1`:M110 扫包时是**现算**的真实内容哈希;手工构造的模型可能撒谎,
/// 但那最多让 `same_bytes` 这句注记不准,**谁落地不受影响**(胜者永远按叠层顺序定)。
pub(super) fn resolve(overrides: &mut [PlanOverride], notes: &mut Vec<ImportIssue>) {
let mut last: HashMap<String, usize> = HashMap::new();
for (index, entry) in overrides.iter().enumerate() {
last.insert(entry.path.clone(), index);
}
for index in 0..overrides.len() {
let path = overrides[index].path.clone();
let Some(&winner_index) = last.get(&path) else {
continue;
};
if winner_index == index {
continue;
}
let winner_dir = overrides[winner_index].dir.clone();
let loser_dir = overrides[index].dir.clone();
let same_bytes = overrides[winner_index].sha1 == overrides[index].sha1;
let conclusion = if same_bytes {
"两份内容逐字节相同,只落地一次"
} else {
"两份内容不同,以这一条为准"
};
notes.push(
ImportIssue::new(
keys::NOTE_OVERRIDE_SHADOWED,
format!(
"{loser_dir}/{path} 与 {winner_dir}/{path} 声明同一实例路径:叠层顺序 \
overrides/ 在前、client-overrides/ 在后(Modrinth 官方客户端语义),\
{conclusion}"
),
)
.with_path(path),
);
overrides[index].shadowed = Some(Shadowed {
winner: overrides[winner_index].source,
winner_dir,
same_bytes,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(dir: &str, path: &str, source: FileSource, sha1: &str) -> PlanOverride {
PlanOverride {
dir: dir.to_string(),
path: path.to_string(),
source,
sha1: sha1.to_string(),
size: 1,
disabled: false,
note: None,
shadowed: None,
}
}
#[test]
fn the_later_layer_wins_and_the_earlier_one_is_shadowed() {
// 叠层顺序:overrides/(Both)在前、client-overrides/(Client)在后 ⇒ 后者胜。
let mut overrides = vec![
entry(
"overrides",
"config/dup.toml",
FileSource::Overrides,
"aaaa",
),
entry(
"client-overrides",
"config/dup.toml",
FileSource::ClientOverrides,
"bbbb",
),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
let shadowed = overrides[0].shadowed.as_ref().expect("前一条要被标记");
assert_eq!(shadowed.winner, FileSource::ClientOverrides);
assert_eq!(shadowed.winner_dir, "client-overrides");
assert!(!shadowed.same_bytes, "同路径不同哈希");
assert!(overrides[1].shadowed.is_none(), "胜者不标遮蔽");
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].message_key, keys::NOTE_OVERRIDE_SHADOWED);
assert_eq!(notes[0].path.as_deref(), Some("config/dup.toml"));
let message = &notes[0].message;
for expected in [
"overrides/config/dup.toml",
"client-overrides/config/dup.toml",
"内容不同",
] {
assert!(message.contains(expected), "缺 {expected}:{message}");
}
}
#[test]
fn identical_bytes_are_reported_as_same_bytes() {
let mut overrides = vec![
entry(
"overrides",
"config/same.toml",
FileSource::Overrides,
"aaaa",
),
entry(
"client-overrides",
"config/same.toml",
FileSource::ClientOverrides,
"aaaa",
),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert!(overrides[0].shadowed.as_ref().expect("标记").same_bytes);
assert!(
notes[0].message.contains("逐字节相同"),
"内容一致要说清楚,别让人以为丢了东西:{}",
notes[0].message
);
}
#[test]
fn different_paths_with_the_same_bytes_are_never_shadowed() {
// 同哈希不同路径 = 两份文件,不是重复声明。按哈希去重会**静默丢一个文件**。
let mut overrides = vec![
entry("overrides", "mods/a.jar", FileSource::Overrides, "aaaa"),
entry("overrides", "mods/b.jar", FileSource::Overrides, "aaaa"),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert!(overrides.iter().all(|entry| entry.shadowed.is_none()));
assert!(notes.is_empty());
}
#[test]
fn the_same_path_twice_inside_one_group_is_shadowed_too() {
// 同侧重复(模型被合并 / 手造时才会出现):照样只落地一次,且注记两份来自同一目录。
let mut overrides = vec![
entry("overrides", "mods/x.jar", FileSource::Overrides, "aaaa"),
entry("overrides", "mods/x.jar", FileSource::Overrides, "bbbb"),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert_eq!(
overrides[0].shadowed.as_ref().expect("标记").winner,
FileSource::Overrides
);
assert_eq!(notes.len(), 1);
}
}
@@ -51,6 +51,8 @@ pub const NOTE_BASELINE_PACK_MISMATCH: &str = "pack.import.note.baseline_pack_mi
pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped"; pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped";
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。 /// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete"; pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
/// 缺失:调用方没给 fetcher(离线导入)。 /// 缺失:调用方没给 fetcher(离线导入)。
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline"; pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
@@ -94,6 +96,7 @@ pub const ALL: &[&str] = &[
NOTE_BASELINE_PACK_MISMATCH, NOTE_BASELINE_PACK_MISMATCH,
NOTE_ENGINE_ARTIFACTS_DROPPED, NOTE_ENGINE_ARTIFACTS_DROPPED,
NOTE_ROLLBACK_INCOMPLETE, NOTE_ROLLBACK_INCOMPLETE,
NOTE_OVERRIDE_SHADOWED,
MISSING_OFFLINE, MISSING_OFFLINE,
MISSING_NO_SOURCE, MISSING_NO_SOURCE,
MISSING_FETCH_FAILED, MISSING_FETCH_FAILED,
+14 -82
View File
@@ -51,9 +51,7 @@
//! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。 //! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。
use std::fmt::Debug; use std::fmt::Debug;
use std::fs::File; use std::path::Path;
use std::io::Write;
use std::path::{Path, PathBuf};
use super::archive::PackArchive; use super::archive::PackArchive;
use super::curseforge; use super::curseforge;
@@ -61,9 +59,9 @@ use super::error::{PackError, PackResult};
use super::manifest::PackManifest; use super::manifest::PackManifest;
use super::modrinth; use super::modrinth;
use super::multimc; use super::multimc;
use super::path::{normalize_dir, normalize_file};
mod payload; mod payload;
mod write;
/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行): /// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行):
/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按 /// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按
@@ -116,7 +114,8 @@ pub trait PackFormat: Debug + Send + Sync {
/// 导出一个整包(清单 + 覆盖内容)。 /// 导出一个整包(清单 + 覆盖内容)。
/// ///
/// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出; /// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出;
/// ③`render` 出清单;④写进**临时文件**再 `rename` 到 `target`(失败不留半个包)。 /// ③`render` 出清单;④**先按声明尺寸预检 ZIP64 阈值**,再写进**临时文件**并 `rename` 到
/// `target`(失败不留半个包)。第④步的细节见 `format::write` 模块文档。
/// ///
/// # 只保证同格式往返(写给 M111/M112) /// # 只保证同格式往返(写给 M111/M112)
/// ///
@@ -148,8 +147,16 @@ pub trait PackFormat: Debug + Send + Sync {
manifest.check_paths()?; manifest.check_paths()?;
let json = self.render(manifest)?; let json = self.render(manifest)?;
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。 // 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
let temp = temporary_path(target); let temp = write::temporary_path(target);
match write_container(self.id(), entry, &json, manifest, payload, &temp) { match write::container(
self.id(),
entry,
&json,
manifest,
payload,
&temp,
zip::ZIP64_BYTES_THR,
) {
Ok(()) => std::fs::rename(&temp, target).map_err(|error| { Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
let _ = std::fs::remove_file(&temp); let _ = std::fs::remove_file(&temp);
PackError::io(format!( PackError::io(format!(
@@ -166,81 +173,6 @@ pub trait PackFormat: Debug + Send + Sync {
} }
} }
/// 临时文件路径(与目标同目录,`rename` 才能原子替换)。
fn temporary_path(target: &Path) -> PathBuf {
let mut name = target.file_name().map_or_else(
|| std::ffi::OsString::from("pack"),
std::ffi::OsStr::to_os_string,
);
name.push(format!(".{}.part", std::process::id()));
target.with_file_name(name)
}
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
fn write_container(
format_id: &str,
entry: &'static str,
json: &str,
manifest: &PackManifest,
payload: PackPayload<'_>,
temp: &Path,
) -> PackResult<()> {
let file = File::create(temp)
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
writer
.start_file(entry, options)
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
writer
.write_all(json.as_bytes())
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
// 未压缩总量封顶在 ZIP64 阈值(≈4 GiB):Modrinth 官方导出也是这样拒绝的,而不是
// 静默截断(HMCL 那边 `(int)` 截断只打 warning 是反面教材)。临时文件由调用方删。
let mut total = json.len() as u64;
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
for override_file in &group.files {
let path = normalize_file(&override_file.path)?;
let name = format!("{dir}/{path}");
let source = match payload.source_for(&name) {
Some(source) => source.to_path_buf(),
None => {
let Some(root) = payload.root() else {
return Err(PackError::unsupported(format!(
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
)));
};
root.join(&dir).join(&path)
}
};
let bytes = std::fs::read(&source).map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?;
total += bytes.len() as u64;
if total >= zip::ZIP64_BYTES_THR {
return Err(PackError::limits(format!(
"包体未压缩总量已超过 {} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包",
zip::ZIP64_BYTES_THR
))
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
}
writer
.start_file(name.clone(), options)
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
writer
.write_all(&bytes)
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
}
}
writer
.finish()
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
Ok(())
}
/// 一次探测的结果。 /// 一次探测的结果。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PackMatch { pub struct PackMatch {
+346
View File
@@ -0,0 +1,346 @@
//! 把模型写成 zip:**声明尺寸预检 → 写清单 → 逐条搬覆盖内容**(`format.rs` 只留 trait 与注册表)。
//!
//! # 为什么预检必须在读第一个字节之前
//!
//! 一条写死的边界(research/18-p2 §8.6,A 级 `export_mrpack.rs:305-311`):**不写 ZIP64 包**。
//! Modrinth 官方导出撞到阈值直接报错,HMCL 那边 `(int)` 截断只打 warning 是反面教材。
//!
//! 早先的实现是「读一个文件 → 累加 → 比较」:真要导出一个 4 GiB 覆盖文件的实例,
//! `std::fs::read` 先把它整份读进内存,闸门**永远够不着**——用户看到的是内存打爆或 IO 错,
//! 而不是"包太大,把这个文件移出实例"。所以现在先跑一遍 `metadata().len()` 累加
//! (`preflight`),超阈值在任何内容被读进内存**之前**就报 `pack_limits`。
//!
//! 写循环里那份增量检查保留,当第二道:预检与写入之间文件可能变大(实例目录不是快照)。
//! **第二道自己从清单长度起算**,绝不拿预检的返回值当基数——两者合起来会重复累计,
//! 于是真实总量只要过半阈值(~2.15 GiB)就被误拒,而且拒在内容已经读出来之后(P2-1)。
//!
//! # 内容按块搬运,不整份读进内存
//!
//! 覆盖文件用 1 MiB 缓冲流式写进 zip(`COPY_CHUNK`)。这不是优化癖:`std::fs::read` 会把
//! 一份 2.5 GiB 的 mod 整份摊在内存里,恰恰破坏"闸在内容进内存之前"这条性质——即使判据
//! 后来修对了,误拒也发生在把几个 GiB 读进内存之后。流式之后第二道闸看的是**已写字节数**,
//! 与内存占用无关。
//!
//! # 阈值与声明口径
//!
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)≥ `zip::ZIP64_BYTES_THR`
//! (≈4 GiB)。zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,
//! 官方实现同样按这个粗粒度判。
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use super::super::error::{PackError, PackResult};
use super::super::manifest::PackManifest;
use super::super::path::{normalize_dir, normalize_file};
use super::payload::PackPayload;
/// 搬覆盖内容时的缓冲大小(流式,不把文件整份读进内存)。
const COPY_CHUNK: usize = 1024 * 1024;
/// 与目标同目录的临时文件路径(同卷 `rename` 才能原子替换)。
pub(super) fn temporary_path(target: &Path) -> PathBuf {
let mut name = target.file_name().map_or_else(
|| std::ffi::OsString::from("pack"),
std::ffi::OsStr::to_os_string,
);
name.push(format!(".{}.part", std::process::id()));
target.with_file_name(name)
}
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
pub(super) fn container(
format_id: &str,
entry: &'static str,
json: &str,
manifest: &PackManifest,
payload: PackPayload<'_>,
temp: &Path,
limit: u64,
) -> PackResult<()> {
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份 4 GiB 的内容。
// **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸
// 再累加一遍 ⇒ 过拒窗口(P2-1)。
let _ = preflight(json.len() as u64, manifest, payload, limit)?;
let file = File::create(temp)
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
writer
.start_file(entry, options)
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
writer
.write_all(json.as_bytes())
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
// 第二道闸的计数器:**从清单长度重新起算**,与预检的结果无关(见 `Written`)。
let mut written = Written::new(json.len() as u64, limit);
let mut buffer = vec![0_u8; COPY_CHUNK];
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
for override_file in &group.files {
let path = normalize_file(&override_file.path)?;
let name = format!("{dir}/{path}");
let source = source_for(payload, &dir, &path)?;
let mut source_file = File::open(&source).map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?;
writer
.start_file(name.clone(), options)
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
loop {
let read = source_file.read(&mut buffer).map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?;
if read == 0 {
break;
}
writer
.write_all(&buffer[..read])
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
written.spend(read as u64)?;
}
}
}
writer
.finish()
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
Ok(())
}
/// 写盘前的**声明尺寸预检**:只量 `metadata().len()`,不读内容。
///
/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器
/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半
/// 阈值(~2.15 GiB)就被误拒(P2-1,已有 2.5 GB 的端到端用例钉住这个窗口)。
fn preflight(
json_len: u64,
manifest: &PackManifest,
payload: PackPayload<'_>,
limit: u64,
) -> PackResult<u64> {
let mut total = json_len;
guard_with(total, limit)?;
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
for override_file in &group.files {
let path = normalize_file(&override_file.path)?;
let source = source_for(payload, &dir, &path)?;
// 缺文件 / 读不到元数据在这里就报(与"读内容失败"同一个 `pack_io` 口径:
// 目标路径上不会留下半个包,临时文件由调用方删)。
let length = std::fs::metadata(&source)
.map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?
.len();
total += length;
guard_with(total, limit)?;
}
}
Ok(total)
}
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<root>/<dir>/<path>` 拼。
fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult<PathBuf> {
let name = format!("{dir}/{path}");
if let Some(source) = payload.source_for(&name) {
return Ok(source.to_path_buf());
}
let Some(root) = payload.root() else {
return Err(PackError::unsupported(format!(
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
)));
};
Ok(root.join(dir).join(path))
}
/// 写循环的第二道闸(**已写出**的未压缩字节数)。
///
/// 起点是**清单 JSON 的长度**,与 `preflight` 的返回值无关:两者混用会把每个文件的字节算
/// 两遍,真实总量过半阈值(~2.15 GiB)就被误拒(P2-1)。类型上也没法混用——`Written`
/// 由 `u64` 构造,而预检的返回值是"全量声明尺寸",只有显式取值才拿得到。
struct Written {
total: u64,
limit: u64,
}
impl Written {
const fn new(json_len: u64, limit: u64) -> Self {
Self {
total: json_len,
limit,
}
}
/// 记一笔实际写出的字节,并在**每一块之后**过闸(大文件因此能在中途被拦下)。
fn spend(&mut self, bytes: u64) -> PackResult<()> {
self.total = self.total.saturating_add(bytes);
guard_with(self.total, self.limit)
}
}
/// ZIP64 阈值闸(`pack_limits`):`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是
/// "撞上就不写")。
///
/// 生产路径的 `limit` 恒为 `zip::ZIP64_BYTES_THR`(由 `format.rs` 传下来);把它做成参数是为了
/// 能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事——真造 4 GiB 的
/// 包要几十秒 CPU 与一大片内存/页面文件,本机资源纪律下不经默认套件跑(塔台 2026-11-07)。
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
if total >= limit {
return Err(PackError::limits(format!(
"包体未压缩总量已超过 {limit} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包"
))
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide};
#[test]
fn the_threshold_is_reached_not_exceeded() {
// `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。
assert!(guard_with(99, 100).is_ok());
let error = guard_with(100, 100).expect_err("等于阈值必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
assert!(error.message().contains("100"), "{error}");
assert!(guard_with(101, 100).is_err());
}
#[test]
fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() {
// P2-1 的窗口:真实总量在 [阈值一半, 阈值) 之间的包**不许**被拒。这一条不真写
// 2.5 GB(那要 ~70 秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
let half = zip::ZIP64_BYTES_THR / 2;
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
written
.spend(2_500_000_000)
.expect("2.5 GB 没到 4 GiB,第二道闸不许拒");
assert!(2_500_000_000 > half, "样本要落在过拒窗口里");
// 对照:撞上阈值必须拒,且正好在阈值处(不是超一点点才拒)。
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
let error = written
.spend(zip::ZIP64_BYTES_THR)
.expect_err("撞阈值必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
}
#[test]
fn the_container_does_not_double_count_the_preflight_total() {
// 容器层的窗口回归钉(P2-1),**用几十字节的 limit 把窗口缩小到可测尺寸**:
// `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。
// - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功;
// - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。
// 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的
// `#[ignore]` 用例)。
let root = std::env::temp_dir().join(format!("ull-double-count-{}", std::process::id()));
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件");
// 清单很短,limit 取"声明总量 + 20 字节"的余量:正确实现只写到 70,重复累计会写到 110。
let json = "{\"name\":\"0123456789012345\"}";
let declared = json.len() as u64 + 40;
let limit = declared + 20;
let mut manifest = PackManifest::new("double count");
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![OverrideFile {
path: "mods/a.bin".to_string(),
sha1: String::new(),
size: 40,
}],
});
let target = root.join("out.zip");
container(
"curseforge",
"manifest.json",
json,
&manifest,
PackPayload::from_root(&root),
&target,
limit,
)
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
// 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。
let refused = root.join("refused.zip");
let error = container(
"curseforge",
"manifest.json",
json,
&manifest,
PackPayload::from_root(&root),
&refused,
declared,
)
.expect_err("声明总量达到 limit 必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
assert!(!refused.exists(), "预检失败时目标路径不许出现");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_preflight_measures_declared_sizes_not_content() {
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住,
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
let root = std::env::temp_dir().join(format!("ull-preflight-{}", std::process::id()));
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件");
let huge = root.join("overrides/mods/huge.jar");
let file = std::fs::File::create(&huge).expect("建稀疏文件");
file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)");
drop(file);
let mut manifest = PackManifest::new("preflight");
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![
OverrideFile {
path: "mods/small.jar".to_string(),
sha1: String::new(),
size: 10,
},
OverrideFile {
path: "mods/huge.jar".to_string(),
sha1: String::new(),
size: 0,
},
],
});
let total = preflight(
7,
&manifest,
PackPayload::from_root(&root),
zip::ZIP64_BYTES_THR,
)
.expect("3 GiB 总量仍在阈值之下,预检不许误伤");
assert_eq!(
total,
7 + 10 + 3 * 1024 * 1024 * 1024,
"清单 + 每个覆盖文件的声明尺寸"
);
let _ = std::fs::remove_dir_all(&root);
}
}
+1
View File
@@ -13,6 +13,7 @@ mod model;
mod roundtrip; mod roundtrip;
mod safety; mod safety;
mod unknown_fields; mod unknown_fields;
mod zip64;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
@@ -111,3 +111,56 @@ fn curseforge_loader_unknown_keys_survive_read_write_reread() {
assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写"); assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写");
assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0")); assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0"));
} }
#[test]
fn mrpack_file_unknown_keys_survive_read_write_reread() {
// 第四个假边(M152 补):`files[]` 这一层。上面三条覆盖了 `env` / CF `files[]` /
// CF `modLoaders[]`,而 mrpack 的文件级未知键一直没人钉——它恰恰是"社区给某条文件加
// 一个私有字段"最常出现的位置(例如镜像站塞的 `mirror` / 作者写的 `optionalReason`)。
let root = super::temp_root("mr-file-extra");
let dir = root.join("src");
super::write_file(
&dir.join("modrinth.index.json"),
br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"file extra keys",
"files":[{"path":"mods/a.jar",
"hashes":{"sha1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"md5":"d41d8cd98f00b204e9800998ecf8427e"},
"downloads":["https://cdn.modrinth.com/data/abc/versions/x/a.jar"],
"fileSize":42,
"mirror":"https://mirror.example.invalid/a.jar",
"optionalReason":{"why":"kept for the community"}}],
"dependencies":{"minecraft":"1.20.1"}}"#,
);
let zip = root.join("pack.mrpack");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
assert_eq!(
manifest.files[0].extra["mirror"],
json!("https://mirror.example.invalid/a.jar")
);
assert_eq!(
manifest.files[0].extra["optionalReason"],
json!({"why": "kept for the community"})
);
// 未知**哈希算法**也不丢:`hashes` 是整张表,不是两个字段。
assert_eq!(
manifest.files[0].hashes.get("md5").map(String::as_str),
Some("d41d8cd98f00b204e9800998ecf8427e")
);
let target = root.join("out.mrpack");
MODRINTH
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 mrpack");
let reread = MODRINTH
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "files[] 里的未知键必须原样回写");
assert_eq!(
reread.files[0].extra["optionalReason"],
json!({"why": "kept for the community"})
);
}
+182
View File
@@ -0,0 +1,182 @@
//! ZIP64 拒写(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
//!
//! # 为什么这个文件以前是空的
//!
//! M112 的完成报告写明:「ZIP64 阈值那条分支只有代码没有端到端测试(要造 4 GiB 数据)」。
//! 而更要紧的是——**那条分支当时根本够不着**:实现先 `std::fs::read` 整个覆盖文件进内存,
//! 再累加比较,所以真要导出一个 4 GiB 文件的实例,内存先炸,用户永远看不到那句
//! `pack_limits`。现在写侧改成「先按 `metadata().len()` 预检」,两个问题一起解决:
//!
//! - 边界是真的会在读第一个字节之前拦下来的(本文件的用例就断言这一点:失败后临时目录里
//! 没有 `.part`、目标路径不存在);
//! - 测试可以用**稀疏文件**造出 4 GiB 的"声明尺寸"而几乎不占磁盘(`set_len` 只改 EOF,
//! NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的前提下打通这条分支的办法。
use std::path::Path;
use super::temp_root;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
/// 一个覆盖文件的声明(内容由调用方提供)。
fn override_file(path: &str) -> OverrideFile {
OverrideFile {
path: path.to_string(),
sha1: "0".repeat(40),
size: 0,
}
}
fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
let mut manifest = PackManifest::new("超大包");
manifest.version = Some("1.0.0".to_string());
manifest.minecraft = Some("1.20.1".to_string());
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files,
});
manifest
}
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。
fn sparse_file(path: &Path, length: u64) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
let file = std::fs::File::create(path).expect("建稀疏文件");
file.set_len(length).expect("拉长文件(稀疏)");
}
/// 临时目录里除 `keep` 之外的文件名(断言"没留下 .part"用)。
fn leftovers(root: &Path, keep: &[&str]) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(root)
.expect("列目录")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| !keep.contains(&name.as_str()))
.collect();
names.sort();
names
}
#[test]
fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
let root = temp_root("zip64-single");
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。
sparse_file(
&root.join("overrides/mods/huge.jar"),
zip::ZIP64_BYTES_THR + 1,
);
let manifest = manifest_of(vec![override_file("mods/huge.jar")]);
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("超 ZIP64 阈值必须拒写");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(error.message().contains("ZIP64"), "{error}");
assert!(
error.hint().is_some_and(|hint| hint.contains("移出实例")),
"要给「把大文件移出实例」这种可执行的提示:{error:?}"
);
assert!(!target.exists(), "失败后目标路径不许存在");
// 临时文件也不许留(预检在建临时文件之前就失败 ⇒ 目录里只有源文件)。
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
}
#[test]
fn the_threshold_is_cumulative_not_per_file() {
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。
let root = temp_root("zip64-cumulative");
let each = 1_610_612_736_u64; // 1.5 GiB
assert!(each < zip::ZIP64_BYTES_THR, "单条必须都在阈值以下");
assert!(each * 3 > zip::ZIP64_BYTES_THR, "三条加起来必须超标");
let mut files = Vec::new();
for index in 0..3 {
let relative = format!("mods/part-{index}.jar");
sparse_file(&root.join("overrides").join(&relative), each);
files.push(override_file(&relative));
}
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest_of(files), PackPayload::from_root(&root), &target)
.expect_err("累计超阈值同样必须拒写");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(error.message().contains("ZIP64"), "{error}");
assert!(!target.exists());
}
#[test]
fn an_export_under_the_threshold_still_writes_a_normal_pack() {
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
let root = temp_root("zip64-under");
let content = b"a normal sized override file";
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/jei.jar"), content).expect("写覆盖文件");
let manifest = manifest_of(vec![override_file("mods/jei.jar")]);
let target = root.join("out.zip");
CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect("正常尺寸必须写得出来");
assert!(target.exists());
let written = crate::addon::pack::PackArchive::open_default(&target).expect("打开导出的包");
assert!(written.has_entry("overrides/mods/jei.jar"));
}
/// 端到端版的窗口回归钉(**不进默认套件**:真写 2.5 GB 要 ~70 秒 deflate,且会把机器内存 /
/// 页面文件顶上去;塔台 2026-11-07 的资源配置令要求避免)。
///
/// 默认套件里由 `format::write` 的单测 `the_second_gate_counts_from_the_manifest_length_not_
/// the_preflight_total` 便宜地钉住同一个窗口(把窗口里的字节数直接喂给第二道闸的计数器)。
/// 本条留给"真跑一遍确认容器层接线没错"的场合:
///
/// ```text
/// cargo test -p unlucky-core --lib -- zip64 --ignored --nocapture
/// ```
///
/// 交付时的实测(M152 r2):单文件 2 500 000 000 字节导出**成功**,包内条目声明尺寸原样为
/// 2.5 GB,耗时 ~70 秒、源文件稀疏(占盘 0),产物是压缩后的零(几 MB)。
#[test]
#[ignore = "真写 2.5 GB(~70 秒 deflate + 一片内存):默认套件用 Written 的便宜单测钉同一窗口"]
fn a_single_file_inside_the_double_count_window_still_exports() {
// P2-1 的回归钉:预检返回的是**全量**声明尺寸,写循环曾经直接拿它当累加基数,于是每个
// 文件的字节被算两遍 —— 真实总量只要过半阈值(≈2.15 GiB)就在内容读完**之后**被误拒。
// 2.5 GB 正落在这个窗口里:它必须能导出,且中途不许把整个人内容摊进内存。
let root = temp_root("zip64-window");
let length = 2_500_000_000_u64;
assert!(length < zip::ZIP64_BYTES_THR, "样本必须在阈值之下");
assert!(
length > zip::ZIP64_BYTES_THR / 2,
"样本必须落在过拒窗口里(≥ 阈值一半)"
);
sparse_file(&root.join("overrides/mods/big.jar"), length);
let manifest = manifest_of(vec![override_file("mods/big.jar")]);
let target = root.join("out.zip");
let result = CURSEFORGE.write(&manifest, PackPayload::from_root(&root), &target);
assert!(
result.is_ok(),
"2.5 GB 的覆盖文件没超 ZIP64 阈值,不许误拒:{:?}",
result.err()
);
// 包内条目的**声明未压缩尺寸**就是 2.5 GB(真写出来的是压缩后的零,只有几 MB)。
// 这里直接用 `zip` 的读者,而不是 `PackArchive::open_default`:后者对**不可信输入**有
// 单条目 64 MiB 的上限(`PackLimits`),拿它读我们自己刚写的包会撞限额,与本次判据无关。
let file = std::fs::File::open(&target).expect("打开导出包");
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
let entry = archive
.by_name("overrides/mods/big.jar")
.expect("覆盖条目要在包里");
assert_eq!(entry.size(), length, "声明尺寸要原样带过去");
// 稀疏源文件只占元数据,但产物与目录都清掉,别让 temp 越积越多。
let _ = std::fs::remove_dir_all(&root);
}
@@ -0,0 +1,211 @@
//! 逐文件冲突策略的**边界样本**(research/18 §3.3 / §6 表 B #1–#2 的那几条)。
//!
//! 三条样本各自钉一个事实,缺一条就会复现不同的 bug:
//!
//! | 样本 | 必须成立的事实 | 写错的后果 |
//! |------|----------------|-----------|
//! | 同路径不同哈希 | 叠层顺序里**最后一条**胜;被遮蔽的那条不落地、只留一条具名注记 | 报告里两条 `Installed`,前一条的哈希根本没落地 |
//! | 同哈希不同路径 | **两个文件都落地**(按路径判重,不许按哈希判重) | 静默丢一个文件(内容一样不等于同一个文件) |
//! | overrides 叠层顺序 | `overrides/` 先、`client-overrides/` 后(= 后者胜,Modrinth 官方语义) | 与 HMCL 的相反语义混进来,同一包在不同启动器装出不同内容 |
//!
//! 判据的纯函数版本(`plan::layer::resolve`)在那边有单元测试;这里是**接在管线上的行为**:
//! 真打 zip、真导入、真读回磁盘内容与报告字段。
use std::sync::{Arc, Mutex};
use crate::addon::import::{
import_pack, keys, ConflictReason, FileAction, ImportProgress, ImportRequest, ImportStage,
ImportStatus,
};
use crate::download::blocking::block_on;
use super::support::sha1;
use super::{read_text, temp_root, write_file, zip_dir};
/// 打一个只有 `files: []` 的 mrpack(覆盖内容全在 overrides 系列目录里)。
fn index_json(name: &str) -> String {
format!(
r#"{{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
"name": "{name}", "files": [], "dependencies": {{"minecraft": "1.20.1"}}}}"#
)
}
/// 报告里所有 `NOTE_OVERRIDE_SHADOWED` 注记的消息。
fn shadow_notes(report: &crate::addon::import::ImportReport) -> Vec<&str> {
report
.warnings
.iter()
.filter(|note| note.message_key == keys::NOTE_OVERRIDE_SHADOWED)
.map(|note| note.message.as_str())
.collect()
}
#[test]
fn same_path_with_different_bytes_lands_the_later_side_only() {
let root = temp_root("layer-same-path");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("dup conflict").as_bytes(),
);
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
write_file(
&dir.join("client-overrides/config/dup.toml"),
b"from-client\n",
);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
// Extract 阶段的总条数就是"要落地的条目数":两点声明 ⇒ 只算 1 条(被遮蔽的不读字节)。
let totals: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
let sink = {
let totals = Arc::clone(&totals);
Arc::new(move |progress: &ImportProgress| {
if progress.stage == ImportStage::Extract {
totals.lock().expect("锁").push(progress.total);
}
})
};
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target).with_progress(sink),
));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
// Modrinth 官方语义:client-overrides/ 后写 = 胜。
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
// 一路径一行,且那一行的哈希必须是**真落地**的那份内容。
let rows: Vec<_> = report
.files
.iter()
.filter(|file| file.path == "config/dup.toml")
.collect();
assert_eq!(rows.len(), 1, "同一路径只许一行:{:?}", report.files);
assert_eq!(
rows[0].sha1.as_deref(),
Some(sha1(b"from-client\n").as_str())
);
assert_eq!(rows[0].action, FileAction::Installed);
// 遮蔽要留下具名注记:谁赢了、两份内容不同。
let notes = shadow_notes(&report);
assert_eq!(notes.len(), 1, "{:?}", report.warnings);
assert!(notes[0].contains("内容不同"), "{}", notes[0]);
assert!(notes[0].contains("client-overrides"), "{}", notes[0]);
assert!(
report.conflicts.is_empty(),
"包内重复声明不是用户冲突:{:?}",
report.conflicts
);
// 被遮蔽的条目不再进 Extract 的总数(1 条真落地)。
assert_eq!(totals.lock().expect("锁").last().copied(), Some(1));
}
#[test]
fn the_same_bytes_at_two_paths_are_two_files() {
let root = temp_root("layer-same-bytes");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("same bytes").as_bytes(),
);
let content = b"identical payload\n";
write_file(&dir.join("overrides/mods/a.jar"), content);
write_file(&dir.join("overrides/mods/b.jar"), content);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
for path in ["mods/a.jar", "mods/b.jar"] {
assert_eq!(
read_text(&target.join(path)),
"identical payload\n",
"{path}"
);
}
let rows: Vec<_> = report
.files
.iter()
.filter(|file| file.path.ends_with(".jar"))
.collect();
assert_eq!(rows.len(), 2, "同哈希不同路径不许折叠:{:?}", report.files);
assert!(rows
.iter()
.all(|row| row.sha1.as_deref() == Some(sha1(content).as_str())));
assert!(
shadow_notes(&report).is_empty(),
"不同路径不是重复声明:{:?}",
report.warnings
);
}
#[test]
fn identical_bytes_on_both_sides_shadow_without_claiming_a_conflict() {
let root = temp_root("layer-same-content");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("dup identical").as_bytes(),
);
let content = b"shared = true\n";
write_file(&dir.join("overrides/config/shared.toml"), content);
write_file(&dir.join("client-overrides/config/shared.toml"), content);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
assert_eq!(
read_text(&target.join("config/shared.toml")),
"shared = true\n"
);
let notes = shadow_notes(&report);
assert_eq!(notes.len(), 1, "{:?}", report.warnings);
assert!(
notes[0].contains("逐字节相同"),
"内容一致必须说清楚,别让人以为丢了东西:{}",
notes[0]
);
}
#[test]
fn the_baseline_remembers_the_winning_bytes() {
// 叠层顺序的**后果**:基线要记胜者那份。记错的话,下一次升级会把"用户没动过"判成
// "用户改过"(或反过来静默覆盖用户改动)。
let root = temp_root("layer-baseline");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("baseline dup").as_bytes(),
);
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
write_file(
&dir.join("client-overrides/config/dup.toml"),
b"from-client\n",
);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let first = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 同一份包再导一次:磁盘内容 == 胜者内容 == 基线 ⇒ 既不该报冲突,也不该重写盘。
let second = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(second.status, ImportStatus::Success, "{:?}", second.error);
assert!(
second.conflicts.is_empty(),
"基线记的必须是胜者那份,否则这里会冒出一条假的 user_modified:{:?}",
second.conflicts
);
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
// 反向确认:冲突清单里不该出现这条路径的**任何**成因。
assert!(!second
.conflicts
.iter()
.any(|conflict| conflict.path == "config/dup.toml"
&& conflict.reason != ConflictReason::PreExisting));
}
+1
View File
@@ -22,6 +22,7 @@
mod fixtures; mod fixtures;
mod import_curseforge; mod import_curseforge;
mod import_failures; mod import_failures;
mod import_layering;
mod import_merge; mod import_merge;
mod import_modrinth; mod import_modrinth;
mod import_multimc; mod import_multimc;
+4 -49
View File
@@ -18,6 +18,10 @@ use super::super::library::Library;
use super::plan::{FileRole, PlanFile}; use super::plan::{FileRole, PlanFile};
use crate::download::ResourceKind; use crate::download::ResourceKind;
use arm64_natives::{arm64_native_url, linux_arm64_native_entry, LINUX_ARM64_CLASSIFIER};
mod arm64_natives;
/// 没写 `url` 的库兜底用的 Maven 基址(Mojang 官方库仓)。 /// 没写 `url` 的库兜底用的 Maven 基址(Mojang 官方库仓)。
pub const DEFAULT_MAVEN_BASE: &str = "https://libraries.minecraft.net/"; pub const DEFAULT_MAVEN_BASE: &str = "https://libraries.minecraft.net/";
@@ -150,39 +154,6 @@ fn library_dest(
} }
} }
/// 现代清单里 LWJGL3 natives 条目的改写结果。
struct Arm64Native {
name: String,
relative: PathBuf,
url: String,
}
/// Linux arm64:把 `org.lwjgl:<module>:<version>:natives-linux` 改写成 arm64。
fn linux_arm64_native_entry(library: &Library, env: &Environment) -> Option<Arm64Native> {
let name = library.linux_arm64_native_name(env)?;
let base = name.strip_suffix(":natives-linux-arm64")?;
let mut parts = base.split(':');
let group = parts.next()?;
let artifact = parts.next()?;
let version = parts.next()?;
if parts.next().is_some() {
return None;
}
let relative = PathBuf::from(format!(
"{}/{artifact}/{version}/{artifact}-{version}-natives-linux-arm64.jar",
group.replace('.', "/")
));
let url = format!(
"{LINUX_ARM64_NATIVES_BASE}{}",
relative.to_string_lossy().replace('\\', "/")
);
Some(Arm64Native {
name,
relative,
url,
})
}
/// 按库声明(或默认)的 Maven 基址拼下载地址。 /// 按库声明(或默认)的 Maven 基址拼下载地址。
fn maven_url(library: &Library, relative: &Path) -> String { fn maven_url(library: &Library, relative: &Path) -> String {
let base = library let base = library
@@ -199,22 +170,6 @@ fn maven_url(library: &Library, relative: &Path) -> String {
} }
} }
/// Linux arm64 的 natives 兜底仓库:Mojang 的 `libraries.minecraft.net` 对
/// `natives-linux-arm64` 一律 404(实测 3.3.2 的 lwjgl/glfw/opengl/stb/tinyfd/openal),
/// 同一坐标在 Maven Central / 阿里云镜像有。
pub const LINUX_ARM64_NATIVES_BASE: &str = "https://maven.aliyun.com/repository/public/";
/// Linux arm64 的 natives 分类器(见 `Library::native_classifier`)。
pub const LINUX_ARM64_CLASSIFIER: &str = "natives-linux-arm64";
/// arm64 natives 的兜底 URL(按相对路径拼镜像基址)。
fn arm64_native_url(relative: &Path) -> String {
format!(
"{LINUX_ARM64_NATIVES_BASE}{}",
relative.to_string_lossy().replace('\\', "/")
)
}
/// 供上层判错用:库展开不产生硬失败,缺地址一律降级成告警。 /// 供上层判错用:库展开不产生硬失败,缺地址一律降级成告警。
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -0,0 +1,72 @@
//! Linux arm64 的 natives 改写与下载兜底。
//!
//! 两件事实拼出来的特例:
//! 1. Mojang 清单里 LWJGL3 的 natives 只写 `natives-linux`(x86_64 的 `.so`);
//! 直接用它,在 aarch64 JVM 里加载必失败。
//! 2. 同一个 Maven 坐标确实有 `natives-linux-arm64` 分类器,但 `libraries.minecraft.net`
//! 对它一律 404(实测 3.3.2 的 lwjgl/glfw/opengl/stb/tinyfd/openal),Maven Central 与
//! 阿里云镜像有。
//!
//! 所以 Linux arm64 上:分类器改写(`Library::native_classifier` /
//! `Library::linux_arm64_native_name`)+ 地址兜底到镜像。**只在 linux + arm64 生效**,
//! x86_64 / Windows / macOS 的地址一个字节不变。用例仍在 `libraries.rs` 的 tests 里
//! (它们从 `plan()` 的出口断言,不直接调这里的私有步骤)。
//!
//! 从 `libraries.rs` 拆出来是为了守住 400 行红线(`guards.yml` 的 `file-size`)。
use std::path::{Path, PathBuf};
use crate::game::arguments::Environment;
use crate::game::library::Library;
/// Linux arm64 的 natives 兜底仓库:Mojang 的 `libraries.minecraft.net` 对
/// `natives-linux-arm64` 一律 404(实测 3.3.2 的 lwjgl/glfw/opengl/stb/tinyfd/openal),
/// 同一坐标在 Maven Central / 阿里云镜像有。
pub const LINUX_ARM64_NATIVES_BASE: &str = "https://maven.aliyun.com/repository/public/";
/// Linux arm64 的 natives 分类器(见 `Library::native_classifier`)。
pub const LINUX_ARM64_CLASSIFIER: &str = "natives-linux-arm64";
/// 现代清单里 LWJGL3 natives 条目的改写结果。
pub(super) struct Arm64Native {
pub(super) name: String,
pub(super) relative: PathBuf,
pub(super) url: String,
}
/// Linux arm64:把 `org.lwjgl:<module>:<version>:natives-linux` 改写成 arm64。
pub(super) fn linux_arm64_native_entry(
library: &Library,
env: &Environment,
) -> Option<Arm64Native> {
let name = library.linux_arm64_native_name(env)?;
let base = name.strip_suffix(":natives-linux-arm64")?;
let mut parts = base.split(':');
let group = parts.next()?;
let artifact = parts.next()?;
let version = parts.next()?;
if parts.next().is_some() {
return None;
}
let relative = PathBuf::from(format!(
"{}/{artifact}/{version}/{artifact}-{version}-natives-linux-arm64.jar",
group.replace('.', "/")
));
let url = format!(
"{LINUX_ARM64_NATIVES_BASE}{}",
relative.to_string_lossy().replace('\\', "/")
);
Some(Arm64Native {
name,
relative,
url,
})
}
/// arm64 natives 的兜底 URL(按相对路径拼镜像基址)。
pub(super) fn arm64_native_url(relative: &Path) -> String {
format!(
"{LINUX_ARM64_NATIVES_BASE}{}",
relative.to_string_lossy().replace('\\', "/")
)
}
+6 -1
View File
@@ -225,8 +225,13 @@ pub fn path_key(path: &Path) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{dedupe, parse_java_home, path_key, standard_roots, standard_roots_in}; use super::{dedupe, parse_java_home, path_key, standard_roots};
use crate::game::java::probe::{JavaOrigin, JavaRuntime}; use crate::game::java::probe::{JavaOrigin, JavaRuntime};
// 这两个只有 Termux 扫描的两个测试用,而那两个测试是 `cfg(unix)`——导入必须同样 gate,
// 否则 Windows 上不编译测试、导入就成了未使用(`-D warnings` 直接判错)。
#[cfg(unix)]
use super::standard_roots_in;
#[cfg(unix)]
use crate::platform::paths::MapEnv; use crate::platform::paths::MapEnv;
fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime { fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
+30 -153
View File
@@ -1,6 +1,7 @@
//! 托管 JDK 安装编排:发行版回退链 → 下载引擎 → 解压 → `java -version` 验证。 //! 托管 JDK 安装编排:发行版回退链 → 下载引擎 → 解压 → `java -version` 验证。
//! //!
//! ```text //! ```text
//! [Termux] termux_install::attempt 系统包(.deb)——与下面这条链不共用任何一步
//! vendor::order(用户优先级, 地区) D23 的尝试顺序 //! vendor::order(用户优先级, 地区) D23 的尝试顺序
//! → source::fetch(vendor, major) 查到该家最新 GA(Temurin/Oracle/MS/Zulu) //! → source::fetch(vendor, major) 查到该家最新 GA(Temurin/Oracle/MS/Zulu)
//! → location::JavaInstallRoot::jdk_dir 按落点模式算目录 //! → location::JavaInstallRoot::jdk_dir 按落点模式算目录
@@ -13,22 +14,35 @@
//! **回退的粒度是发行版**(D23):该家没有这个大版本、镜像全 4xx、sha256 取不到、下载校验失败, //! **回退的粒度是发行版**(D23):该家没有这个大版本、镜像全 4xx、sha256 取不到、下载校验失败,
//! 都记一条降级原因然后换下一家;只有全部失败才整体报错,错误里带上每一跳的原因。 //! 都记一条降级原因然后换下一家;只有全部失败才整体报错,错误里带上每一跳的原因。
//! //!
//! **平台参数(`AdoptiumTarget::host()`)失败不提前退出**:Termux/Android 这类没有归档产物的
//! 宿主上,早退只会在错误里留一句「不支持该平台」,看不出是哪几家、各卡在哪;现在它降级成
//! 每家一条原因,与其它失败同形。
//!
//! 这一步**不拼任何镜像 URL**(Oracle 的哈希段不可推导,见 `oracle` 模块):Temurin 的
//! GitHub 直链交给 `MirrorTable::expand` 展开成院校镜像,Oracle/MS/Zulu 的链接由各自模块
//! 从目录页/模板/API 拿到最终形态,引擎只负责「把这个 URL 下下来并校验」。
//!
//! 这一步**不拼任何镜像 URL**(Oracle 的哈希段不可推导,见 `oracle` 模块):Temurin 的 //! 这一步**不拼任何镜像 URL**(Oracle 的哈希段不可推导,见 `oracle` 模块):Temurin 的
//! GitHub 直链交给 `MirrorTable::expand` 展开成院校镜像,Oracle/MS/Zulu 的链接由各自模块 //! GitHub 直链交给 `MirrorTable::expand` 展开成院校镜像,Oracle/MS/Zulu 的链接由各自模块
//! 从目录页/模板/API 拿到最终形态,引擎只负责「把这个 URL 下下来并校验」。 //! 从目录页/模板/API 拿到最终形态,引擎只负责「把这个 URL 下下来并校验」。
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use super::error::{io_error, JavaError, JavaErrorKind}; use super::error::{JavaError, JavaErrorKind};
use super::extract; use super::extract;
use super::location::{JavaInstallRoot, LocationMode}; use super::location::{JavaInstallRoot, LocationMode};
use super::probe::{self, JavaOrigin, JavaRuntime}; use super::probe::{self, JavaRuntime};
use super::release::{JdkArchive, JdkRelease}; use super::release::JdkRelease;
use super::source::{self, ChainLog}; use super::source::{self, ChainLog};
use super::target::AdoptiumTarget; use super::target::AdoptiumTarget;
use super::vendor::{self, JdkVendor}; use super::vendor::{self, JdkVendor};
use crate::download::{DownloadEngine, DownloadRequest, ResourceKind}; use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
use one::install_one;
mod one;
mod termux_install;
/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。 /// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
pub use super::location::CACHE_DIR; pub use super::location::CACHE_DIR;
@@ -186,75 +200,24 @@ pub async fn install(
) -> Result<InstalledJdk, JavaError> { ) -> Result<InstalledJdk, JavaError> {
let domestic = engine.region().is_domestic(); let domestic = engine.region().is_domestic();
let priority = request.order(domestic); let priority = request.order(domestic);
let mut chain = ChainLog::new();
let mut reasons: Vec<String> = Vec::new(); let mut reasons: Vec<String> = Vec::new();
// 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。 // 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
let _ = super::location::cleanup_orphans(&location.root); let _ = super::location::cleanup_orphans(&location.root);
// Termux:JDK 由系统包管理器装进 `$PREFIX`,不是可下载的归档。 // Termux 那一条路(系统包)单独成模块:它与归档链不共用任何一步,见 `termux_install`。
// 默认(没钉发行版)时优先走它;`--vendor termux` 时只走它。 // 它把 chain 还回来(没走成时),归档链接着往上记。
let want_termux = match request.vendor { let mut chain = match termux_install::attempt(
Some(JdkVendor::Termux) => true, request.vendor,
Some(_) => false, request.major,
None => super::termux::available(), location,
ChainLog::new(),
&mut reasons,
)
.await?
{
termux_install::Outcome::Done(installed) => return Ok(*installed),
termux_install::Outcome::Skipped(chain) => chain,
}; };
if want_termux {
if !super::termux::available() {
return Err(
JavaError::new(JavaErrorKind::Unsupported, "termux 源只在 Termux 里可用")
.with_hint("普通 Linux/Windows 用 --vendor temurin|oracle|microsoft|zulu"),
);
}
match super::termux::install(request.major).await {
Ok((home, reused)) => {
let mut runtime = probe::probe(&extract::java_binary(&home), JavaOrigin::Managed);
runtime.vendor = Some(JdkVendor::Termux.id().to_string());
let version = runtime
.version
.clone()
.unwrap_or_else(|| request.major.to_string());
let release = JdkRelease {
vendor: JdkVendor::Termux,
major: request.major,
version,
semver: None,
release_name: format!("Termux openjdk-{}", request.major),
archive: JdkArchive {
name: format!("openjdk-{}.deb", request.major),
url: String::new(),
sha256: String::new(),
size: 0,
},
};
chain.hit(JdkVendor::Termux, format!("命中 {}", release.summary()));
return Ok(InstalledJdk {
major: release.major,
vendor: JdkVendor::Termux,
release,
root: home,
mode: location.mode,
archive: PathBuf::new(),
runtime,
downloaded: if reused {
None
} else {
Some("termux".to_string())
},
bytes: 0,
files: 0,
extracted: false,
chain,
});
}
Err(error) => {
if request.vendor == Some(JdkVendor::Termux) {
return Err(error);
}
chain.failed(JdkVendor::Termux, error.message().to_string());
reasons.push(format!("termux:{}", error.message()));
}
}
}
// 归档发行版需要 Adoptium 的平台参数;Termux/Android 上这里会 Unsupported, // 归档发行版需要 Adoptium 的平台参数;Termux/Android 上这里会 Unsupported,
// 不提前 `?` 退出——让每一家都记一条降级原因,错误里能看见全貌。 // 不提前 `?` 退出——让每一家都记一条降级原因,错误里能看见全貌。
@@ -327,84 +290,6 @@ pub async fn install(
.with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME")) .with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME"))
} }
/// 某一家的中间结果(把七元组元组换成具名结构,读起来才不费劲)。
struct Prepared {
root: PathBuf,
archive: PathBuf,
runtime: JavaRuntime,
downloaded: Option<String>,
bytes: u64,
files: usize,
extracted: bool,
}
/// 装某一家的那一份(下载 → 解压 → 探测)。
async fn install_one(
engine: &DownloadEngine,
location: &JavaInstallRoot,
release: &JdkRelease,
) -> Result<Prepared, JavaError> {
let root = location.jdk_dir(release.major, release.vendor, &release.version);
let binary = extract::java_binary(&root);
let archive = archive_path(&location.root, release);
// 幂等第一层:戳记里的 sha256 与本次一致,且 bin/java 在 → 一个字节都不动。
if extract::is_ready(&root, release.sha256()) {
let runtime = probe::probe(&binary, JavaOrigin::Managed);
if runtime.major == Some(release.major) {
let mut runtime = runtime;
runtime.vendor = Some(release.vendor.id().to_string());
return Ok(Prepared {
root,
archive,
runtime,
downloaded: None,
bytes: 0,
files: 0,
extracted: false,
});
}
note(&format!(
"托管目录 {} 里的 java 跑不起来,重装一份",
root.display()
));
}
if let Some(parent) = archive.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| io_error("创建归档缓存目录", parent.display(), &error))?;
}
let outcome = engine
.download(archive_request(release, archive.clone()))
.await?;
let report = extract::extract_archive(&archive, release.sha256(), release.major, &root)?;
let mut runtime = probe::probe(&binary, JavaOrigin::Managed);
runtime.vendor = Some(release.vendor.id().to_string());
if runtime.major != Some(release.major) {
return Err(JavaError::new(
JavaErrorKind::Probe,
format!(
"装好的 JDK 探测结果是 Java {},与请求的 {} 不符({})",
runtime
.major
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
release.major,
binary.display()
),
)
.with_hint("删掉该托管目录后重跑 `ull java install`;或手动设 JAVA_HOME"));
}
Ok(Prepared {
root,
archive,
runtime,
downloaded: Some(outcome.source),
bytes: outcome.bytes,
files: report.files,
extracted: report.wrote(),
})
}
/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。 /// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。
/// ///
/// # Errors /// # Errors
@@ -446,11 +331,3 @@ fn majors_text(vendor: JdkVendor) -> String {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("/") .join("/")
} }
fn note(message: &str) {
if cfg!(debug_assertions) {
eprintln!("[ull::game::java] {message}");
} else {
let _ = message;
}
}
+100
View File
@@ -0,0 +1,100 @@
//! 回退链里的「一家」怎么装:下载 → 解压 → 探测,外加幂等复用。
//!
//! 从 `install.rs` 拆出来是为了守住 400 行红线(`guards.yml` 的 `file-size`):
//! `install.rs` 只保留「按优先级链挑一家 / 记降级原因」的编排,这里放单家的机械步骤。
use std::path::PathBuf;
use super::super::error::{io_error, JavaError, JavaErrorKind};
use super::super::extract;
use super::super::location::JavaInstallRoot;
use super::super::probe::{self, JavaOrigin, JavaRuntime};
use super::super::release::JdkRelease;
use super::{archive_path, archive_request};
use crate::download::DownloadEngine;
/// 某一家的中间结果(把七元组元组换成具名结构,读起来才不费劲)。
pub(super) struct Prepared {
pub(super) root: PathBuf,
pub(super) archive: PathBuf,
pub(super) runtime: JavaRuntime,
pub(super) downloaded: Option<String>,
pub(super) bytes: u64,
pub(super) files: usize,
pub(super) extracted: bool,
}
/// 装某一家的那一份(下载 → 解压 → 探测)。
pub(super) async fn install_one(
engine: &DownloadEngine,
location: &JavaInstallRoot,
release: &JdkRelease,
) -> Result<Prepared, JavaError> {
let root = location.jdk_dir(release.major, release.vendor, &release.version);
let binary = extract::java_binary(&root);
let archive = archive_path(&location.root, release);
// 幂等第一层:戳记里的 sha256 与本次一致,且 bin/java 在 → 一个字节都不动。
if extract::is_ready(&root, release.sha256()) {
let runtime = probe::probe(&binary, JavaOrigin::Managed);
if runtime.major == Some(release.major) {
let mut runtime = runtime;
runtime.vendor = Some(release.vendor.id().to_string());
return Ok(Prepared {
root,
archive,
runtime,
downloaded: None,
bytes: 0,
files: 0,
extracted: false,
});
}
note(&format!(
"托管目录 {} 里的 java 跑不起来,重装一份",
root.display()
));
}
if let Some(parent) = archive.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| io_error("创建归档缓存目录", parent.display(), &error))?;
}
let outcome = engine
.download(archive_request(release, archive.clone()))
.await?;
let report = extract::extract_archive(&archive, release.sha256(), release.major, &root)?;
let mut runtime = probe::probe(&binary, JavaOrigin::Managed);
runtime.vendor = Some(release.vendor.id().to_string());
if runtime.major != Some(release.major) {
return Err(JavaError::new(
JavaErrorKind::Probe,
format!(
"装好的 JDK 探测结果是 Java {},与请求的 {} 不符({})",
runtime
.major
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
release.major,
binary.display()
),
)
.with_hint("删掉该托管目录后重跑 `ull java install`;或手动设 JAVA_HOME"));
}
Ok(Prepared {
root,
archive,
runtime,
downloaded: Some(outcome.source),
bytes: outcome.bytes,
files: report.files,
extracted: report.wrote(),
})
}
fn note(message: &str) {
if cfg!(debug_assertions) {
eprintln!("[ull::game::java] {message}");
} else {
let _ = message;
}
}
@@ -0,0 +1,105 @@
//! `install` 的 Termux 分支:JDK 由系统包管理器装进 `$PREFIX`,不是可下载的归档。
//!
//! 单独成模块有两个理由:`.deb` 这条路与归档链**没有共用步骤**(不查版本元数据、不下载、
//! 不解压、不校验 sha256),留在 `install()` 里只能是一段嵌在中间的 if;而 `install.rs`
//! 贴着 400 行红线(拆分前 456 行,见 `guards.yml` 的 `file-size`)。
use super::super::error::{JavaError, JavaErrorKind};
use super::super::extract;
use super::super::location::JavaInstallRoot;
use super::super::probe::{self, JavaOrigin};
use super::super::release::{JdkArchive, JdkRelease};
use super::super::source::ChainLog;
use super::super::vendor::JdkVendor;
use super::InstalledJdk;
/// Termux 分支的结果。
pub(super) enum Outcome {
/// 系统包装成了,`install` 直接返回它(装箱只为让枚举别撑到 392 字节,
/// 这条路一次安装才走一次)。
Done(Box<InstalledJdk>),
/// 不该走这条分支,或者走了但失败(原因已进 `chain` 与 `reasons`)——把 chain 还给调用方,
/// 由它继续跑归档回退链。
Skipped(ChainLog),
}
/// 尝试走 Termux 系统包。
///
/// 语义(D23「`--vendor` 钉死一家、不自动降级」):
/// - `pinned = Some(Termux)`:只走这条,失败直接冒泡;
/// - `pinned = None`:Termux 上先试系统包,失败记一条降级原因后继续归档链;
/// - 其它平台:`Ok(Skipped(chain))`,一个字节都不动。
///
/// # Errors
///
/// 只有「钉死 termux」且失败时才返回错误;其余失败一律降级成 `reasons` 里的一行。
pub(super) async fn attempt(
pinned: Option<JdkVendor>,
major: u32,
location: &JavaInstallRoot,
chain: ChainLog,
reasons: &mut Vec<String>,
) -> Result<Outcome, JavaError> {
let wanted = match pinned {
Some(JdkVendor::Termux) => true,
Some(_) => false,
None => super::super::termux::available(),
};
if !wanted {
return Ok(Outcome::Skipped(chain));
}
if !super::super::termux::available() {
return Err(
JavaError::new(JavaErrorKind::Unsupported, "termux 源只在 Termux 里可用")
.with_hint("普通 Linux/Windows 用 --vendor temurin|oracle|microsoft|zulu"),
);
}
let mut chain = chain;
match super::super::termux::install(major).await {
Ok((home, reused)) => {
let mut runtime = probe::probe(&extract::java_binary(&home), JavaOrigin::Managed);
runtime.vendor = Some(JdkVendor::Termux.id().to_string());
let version = runtime.version.clone().unwrap_or_else(|| major.to_string());
let release = JdkRelease {
vendor: JdkVendor::Termux,
major,
version,
semver: None,
release_name: format!("Termux openjdk-{major}"),
archive: JdkArchive {
name: format!("openjdk-{major}.deb"),
url: String::new(),
sha256: String::new(),
size: 0,
},
};
chain.hit(JdkVendor::Termux, format!("命中 {}", release.summary()));
Ok(Outcome::Done(Box::new(InstalledJdk {
major: release.major,
vendor: JdkVendor::Termux,
release,
root: home,
mode: location.mode,
archive: std::path::PathBuf::new(),
runtime,
downloaded: if reused {
None
} else {
Some("termux".to_string())
},
bytes: 0,
files: 0,
extracted: false,
chain,
})))
}
Err(error) => {
if pinned == Some(JdkVendor::Termux) {
return Err(error);
}
chain.failed(JdkVendor::Termux, error.message().to_string());
reasons.push(format!("termux:{}", error.message()));
Ok(Outcome::Skipped(chain))
}
}
}
+12 -3
View File
@@ -22,6 +22,11 @@ pub enum UpdateErrorKind {
SourceMissing, SourceMissing,
/// 更新源协议不支持(例如本地目录源里出现了 `http://` 地址)。 /// 更新源协议不支持(例如本地目录源里出现了 `http://` 地址)。
UnsupportedSource, UnsupportedSource,
/// 远端源取字节失败:连不上、超时、5xx、所有候选源(含镜像)全挂。
///
/// 与 [`Self::Io`] 分开是因为处置不同:网络失败值得隔一会儿重试/换镜像,
/// 本地 IO 失败(盘满、没权限)重试多少次都一样。
Network,
/// 本地 IO 失败:读源、写暂存、替换落盘。 /// 本地 IO 失败:读源、写暂存、替换落盘。
Io, Io,
/// 摘要不符:拿到的字节与清单声明的不是同一份。 /// 摘要不符:拿到的字节与清单声明的不是同一份。
@@ -40,6 +45,7 @@ impl UpdateErrorKind {
Self::ChannelMismatch => "channel_mismatch", Self::ChannelMismatch => "channel_mismatch",
Self::SourceMissing => "source_missing", Self::SourceMissing => "source_missing",
Self::UnsupportedSource => "unsupported_source", Self::UnsupportedSource => "unsupported_source",
Self::Network => "network",
Self::Io => "io", Self::Io => "io",
Self::DigestMismatch => "digest_mismatch", Self::DigestMismatch => "digest_mismatch",
Self::SizeMismatch => "size_mismatch", Self::SizeMismatch => "size_mismatch",
@@ -48,11 +54,14 @@ impl UpdateErrorKind {
/// 换个源 / 重下一次是否可能变好(调用方据此决定重试,别在这里写死重试次数)。 /// 换个源 / 重下一次是否可能变好(调用方据此决定重试,别在这里写死重试次数)。
/// ///
/// 清单本身不合法、协议不支持、目标平台无权写盘这三类换多少源都一样, /// 清单本身不合法、协议不支持、渠道不符这几类换多少源都一样,所以只有字节层
/// 所以只有字节层(摘要/长度)与 IO(可能是瞬时占用)算可重试。 /// (摘要/长度)、网络(可能是瞬时抖动)与 IO(可能是瞬时占用)算可重试。
#[must_use] #[must_use]
pub const fn is_retryable(self) -> bool { pub const fn is_retryable(self) -> bool {
matches!(self, Self::DigestMismatch | Self::SizeMismatch | Self::Io) matches!(
self,
Self::DigestMismatch | Self::SizeMismatch | Self::Network | Self::Io
)
} }
} }
+342
View File
@@ -0,0 +1,342 @@
//! GitHub Releases 更新源(D50 定案:GitHub Releases 是唯一更新源)。
//!
//! # 地址形状(固定,不可配置)
//!
//! ```text
//! 清单 <base>/<owner>/<repo>/releases/latest/download/manifest.json
//! 产物 <base>/<owner>/<repo>/releases/latest/download/<清单里的产物文件名>
//! ```
//!
//! `releases/latest/download/<asset>` 是 GitHub 原生的「最新发布」地址:不用知道 tag、
//! 不用调 API(API 有速率限制、也要 token),发布侧只要把 `manifest.json` 和各平台产物
//! 挂在最新 release 上,客户端就能取到。清单里的 `url` 因此照 D49 的产物命名规则写成
//! **文件名**(如 `unlucky-launcher_0.6.0_windows_x64.exe`),由本源拼成上面的地址;
//! 清单若已经写了完整 `http(s)` 地址(例如临时挂到别处复测),原样使用。
//!
//! # 可配置面只有三个
//!
//! * `owner` / `repo`(默认 [`DEFAULT_OWNER`] / [`DEFAULT_REPO`]);
//! * `base`(默认 [`DEFAULT_BASE`];**只为测试存在**——把它指向本地假 HTTP 服务,
//! 单测就不必联网)。
//!
//! # 网络全走 `core::download`
//!
//! * **产物**(几十 MB 的包体):整段交给 [`DownloadEngine::download`]——镜像展开
//! (国内 gh-proxy 前缀)、地区判定、健康度排序、失败退避、断点续传、原子落盘全部复用,
//! 本源不再造一套网络栈,也不自己拼镜像地址;
//! * **清单**(几 KB 的 JSON):用引擎的候选规划 [`GitHubReleaseSource::candidates`] +
//! 共享客户端 [`DownloadEngine::client`] 逐候选取文本,与 `core::game::java` 的
//! 「一锤子 GET」同一手法。包体才需要退避/续传/分块,为几 KB 建一条下载流水线不划算。
//!
//! 两种路径都吃到同一条镜像策略:`mirrors.json` 的前缀规则 + 地区档案里生效的
//! `gh_proxy` 前缀([`crate::download::mirrors::MirrorTable::gh_proxy_candidates`]),
//! 国内环境下 `https://github.com/...` 会被改写成 `https://gh-proxy.com/https://github.com/...`。
//!
//! # 渠道
//!
//! `releases/latest` 只有一个,所以本源**只服务 stable**。beta/alpha/nightly 的清单枚举
//! 保留但不发布(D50「先只发 stable」),因此非 stable 渠道一律 `Ok(None)` = 没有更新——
//! 这不是错误,别让「还没开通道」变成用户机器上的红字。
//!
//! # 校验
//!
//! 本源只管「把字节放到 dest」:长度与摘要由 [`super::verify`] 统一双检(与 [`super::DirSource`]
//! 一致)。所以包体请求只带 `expected_size`(给进度预算与门户页假阳性兜底),不带摘要——
//! 摘要判据只有一处,别在这里再写一套。
//!
//! # 运行时
//!
//! [`UpdateSource`] 是同步签名,本源在方法内部用 [`crate::download::block_on`] 桥接异步引擎
//! (CLI 与 presenter 都是这么用的)。因此**不要在已有的 tokio 运行时里直接调本源的
//! `fetch_*`**:tokio 不允许嵌套 `block_on`,会 panic。更新流程请放在独立线程上跑。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::download::cache::now_ms;
use crate::download::{
Candidate, DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind,
};
use super::channel::Channel;
use super::error::{UpdateError, UpdateErrorKind};
use super::manifest::{Artifact, ReleaseManifest};
use super::source::UpdateSource;
/// 默认仓库 owner。
pub const DEFAULT_OWNER: &str = "AskaEth";
/// 默认仓库名。
pub const DEFAULT_REPO: &str = "Unlucky-Launcher";
/// GitHub 站点根地址(`base` 的默认值)。
pub const DEFAULT_BASE: &str = "https://github.com";
/// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。
pub const MANIFEST_ASSET: &str = "manifest.json";
/// GitHub Releases 更新源。
#[derive(Debug, Clone)]
pub struct GitHubReleaseSource {
engine: Arc<DownloadEngine>,
owner: String,
repo: String,
base: String,
id: String,
}
impl GitHubReleaseSource {
/// 用默认仓库([`DEFAULT_OWNER`] / [`DEFAULT_REPO`])与 GitHub 站点根地址构造。
#[must_use]
pub fn new(engine: Arc<DownloadEngine>) -> Self {
Self::with_repo(engine, DEFAULT_OWNER, DEFAULT_REPO)
}
/// 指定仓库构造(fork / 自建镜像仓、测试用)。
#[must_use]
pub fn with_repo(
engine: Arc<DownloadEngine>,
owner: impl Into<String>,
repo: impl Into<String>,
) -> Self {
let owner = owner.into();
let repo = repo.into();
let id = format!("github:{owner}/{repo}");
Self {
engine,
owner,
repo,
base: DEFAULT_BASE.to_string(),
id,
}
}
/// 换 `base`(站点根地址,末尾斜杠会去掉)。
///
/// 生产不用这个口子;单测把它指向本地假服务(`http://127.0.0.1:<port>`)以保持离线。
#[must_use]
pub fn with_base(mut self, base: impl Into<String>) -> Self {
self.base = base.into().trim_end_matches('/').to_string();
self
}
/// 仓库 owner。
#[must_use]
pub fn owner(&self) -> &str {
&self.owner
}
/// 仓库名。
#[must_use]
pub fn repo(&self) -> &str {
&self.repo
}
/// 站点根地址。
#[must_use]
pub fn base(&self) -> &str {
&self.base
}
/// 引擎(调用方要挂进度事件时用)。
#[must_use]
pub fn engine(&self) -> &Arc<DownloadEngine> {
&self.engine
}
/// 某渠道的清单地址;非 stable 渠道没有清单(返回 `None`)。
#[must_use]
pub fn manifest_url(&self, channel: Channel) -> Option<String> {
(channel == Channel::Stable).then(|| format!("{}/{MANIFEST_ASSET}", self.download_base()))
}
/// 把清单里的 `url` 解析成真实下载地址。
///
/// 已经是 `http(s)://` 的地址原样使用(清单可以临时指向别处);其余一律当成
/// 「最新 release 里的产物文件名」,拼到本源的下载根路径下。
///
/// # Errors
///
/// `url` 为空是 [`UpdateErrorKind::InvalidManifest`](清单坏,不是源的问题);
/// 非 http(s) 的其它协议是 [`UpdateErrorKind::UnsupportedSource`]。
pub fn artifact_url(&self, artifact: &Artifact) -> Result<String, UpdateError> {
let raw = artifact.url.trim();
if raw.is_empty() {
return Err(UpdateError::invalid_manifest("产物的 url 为空"));
}
if raw.starts_with("http://") || raw.starts_with("https://") {
return Ok(raw.to_string());
}
if raw.contains("://") {
return Err(UpdateError::new(
UpdateErrorKind::UnsupportedSource,
format!("GitHub 源只认 http(s) 地址或 release 内的产物文件名:`{raw}`"),
));
}
// `./name` 与 `/name` 都当成「release 里的文件名」,别把整串拼出个双斜杠路径。
let name = raw.trim_start_matches(['.', '/']);
Ok(format!("{}/{name}", self.download_base()))
}
/// 某地址在引擎眼里的候选源序列(镜像展开 + 健康度排序,**不发请求**)。
///
/// 生产调用走引擎自己的规划;这里是给「镜像策略是否生效」这类断言用的观察窗。
#[must_use]
pub fn candidates(&self, url: &str) -> Vec<Candidate> {
// `dest` 只是 `DownloadRequest` 的必填字段,`plan` 不看它。
let request = DownloadRequest::new(ResourceKind::GitHubRelease, url, PathBuf::new());
self.engine.plan(&request, now_ms())
}
/// 最新的 release 产物根路径(`<base>/<owner>/<repo>/releases/latest/download`)。
fn download_base(&self) -> String {
format!(
"{}/{}/{}/releases/latest/download",
self.base, self.owner, self.repo
)
}
/// 逐候选取一段文本:`Ok(None)` = 所有候选都说「这份文件不存在」。
fn fetch_text(&self, url: &str) -> Result<Option<String>, UpdateError> {
let candidates = self.candidates(url);
if candidates.is_empty() {
return Err(UpdateError::new(
UpdateErrorKind::Network,
format!("没有可用候选源:{url}"),
));
}
let mut failure: Option<UpdateError> = None;
let mut all_missing = true;
for candidate in candidates {
match self.get_candidate_text(&candidate.url) {
Ok(Some(text)) => return Ok(Some(text)),
// 某个候选 404 不代表没有:镜像可能比官方慢半拍,继续问下一个。
Ok(None) => {}
Err(error) => {
all_missing = false;
failure = Some(error);
}
}
}
if all_missing {
Ok(None)
} else {
Err(failure.unwrap_or_else(|| {
UpdateError::new(UpdateErrorKind::Network, format!("取 {url} 失败"))
}))
}
}
/// 单个候选地址取文本;`Ok(None)` = 404/410(该候选没有这份文件)。
fn get_candidate_text(&self, url: &str) -> Result<Option<String>, UpdateError> {
let client = self.engine.client();
crate::download::block_on(async {
let response = client.get(url).send().await.map_err(|error| {
UpdateError::new(
UpdateErrorKind::Network,
format!("取更新清单失败:{error}({url})"),
)
})?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
return Ok(None);
}
if !status.is_success() {
return Err(UpdateError::new(
UpdateErrorKind::Network,
format!("更新清单返回 HTTP {status}({url})"),
));
}
let text = response.text().await.map_err(|error| {
UpdateError::new(
UpdateErrorKind::Network,
format!("读更新清单响应失败:{error}({url})"),
)
})?;
if text.trim().is_empty() {
return Err(UpdateError::invalid_manifest(format!(
"清单响应为空({url}),发布脚本可能漏传了 {MANIFEST_ASSET}"
)));
}
Ok(Some(text))
})
}
}
impl UpdateSource for GitHubReleaseSource {
fn source_id(&self) -> &str {
&self.id
}
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
let Some(url) = self.manifest_url(channel) else {
return Ok(None);
};
let Some(text) = self.fetch_text(&url)? else {
return Ok(None);
};
let manifest = ReleaseManifest::from_json(&text).map_err(|mut error| {
error.message = format!("{}(来源 {url})", error.message);
error
})?;
if manifest.channel != channel {
return Err(UpdateError::new(
UpdateErrorKind::ChannelMismatch,
format!(
"请求的是 {channel} 渠道,清单 {url} 自报 {}",
manifest.channel
),
));
}
Ok(Some(manifest))
}
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
let url = self.artifact_url(artifact)?;
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|error| UpdateError::io_at(parent, &error))?;
}
}
// 摘要不在这里传:`update::verify` 统一做长度/摘要双检(trait 契约,别各写一套)。
let request = DownloadRequest::new(ResourceKind::GitHubRelease, url.clone(), dest)
.with_size(artifact.size);
let outcome = crate::download::block_on(self.engine.download(request))
.map_err(|error| download_error(&error, &url))?;
Ok(outcome.bytes)
}
}
/// 把下载引擎的错误翻译成自更新的错误分类(两套分类各有各的职责,别混用)。
fn download_error(error: &DownloadError, url: &str) -> UpdateError {
let kind = match error.kind {
DownloadErrorKind::NotFound => UpdateErrorKind::SourceMissing,
DownloadErrorKind::Integrity => UpdateErrorKind::DigestMismatch,
DownloadErrorKind::Io => UpdateErrorKind::Io,
DownloadErrorKind::InvalidRequest => UpdateErrorKind::UnsupportedSource,
DownloadErrorKind::AllSourcesFailed if all_candidates_missing(error) => {
UpdateErrorKind::SourceMissing
}
DownloadErrorKind::ServerError
| DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::RangeUnsupported
| DownloadErrorKind::AllSourcesFailed => UpdateErrorKind::Network,
};
let mut message = format!("{}({url})", error.message);
if !error.suppressed.is_empty() {
message.push_str(&format!(";各源:{}", error.suppressed.join(";")));
}
UpdateError::new(kind, message)
}
/// 所有候选是不是都回「这份文件不存在」。
///
/// 引擎把逐源失败压成一条 [`DownloadErrorKind::AllSourcesFailed`],原始的 404/410 分类只剩在
/// 失败摘要文本里(`download::http::classify_status` 的文案:`HTTP 404`)。不还原这一层,
/// 「发布清单里挂了个没上传的产物」会被报成「网络失败 + 可重试」,重试多少次都一样。
fn all_candidates_missing(error: &DownloadError) -> bool {
!error.suppressed.is_empty()
&& error
.suppressed
.iter()
.all(|line| line.contains("HTTP 404") || line.contains("HTTP 410"))
}
+9 -6
View File
@@ -1,8 +1,9 @@
//! 自更新内核:版本比较、发布清单、渠道准入、下载校验、原子替换。 //! 自更新内核:版本比较、发布清单、渠道准入、下载校验、原子替换。
//! //!
//! 用户已定的主线是「安装包 + 自动更新通道」,但**更新源还没拍板**。所以这一层只做与源无关 //! 更新源已定(D50):**GitHub Releases 是唯一更新源**,真实实现在
//! 的部分,源本身是插拔的 [`UpdateSource`]:core 里没有任何 CDN 常量、没有任何厂商名, //! [`GitHubReleaseSource`](清单固定地址 + 产物走 `core::download` 引擎)。
//! 接哪家由上层注入(本地目录源 [`DirSource`] 是内置的对照/测试实现)。 //! 这一层仍然保持源无关:核心只认 [`UpdateSource`] 三个方法,接哪家由上层注入
//! (本地目录源 [`DirSource`] 留作对拍基准与内网手工分发)。
//! //!
//! 设计输入:`DevDocs/analyse/07-settings-packaging-update-p2.md` §4(HMCL 自更新)与 §6.2 //! 设计输入:`DevDocs/analyse/07-settings-packaging-update-p2.md` §4(HMCL 自更新)与 §6.2
//! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。 //! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。
@@ -23,9 +24,9 @@
//! ```text //! ```text
//! version 语义化版本解析与排序(alpha/beta/rc、build metadata) channel 渠道阶梯与准入 //! version 语义化版本解析与排序(alpha/beta/rc、build metadata) channel 渠道阶梯与准入
//! manifest 清单模型 + JSON 校验 target 目标平台与包形态 //! manifest 清单模型 + JSON 校验 target 目标平台与包形态
//! source UpdateSource trait + 本地目录源 verify 暂存 + 长度/摘要校验 //! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50)
//! checker 选择哪一份发布、急不急 install 原子替换 + 回滚 //! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急
//! error 分类与文案 //! install 原子替换 + 回滚 error 分类与文案
//! ``` //! ```
//! //!
//! # 刻意不做的事 //! # 刻意不做的事
@@ -40,6 +41,7 @@
pub mod channel; pub mod channel;
pub mod checker; pub mod checker;
pub mod error; pub mod error;
pub mod github;
pub mod install; pub mod install;
pub mod manifest; pub mod manifest;
pub mod source; pub mod source;
@@ -53,6 +55,7 @@ pub use crate::platform::os_info::OsFamily;
pub use self::channel::Channel; pub use self::channel::Channel;
pub use self::checker::{CheckOutcome, UpdateCandidate, UpdateChecker, Urgency}; pub use self::checker::{CheckOutcome, UpdateCandidate, UpdateChecker, Urgency};
pub use self::error::{UpdateError, UpdateErrorKind}; pub use self::error::{UpdateError, UpdateErrorKind};
pub use self::github::GitHubReleaseSource;
pub use self::install::{ pub use self::install::{
backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy, backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy,
}; };
+5 -6
View File
@@ -1,12 +1,11 @@
//! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。 //! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。
//! //!
//! 更新源(CDN / 对象存储 / GitHub Release / 内网文件服务器)**还没拍板**,所以这里 //! 这一层刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层
//! 刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层 //! 注入。真实源已定(D50):[`super::GitHubReleaseSource`],它在 `github.rs`,网络与镜像策略
//! 注入。将来接 HTTP 源时,实现里用 `tokio`/`reqwest` 或 //! 全交给 `crate::download`。
//! [`crate::download::blocking::block_on`] 都行,trait 形状不用动。
//! //!
//! 内置的实现只有一个:[`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发、 //! 内置的另一个实现是 [`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发,
//! 以及给将来的 HTTP 实现当对照物。 //! 以及给远端实现当对拍基准。
//! //!
//! # 本地源的目录约定 //! # 本地源的目录约定
//! //!
@@ -0,0 +1,387 @@
//! GitHub Releases 源:固定清单地址、产物 URL 解析、渠道只有 stable、与 `DirSource` 对拍。
//!
//! 全程离线:假 HTTP 服务起在 `127.0.0.1` 上,`base` 指向它——没有一个用例访问 github.com
//! 或 gh-proxy(那部分只能真联网验收,见 mission 报告的「没验的部分」)。
use std::fs;
use std::sync::Arc;
use crate::download::mirrors::SOURCE_GH_PROXY;
use crate::download::{DownloadEngine, RegionProfile, SOURCE_OFFICIAL};
use crate::update::github::{DEFAULT_BASE, DEFAULT_OWNER, DEFAULT_REPO, MANIFEST_ASSET};
use crate::update::{
Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target, UpdateChecker, UpdateError,
UpdateErrorKind, UpdateSource, Version,
};
use super::support::{artifact, engine_for, manifest, release, FakeHttp, FakeResponse, TempDir};
const PAYLOAD: &[u8] = b"github release payload";
/// 假服务上清单所在路径(与本源拼出来的地址逐字符对齐)。
fn manifest_path() -> String {
format!("/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}")
}
/// 按 D49 命名规则的产物文件名。
fn asset_name() -> &'static str {
"unlucky-launcher_0.6.0_windows_x64.exe"
}
/// 假服务上产物所在路径。
fn asset_path() -> String {
format!(
"/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{}",
asset_name()
)
}
/// 一份可用的 stable 清单(产物 url 就是 D49 文件名)。
fn stable_manifest_json() -> String {
manifest(
"stable",
&[release(
"0.6.0",
r#","notes":"修掉启动崩溃""#,
&[artifact("win-x64", "portable-exe", asset_name(), PAYLOAD)],
)],
)
}
/// 解析上面那份清单(多数字段级断言都用它)。
fn parsed_stable() -> ReleaseManifest {
ReleaseManifest::from_json(&stable_manifest_json()).expect("清单应可解析")
}
fn target() -> Target {
Target::parse("win-x64").expect("目标平台写法合法")
}
fn source_with(engine: Arc<DownloadEngine>, base: &str) -> GitHubReleaseSource {
GitHubReleaseSource::new(engine).with_base(base)
}
/// 本地假服务 + 指向它的源(直连模式:不套 gh-proxy,请求落在 127.0.0.1 上)。
fn local_source(tmp: &TempDir, server: &FakeHttp) -> GitHubReleaseSource {
source_with(engine_for(tmp, RegionProfile::direct(0)), &server.base())
}
#[test]
fn manifest_url_is_the_fixed_latest_download_path() {
let tmp = TempDir::new("gh-url");
let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0)));
assert_eq!(source.owner(), DEFAULT_OWNER);
assert_eq!(source.repo(), DEFAULT_REPO);
assert_eq!(source.base(), DEFAULT_BASE);
let expected = format!(
"{DEFAULT_BASE}/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}"
);
assert_eq!(
source.manifest_url(Channel::Stable).as_deref(),
Some(expected.as_str()),
"D50 定死的固定地址,一个字符都不许飘"
);
assert_eq!(
source.source_id(),
format!("github:{DEFAULT_OWNER}/{DEFAULT_REPO}")
);
// fork / 自建镜像仓:owner/repo 可换,路径形状不变。
let fork = GitHubReleaseSource::with_repo(
engine_for(&tmp, RegionProfile::direct(0)),
"someone",
"ull-fork",
)
.with_base("http://127.0.0.1:1/");
assert_eq!(
fork.manifest_url(Channel::Stable).as_deref(),
Some("http://127.0.0.1:1/someone/ull-fork/releases/latest/download/manifest.json"),
"base 末尾斜杠要去掉,别拼出双斜杠"
);
assert_eq!(fork.source_id(), "github:someone/ull-fork");
}
#[test]
fn channels_other_than_stable_publish_nothing() {
let tmp = TempDir::new("gh-channels");
let server = FakeHttp::start(vec![(
manifest_path().as_str(),
FakeResponse::json(&stable_manifest_json()),
)]);
let source = local_source(&tmp, &server);
for channel in [Channel::Beta, Channel::Alpha, Channel::Nightly] {
assert!(
source.manifest_url(channel).is_none(),
"{channel} 不该有清单地址"
);
let manifest = source.fetch_manifest(channel).expect("没开通道不是错误");
assert!(
manifest.is_none(),
"{channel} 应返回 Ok(None)(不发布即无更新)"
);
}
assert!(
server.hits().is_empty(),
"非 stable 渠道一次请求都不该发(实际:{:?})",
server.hits()
);
}
#[test]
fn artifact_url_appends_the_release_download_path() {
let tmp = TempDir::new("gh-artifact-url");
let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0)));
let artifact = &parsed_stable().releases[0].artifacts[0];
let url = source.artifact_url(artifact).expect("应能拼出地址");
assert_eq!(
url,
format!(
"{DEFAULT_BASE}/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{}",
asset_name()
)
);
// `./name` / `/name` 与裸文件名等价(清单是人手写的)。
for written in [format!("./{}", asset_name()), format!("/{}", asset_name())] {
let mut tweaked = artifact.clone();
tweaked.url = written;
assert_eq!(source.artifact_url(&tweaked).expect("应可解析"), url);
}
// 已经写了完整地址 → 原样使用(清单可以临时指向别处)。
let mut absolute = artifact.clone();
absolute.url = "https://example.invalid/dist/ull.exe".to_string();
assert_eq!(
source.artifact_url(&absolute).expect("绝对地址原样"),
"https://example.invalid/dist/ull.exe"
);
// 其它协议不认(那是别的源的活)。
let mut unsupported = artifact.clone();
unsupported.url = "ftp://example.invalid/ull.exe".to_string();
assert_eq!(
source
.artifact_url(&unsupported)
.expect_err("ftp 不是本源能取的")
.kind,
UpdateErrorKind::UnsupportedSource
);
}
#[test]
fn fetches_and_parses_the_manifest_from_the_base() {
let tmp = TempDir::new("gh-manifest-ok");
let server = FakeHttp::start(vec![(
manifest_path().as_str(),
FakeResponse::json(&stable_manifest_json()),
)]);
let source = local_source(&tmp, &server);
let manifest = source
.fetch_manifest(Channel::Stable)
.expect("应取到清单")
.expect("清单存在");
assert_eq!(manifest.channel, Channel::Stable);
assert_eq!(manifest.releases.len(), 1);
assert_eq!(manifest.releases[0].version.to_string(), "0.6.0");
assert_eq!(server.hits(), vec![manifest_path()]);
}
#[test]
fn missing_manifest_in_the_release_is_not_an_error() {
// 最新 release 没挂 manifest.json → 404 → 当成「还没发布」,不是红字。
let tmp = TempDir::new("gh-manifest-404");
let server = FakeHttp::start(vec![]);
let source = local_source(&tmp, &server);
let outcome = source
.fetch_manifest(Channel::Stable)
.expect("404 不是错误");
assert!(outcome.is_none(), "取不到清单 = 还没发过版");
assert_eq!(
server.hits(),
vec![manifest_path()],
"确实问过一次,不是压根没发请求"
);
}
#[test]
fn broken_json_is_reported_as_invalid_manifest() {
let tmp = TempDir::new("gh-manifest-broken");
let server = FakeHttp::start(vec![(
manifest_path().as_str(),
FakeResponse::json("{ not json"),
)]);
let source = local_source(&tmp, &server);
let error = source
.fetch_manifest(Channel::Stable)
.expect_err("坏 JSON 必须报错");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
assert!(
error.message.contains(&server.base()),
"错误里要带出来源地址,日志才定位得到(实际:{})",
error.message
);
assert!(!error.is_retryable(), "清单坏重试多少次都一样");
}
#[test]
fn illegal_manifest_fields_are_reported() {
let tmp = TempDir::new("gh-manifest-illegal");
// size 为 0(发布脚本写漏)——字段层面的非法,走同一条 InvalidManifest 分类。
let json = r#"{"channel":"stable","releases":[{"version":"0.6.0","artifacts":[{"target":"win-x64","format":"portable-exe","url":"ull.exe","size":0,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}]}"#;
let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::json(json))]);
let source = local_source(&tmp, &server);
let error = source
.fetch_manifest(Channel::Stable)
.expect_err("size 为 0 必须报错");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
}
#[test]
fn manifest_self_reporting_another_channel_is_a_mismatch() {
let tmp = TempDir::new("gh-manifest-mismatch");
let server = FakeHttp::start(vec![(
manifest_path().as_str(),
FakeResponse::json(&manifest("beta", &[])),
)]);
let source = local_source(&tmp, &server);
let error = source
.fetch_manifest(Channel::Stable)
.expect_err("渠道不符必须报错");
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
}
#[test]
fn server_errors_are_network_errors() {
let tmp = TempDir::new("gh-manifest-500");
let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::status(500))]);
let source = local_source(&tmp, &server);
let error = source
.fetch_manifest(Channel::Stable)
.expect_err("5xx 必须报错");
assert_eq!(error.kind, UpdateErrorKind::Network);
assert!(error.is_retryable(), "网络抖动值得隔一会儿再试");
}
#[test]
fn downloads_the_artifact_through_the_engine() {
let tmp = TempDir::new("gh-artifact-download");
let server = FakeHttp::start(vec![(asset_path().as_str(), FakeResponse::bin(PAYLOAD))]);
let source = local_source(&tmp, &server);
let dest = tmp.join("staging/package.exe");
let written = source
.fetch_artifact(&parsed_stable().releases[0].artifacts[0], &dest)
.expect("应下载成功");
assert_eq!(written, PAYLOAD.len() as u64);
assert_eq!(fs::read(&dest).expect("目标文件应存在"), PAYLOAD);
assert_eq!(
server.hits(),
vec![asset_path()],
"包体走引擎,请求地址就是解析出来的那条"
);
}
#[test]
fn missing_artifact_is_reported_as_source_missing() {
let tmp = TempDir::new("gh-artifact-404");
let server = FakeHttp::start(vec![]);
let source = local_source(&tmp, &server);
let error: UpdateError = source
.fetch_artifact(
&parsed_stable().releases[0].artifacts[0],
&tmp.join("staging/package.exe"),
)
.expect_err("产物不存在必须报错");
assert_eq!(error.kind, UpdateErrorKind::SourceMissing);
assert!(!error.is_retryable(), "产物没上传,重试多少次都一样");
}
#[test]
fn mirror_first_region_puts_the_gh_proxy_in_front() {
// 镜像策略不新造:国内(镜像优先)环境下 gh-proxy 前缀由引擎的镜像表 + 地区档案决定。
let tmp = TempDir::new("gh-mirror");
let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::mirror_first(0)));
let artifact_url = source
.artifact_url(&parsed_stable().releases[0].artifacts[0])
.expect("应能拼出地址");
let planned = source.candidates(&artifact_url);
assert!(
planned.len() >= 2,
"镜像优先至少要有镜像与官方两条候选(实际 {planned:?})"
);
assert_eq!(planned[0].source, SOURCE_GH_PROXY);
assert_eq!(
planned[0].url,
format!("https://gh-proxy.com/{artifact_url}")
);
assert!(
planned
.iter()
.any(|candidate| candidate.source == SOURCE_OFFICIAL && candidate.url == artifact_url),
"官方地址仍要留作兜底(实际 {planned:?})"
);
// 直连模式不套前缀:只剩官方一条。
let direct = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0)));
let planned_direct = direct.candidates(&artifact_url);
assert_eq!(planned_direct.len(), 1);
assert_eq!(planned_direct[0].source, SOURCE_OFFICIAL);
// 清单地址同样吃到 gh-proxy(国内拿不到 releases/latest/download 的正是清单这一刻)。
let manifest_url = source.manifest_url(Channel::Stable).expect("stable 有清单");
let planned_manifest = source.candidates(&manifest_url);
assert_eq!(planned_manifest[0].source, SOURCE_GH_PROXY);
assert_eq!(
planned_manifest[0].url,
format!("https://gh-proxy.com/{manifest_url}")
);
}
#[test]
fn agrees_with_dir_source_on_the_same_manifest() {
// 对拍:同一份清单、同一个当前版本、同一个目标平台,两个源的判定必须一模一样。
let tmp = TempDir::new("gh-dir-parity");
let json = stable_manifest_json();
// 本地目录源:清单与产物都在临时目录里(产物 url 是相对文件名)。
tmp.write(
&format!("{}.json", Channel::Stable.as_str()),
json.as_bytes(),
);
tmp.write(asset_name(), PAYLOAD);
let dir = DirSource::new(tmp.path());
// GitHub 源:同一份 JSON 由假服务提供,产物 url 解析成 release 产物地址。
let server = FakeHttp::start(vec![
(manifest_path().as_str(), FakeResponse::json(&json)),
(asset_path().as_str(), FakeResponse::bin(PAYLOAD)),
]);
let github = local_source(&tmp, &server);
let current = Version::parse("0.5.0").expect("版本号合法");
let from_dir = UpdateChecker::new(dir, target())
.check(&current, Channel::Stable)
.expect("本地源应能判定");
let from_github = UpdateChecker::new(github, target())
.check(&current, Channel::Stable)
.expect("GitHub 源应能判定");
assert!(
from_dir.is_available(),
"基准用例应当有可用更新(实际 {from_dir:?})"
);
assert_eq!(
from_github, from_dir,
"同一份清单,两个源选出的版本/产物/紧急度必须一致"
);
}
+4 -2
View File
@@ -7,12 +7,14 @@
//! * 渠道准入:阶梯比较 + 预发布阶段表; //! * 渠道准入:阶梯比较 + 预发布阶段表;
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留; //! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用; //! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝。 //! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝;
//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。
//! //!
//! 全程离线:所有字节都在临时目录里造,没有一个用例发网络请求。 //! 全程离线:所有字节都在临时目录或 127.0.0.1 的假 HTTP 服务上造,没有一个用例发公网请求。
mod channel_cases; mod channel_cases;
mod checker_cases; mod checker_cases;
mod github_cases;
mod install_cases; mod install_cases;
mod manifest_cases; mod manifest_cases;
mod source_cases; mod source_cases;
+186 -1
View File
@@ -8,7 +8,10 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use crate::download::verify::digest_bytes; use crate::download::verify::digest_bytes;
use crate::download::HashAlgo; use crate::download::{
ContentCache, DownloadEngine, EngineConfig, HashAlgo, HealthRegistry, MirrorTable,
RegionProfile,
};
/// 一次性临时目录(`Drop` 时尽力删除;删不掉也留在系统 temp 下不碍事)。 /// 一次性临时目录(`Drop` 时尽力删除;删不掉也留在系统 temp 下不碍事)。
pub(crate) struct TempDir { pub(crate) struct TempDir {
@@ -80,3 +83,185 @@ pub(crate) fn manifest(channel: &str, releases: &[String]) -> String {
releases.join(",") releases.join(",")
) )
} }
/// 造一台测试用下载引擎:镜像表用内置清单,健康度只在内存里,缓存落临时目录。
///
/// `region` 决定「官方优先」还是「镜像优先」——gh-proxy 前缀只在该是后一种时生效。
pub(crate) fn engine_for(tmp: &TempDir, region: RegionProfile) -> std::sync::Arc<DownloadEngine> {
std::sync::Arc::new(DownloadEngine::new(
EngineConfig {
// 失败用例不必等三次退避。
max_attempts_per_source: 1,
..EngineConfig::default()
},
std::sync::Arc::new(MirrorTable::builtin()),
std::sync::Arc::new(region),
std::sync::Arc::new(HealthRegistry::in_memory()),
std::sync::Arc::new(ContentCache::open(tmp.join("cache"))),
))
}
/// 假服务的一条路由应答。
#[derive(Debug, Clone)]
pub(crate) struct FakeResponse {
pub(crate) status: u16,
pub(crate) content_type: &'static str,
pub(crate) body: Vec<u8>,
}
impl FakeResponse {
/// 200 + JSON 体。
pub(crate) fn json(body: &str) -> Self {
Self {
status: 200,
content_type: "application/json",
body: body.as_bytes().to_vec(),
}
}
/// 200 + 二进制体(模拟 release 里的产物)。
pub(crate) fn bin(body: &[u8]) -> Self {
Self {
status: 200,
content_type: "application/octet-stream",
body: body.to_vec(),
}
}
/// 固定状态码 + 空体(404 / 500 用)。
pub(crate) fn status(status: u16) -> Self {
Self {
status,
content_type: "text/plain",
body: Vec::new(),
}
}
}
/// 进程内极简 HTTP/1.1 假服务:按**完整请求路径**查表应答,未命中的路径一律 404。
///
/// 用它而不是 httpmock:项目里下载测试就是这么干的(零额外依赖),而且能把
/// 「请求真的发到了哪个 URL」记下来给断言用。
pub(crate) struct FakeHttp {
addr: std::net::SocketAddr,
hits: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl FakeHttp {
/// 起服务(`routes` 是「路径 → 应答」表)。
pub(crate) fn start(routes: Vec<(&str, FakeResponse)>) -> Self {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("绑定本地端口");
let addr = listener.local_addr().expect("取本地地址");
let routes: Vec<(String, FakeResponse)> = routes
.into_iter()
.map(|(path, response)| (path.to_string(), response))
.collect();
let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let worker_hits = std::sync::Arc::clone(&hits);
let worker_stop = std::sync::Arc::clone(&stop);
let handle = std::thread::spawn(move || {
for stream in listener.incoming() {
if worker_stop.load(std::sync::atomic::Ordering::SeqCst) {
break;
}
let Ok(mut stream) = stream else { break };
let Some(path) = read_request_path(&mut stream) else {
continue;
};
worker_hits
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(path.clone());
let response = routes.iter().find(|(route, _)| route == &path).map_or_else(
|| FakeResponse::status(404),
|(_, response)| response.clone(),
);
let _ = write_response(&mut stream, &response);
}
});
Self {
addr,
hits,
stop,
handle: Some(handle),
}
}
/// 站点根地址(可直接喂给 `GitHubReleaseSource::with_base`)。
pub(crate) fn base(&self) -> String {
format!("http://{}", self.addr)
}
/// 已经收到的请求路径(按到达顺序)。
pub(crate) fn hits(&self) -> Vec<String> {
self.hits
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
}
impl Drop for FakeHttp {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
// 自己连一下唤醒阻塞中的 accept,否则 join 会一直等。
let _ = std::net::TcpStream::connect(self.addr);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
/// 读一个请求的路径(只读头部,够用)。
fn read_request_path(stream: &mut std::net::TcpStream) -> Option<String> {
use std::io::Read;
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).ok()?;
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let head = String::from_utf8_lossy(&buffer).to_string();
head.lines()
.next()?
.split_whitespace()
.nth(1)
.map(str::to_string)
}
fn write_response(
stream: &mut std::net::TcpStream,
response: &FakeResponse,
) -> std::io::Result<()> {
use std::io::Write;
let head = format!(
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
response.status,
reason(response.status),
response.content_type,
response.body.len()
);
stream.write_all(head.as_bytes())?;
stream.write_all(&response.body)?;
stream.flush()
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Status",
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ publish.workspace = true
# 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。 # 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。
# 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。 # 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。
[dependencies] [dependencies]
unlucky-design = { path = "../design", version = "0.2.1" } unlucky-design = { path = "../design", version = "0.2.2" }
# 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内): # 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内):
# - png:启动器背景图最常见的无损格式,也是截图通道用的格式; # - png:启动器背景图最常见的无损格式,也是截图通道用的格式;
+1 -1
View File
@@ -22,7 +22,7 @@ serde_json.workspace = true
# 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。 # 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。
# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。 # host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.2" }
[lints] [lints]
workspace = true workspace = true
+28 -2
View File
@@ -55,7 +55,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
| 文件 | 作用 | 行数上限 | | 文件 | 作用 | 行数上限 |
|------|------|---------| |------|------|---------|
| `packaging/README.md` | 本文:索引 + §11 / §12(契约入口;§1–§10 见 `docs/`) | ≤400 | | `packaging/README.md` | 本文:索引 + §11 / §12 / §13(契约入口;§1–§10 见 `docs/`) | ≤400 |
| `packaging/docs/artifacts-and-version.md` | §1 产物矩阵 + §2 版本来源 | ≤400 | | `packaging/docs/artifacts-and-version.md` | §1 产物矩阵 + §2 版本来源 | ≤400 |
| `packaging/docs/build-and-ci.md` | §3 怎么构建(本地 / CI) | ≤400 | | `packaging/docs/build-and-ci.md` | §3 怎么构建(本地 / CI) | ≤400 |
| `packaging/docs/runtime-deps.md` | §4 每平台运行时依赖 + §5 软渲染兜底 | ≤400 | | `packaging/docs/runtime-deps.md` | §4 每平台运行时依赖 + §5 软渲染兜底 | ≤400 |
@@ -69,4 +69,30 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 | | `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 |
| `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 | | `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 |
| `.github/workflows/release-build.yml` | `workflow_call`:单槽位构建(体检 / cargo / 打包 / 上传 artifact) | ≤400 | | `.github/workflows/release-build.yml` | `workflow_call`:单槽位构建(体检 / cargo / 打包 / 上传 artifact) | ≤400 |
| `.github/workflows/release-publish.yml` | `workflow_call`:发布(`PUBLISH_SLOTS` 裁剪 / SHA256SUMS / Release) | ≤400 | | `packaging/make_manifest.py` | 更新清单 `manifest.json` 的生成与自验(D50,见 §13) | ≤400 |
| `packaging/tests/manifest.golden.json` | 清单 golden 样例:`make_manifest.py --self-test` 逐字节对拍 | ≤400 |
| `.github/workflows/release-publish.yml` | `workflow_call`:发布(`PUBLISH_SLOTS` 裁剪 / SHA256SUMS / 清单 / Release) | ≤400 |
## 13. 更新清单 `manifest.json`(D50)
清单作为**固定文件名**的 Release 资产挂着;客户端按 `releases/latest/download/manifest.json` 取(D50)。
字段形状的唯一权威是 `crates/core/src/update/manifest.rs` 的 `ReleaseManifest`(`deny_unknown_fields` +
逐字段校验:semver、`size > 0`、摘要长度/字符集、`target`/`format` 取值表)——生成器只负责把那套形状填对。
- **生成器**:`packaging/make_manifest.py`(零第三方依赖)。CI 在 `release-publish.yml` 里**重算
`SHA256SUMS.txt` 之后**调它,所以清单描述的是「真正要发的资产」,不是「构建出来的全部」。
- **同源,不双写**:每个 `sha256` 直接读 `SHA256SUMS.txt`,`size` 取自同一批文件的 `stat()`;产物名里的版本段
必须等于 `--version`(tag 与 `Cargo.toml` 漂了就没有产物可对,当场失败)。`--self-test` 用
`packaging/tests/manifest.golden.json` 逐字节对拍,并跑 14 条负例证明严格解析不是摆设。
- **只放启动器产物**:`ull` 不进清单。DTO 的 `Artifact` 没有角色字段,`Release::artifact_for()` 取「第一个
target 命中的产物」,把 CLI 并列写进去会让「该覆盖哪个文件」取决于数组顺序(`ull` 照旧进 `SHA256SUMS.txt`)。
- **`url` 两种形态**:默认裸文件名(相对源根,`DirSource` 口径,本地/内网用);CI 传 `--asset-base-url`
拼绝对地址,且**钉 tag 不钉 latest**——清单里每条都指向与它自报版本相符的字节。
- `min_supported` / `notes` 只是**可填而不默认填**:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
后者在 Release 正文里已有,两处各写一份迟早漂。
- 本地复跑:`python3 packaging/make_artifacts.py --manifest` → 与 CI 同一段代码,产出 `dist/manifest.json`。
**D51 定案(2026-11-06,方案甲)**:GitHub 的 `releases/latest/download/*` 只指向**非 prerelease** 的最新
Release。稳定版发布时显式 dispatch `prerelease=false` 让 latest 生效(客户端地址不变);push tag 默认仍
`prerelease=true`,视为预发布观察、不进稳定通道。发版 checklist 第六件 = 确认 prerelease 标记与通道一致;
v0.2.2 起首个适用。
+3 -2
View File
@@ -16,6 +16,7 @@
| `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_x64.tar.gz` | 单二进制归档 | 同上 | | `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_x64.tar.gz` | 单二进制归档 | 同上 |
| 每槽位 | 随所属槽位 | `ull_<ver>_<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` | | 每槽位 | 随所属槽位 | `ull_<ver>_<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` |
| 全平台 | 仅已发布资产 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) | | 全平台 | 仅已发布资产 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
| 全平台 | 仅已发布资产 | `manifest.json` | **更新清单**(D50,见 `README.md` §13) | 渠道 + 版本 + 各平台产物(`target`/`format`/`url`/`size`/`sha256`);生成器 `make_manifest.py` |
约定细节: 约定细节:
@@ -29,7 +30,7 @@
LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。 LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。
- `unlucky-launcher` 本体也认全部 CLI 子命令(M82),`ull` 不是第二个界面,而是**不链接图形栈**的同一套命令(差异见 §4 与 §7.4),两者不是重复品。 - `unlucky-launcher` 本体也认全部 CLI 子命令(M82),`ull` 不是第二个界面,而是**不链接图形栈**的同一套命令(差异见 §4 与 §7.4),两者不是重复品。
M118:GUI 的**构建产物**名是 `unlucky-launcher-iced`(`crates/app` 已被 workspace `exclude`,不再产出 `unlucky-launcher`);对外文件名仍是 `unlucky-launcher`,改名在 `make_artifacts.py` 落盘时(`LAUNCHER_BIN` 找产物 / `LAUNCHER_ASSET` 定对外名),所以下载链接 / `SHA256SUMS` / 本文矩阵不变。 M118:GUI 的**构建产物**名是 `unlucky-launcher-iced`(`crates/app` 已被 workspace `exclude`,不再产出 `unlucky-launcher`);对外文件名仍是 `unlucky-launcher`,改名在 `make_artifacts.py` 落盘时(`LAUNCHER_BIN` 找产物 / `LAUNCHER_ASSET` 定对外名),所以下载链接 / `SHA256SUMS` / 本文矩阵不变。
- `<ver>` 不带前导 `v`(tag 是 `v0.2.1`,文件名是 `0.2.1`)——由脚本统一裁剪。 - `<ver>` 不带前导 `v`(tag 是 `v0.2.2`,文件名是 `0.2.2`)——由脚本统一裁剪。
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644), - UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
同源构建的 sha256 不受打包时刻影响。 同源构建的 sha256 不受打包时刻影响。
- **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` / Mach-O - **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` / Mach-O
@@ -42,7 +43,7 @@
- `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件), - `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件),
任何一个 workspace member 版本不一致时直接失败。 任何一个 workspace member 版本不一致时直接失败。
- 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验, - 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验,
**不一致即失败**——避免出现 tag 说 0.2.1、产物里写 0.2.0 的发布事故。 **不一致即失败**——避免出现 tag 说 0.2.2、产物里写 0.2.1 的发布事故。
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中), - `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。 这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。 - 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
+3 -3
View File
@@ -12,7 +12,7 @@
cargo build --release --locked # 产出 target/release/{unlucky-launcher-iced,ull}(M118:Slint 版不再进 workspace) cargo build --release --locked # 产出 target/release/{unlucky-launcher-iced,ull}(M118:Slint 版不再进 workspace)
cargo build --profile fast --bins # 只是想快点看到画面/跑测试:快档(§6),产物在 target/fast/ cargo build --profile fast --bins # 只是想快点看到画面/跑测试:快档(§6),产物在 target/fast/
python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt
python packaging/make_artifacts.py --version v0.2.1 # 顺带校验版本一致性 python packaging/make_artifacts.py --version v0.2.2 # 顺带校验版本一致性
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟 python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
``` ```
@@ -41,7 +41,7 @@ python packaging/make_artifacts.py --slot windows-arm64 \
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false = - 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false =
只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch 只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch
才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.1`。 才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.2`。
分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以 分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以
「dispatch 只构建不发布」与「dispatch 能关 pre-release」是两条互不冲突的规则。 「dispatch 只构建不发布」与「dispatch 能关 pre-release」是两条互不冲突的规则。
- 矩阵**由 `plan` job 按 `all_slots` 生成**(`matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`:矩阵本身 - 矩阵**由 `plan` job 按 `all_slots` 生成**(`matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`:矩阵本身
@@ -66,7 +66,7 @@ python packaging/make_artifacts.py --slot windows-arm64 \
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run, - **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run,
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的 `inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
`workflow_dispatch` 显式给了输入才断言(`prerelease=true` → `--prerelease`,`false` → `--prerelease=false`)。 `workflow_dispatch` 显式给了输入才断言(`prerelease=true` → `--prerelease`,`false` → `--prerelease=false`)。
要改标记两条路等价且互不打架:① 走流水线 `gh workflow run release.yml --ref v0.2.1 -f prerelease=false` 要改标记两条路等价且互不打架:① 走流水线 `gh workflow run release.yml --ref v0.2.2 -f prerelease=false`
(会落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑 (会落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑
不碰该标记,不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。 不碰该标记,不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。
- **M145 拆分(本节新增的唯一一段)**:上面所有口径都还成立,只是"在哪个文件里"变了 —— `release.yml` 只留 - **M145 拆分(本节新增的唯一一段)**:上面所有口径都还成立,只是"在哪个文件里"变了 —— `release.yml` 只留
@@ -28,7 +28,7 @@
| Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb | | Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb |
| Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client | | Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
| Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) | | Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) |
| Android Termux + X11 | — | 试验级(不承诺) | CLI 与 GUI 均需源码自建;GUI 已在单机 CPU 渲染下验证(`packaging/docs/termux-glibc-build.md`),不在本发布矩阵内 | | Android Termux + X11 | — | 试验级(不承诺) | CLI 与 GUI 均需源码自建;GUI 已在单机真机验证两条路径各一次(CPU 软渲染 + 自编 Mesa/turnip GPU 加速,配方见 `packaging/docs/termux-glibc-build.md`、证据见 `README.termux.md`),不在本发布矩阵内 |
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not `packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。 published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
+1 -1
View File
@@ -32,7 +32,7 @@
构建环境:rustc 1.99.1 stable / `x86_64-pc-windows-gnu`(MinGW 链接器)。§7.2/§7.3 的早数字是另有 worker 并发做全量 debug 构建时测的(偏悲观);§7.1 的三行 M96 数据是空载冷编译。 构建环境:rustc 1.99.1 stable / `x86_64-pc-windows-gnu`(MinGW 链接器)。§7.2/§7.3 的早数字是另有 worker 并发做全量 debug 构建时测的(偏悲观);§7.1 的三行 M96 数据是空载冷编译。
**本节以下所有输出里的 `0.1.0` 都是当时那次运行的真实输出(workspace 版本那时是 `0.1.0`),属实测记录,不随后续版本号改动而回填**;当前版本见 `Cargo.toml` 的 `[workspace.package] version`。§2 的校验机制不依赖任何写死的数字,改版本号不会让它失效。产物名同理且更容易被误读成现状:§7.3 / §7.5 里的 `unlucky-launcher-0.1.0-windows-*.exe`(**连字符**)是 2026-11-01 那几次实测的原文,D49(2026-11-05)才改成下划线形态——同一槽位现在叫 `unlucky-launcher_0.2.1_windows_x64.exe`(规则见 §1);拿旧名去 Release 页或磁盘上找文件必然找不到,那不代表发布挂了。 **本节以下所有输出里的 `0.1.0` 都是当时那次运行的真实输出(workspace 版本那时是 `0.1.0`),属实测记录,不随后续版本号改动而回填**;当前版本见 `Cargo.toml` 的 `[workspace.package] version`。§2 的校验机制不依赖任何写死的数字,改版本号不会让它失效。产物名同理且更容易被误读成现状:§7.3 / §7.5 里的 `unlucky-launcher-0.1.0-windows-*.exe`(**连字符**)是 2026-11-01 那几次实测的原文,D49(2026-11-05)才改成下划线形态——同一槽位现在叫 `unlucky-launcher_0.2.2_windows_x64.exe`(规则见 §1);拿旧名去 Release 页或磁盘上找文件必然找不到,那不代表发布挂了。
### 7.1 体积与冷构建耗时(实测字节数,不是估算) ### 7.1 体积与冷构建耗时(实测字节数,不是估算)
+28 -5
View File
@@ -4,13 +4,14 @@
Release matrix (see `packaging/README.md` for the full contract): Release matrix (see `packaging/README.md` for the full contract):
Windows x64 unlucky-launcher_<ver>_windows_x64.exe (bare exe, no zip) Windows x64 unlucky-launcher_<ver>_windows_x64.exe (bare exe, no zip)
Windows arm64 unlucky-launcher_<ver>_windows_arm64.exe (bare exe, cross-built) Windows arm64 unlucky-launcher_<ver>_windows_arm64.exe (bare exe, native arm64 runner)
Linux x64 unlucky-launcher_<ver>_linux_x64.tar.gz Linux x64 unlucky-launcher_<ver>_linux_x64.tar.gz
Linux arm64 unlucky-launcher_<ver>_linux_arm64.tar.gz Linux arm64 unlucky-launcher_<ver>_linux_arm64.tar.gz
macOS arm64 unlucky-launcher_<ver>_macos_arm64.tar.gz macOS arm64 unlucky-launcher_<ver>_macos_arm64.tar.gz
macOS x64 unlucky-launcher_<ver>_macos_x64.tar.gz macOS x64 unlucky-launcher_<ver>_macos_x64.tar.gz
every slot ull_<ver>_<platform>_<arch>[.exe] (bare CLI binary, no archive) every slot ull_<ver>_<platform>_<arch>[.exe] (bare CLI binary, no archive)
all SHA256SUMS.txt all SHA256SUMS.txt
all manifest.json (only with --manifest, see below)
Naming rule (user's call, 2026-11-05): `<name>_<version>_<platform>_<arch>`, underscore Naming rule (user's call, 2026-11-05): `<name>_<version>_<platform>_<arch>`, underscore
separated, and **no implementation detail in the file name** — the asset never says which UI separated, and **no implementation detail in the file name** — the asset never says which UI
@@ -31,10 +32,19 @@ carries LICENSE / NOTICE / README.md anyway, so a bare `ull` loses nothing.
The launcher binary also answers every CLI subcommand, so `ull` is not a second The launcher binary also answers every CLI subcommand, so `ull` is not a second
interface — it is the version of it that links no graphics stack at all. interface — it is the version of it that links no graphics stack at all.
Slot and build directory are overridable because a cross build's binaries live in `--manifest` additionally writes `manifest.json` (the update manifest, D50) into the same
`target/<triple>/release` and the build host (x64) is not the artifact (arm64): CI output directory. The generation itself lives in `packaging/make_manifest.py`, invoked as a
passes both from its matrix, and the executable header is read back so a binary can subprocess: this file stays the single source of truth for **naming** (`asset_stem()`), that
never be shipped under a slot name it was not built for. one is the single writer of the **manifest**, and it derives every sha256/size from the
`SHA256SUMS.txt` this script just wrote — one set of facts, no second bookkeeping. CI does not
use this flag: `release-publish.yml` recomputes `SHA256SUMS.txt` after pruning the publish
whitelist and generates the manifest from *that*, so the manifest always describes what ships.
Slot and build directory are overridable because the build host is not always the
artifact: a cross build's binaries live in `target/<triple>/release` under an x64
host, while every native slot (all six in CI, M140) puts them in `target/release`.
CI passes both from its matrix, and the executable header is read back so a binary
can never be shipped under a slot name it was not built for.
Usage: Usage:
cargo build --release --locked cargo build --release --locked
@@ -209,6 +219,15 @@ def write_sums(out_dir: Path, assets: list[Path]) -> Path:
return sums return sums
def write_manifest(out_dir: Path, version: str) -> Path:
"""`manifest.json`(D50):跑的是 CI 发布侧同一份脚本,事实全部来自刚写完的 SHA256SUMS.txt。"""
script = REPO_ROOT / "packaging" / "make_manifest.py"
proc = subprocess.run([sys.executable, str(script), "--dist", str(out_dir), "--version", version])
if proc.returncode != 0:
fail(f"manifest generation failed ({script.name} exited {proc.returncode})")
return out_dir / "manifest.json"
def asset_stem(name: str, version: str, slot: str) -> str: def asset_stem(name: str, version: str, slot: str) -> str:
"""`<name>_<version>_<platform>_<arch>` — 产物命名的唯一真相源。 """`<name>_<version>_<platform>_<arch>` — 产物命名的唯一真相源。
@@ -267,6 +286,7 @@ def main() -> int:
parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host") parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host")
parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})") parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})")
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run") parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
parser.add_argument("--manifest", action="store_true", help="also write manifest.json (update manifest, D50)")
args = parser.parse_args() args = parser.parse_args()
version = workspace_version() version = workspace_version()
@@ -310,6 +330,9 @@ def main() -> int:
size_mb = item.stat().st_size / (1024 * 1024) size_mb = item.stat().st_size / (1024 * 1024)
print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}") print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}")
print(f"sums: {sums.name}") print(f"sums: {sums.name}")
if args.manifest:
manifest = write_manifest(out_dir, version)
print(f"manifest: {manifest.name}")
return 0 return 0
+396
View File
@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""Generate `manifest.json` — the update manifest the launcher reads (D50).
Contract and rationale live in `packaging/README.md` §13; this docstring only states what the
code must not get wrong.
* **Shape authority** = `crates/core/src/update/manifest.rs` (`ReleaseManifest`,
`deny_unknown_fields` + per-field validation). This script only writes that shape.
* **Facts come from one place**: every `sha256` is read out of the Release's own
`SHA256SUMS.txt`, every `size` is `stat()` of the same file — the two can never disagree.
The version token inside each asset name is asserted to equal `--version`, so a tag that
drifted from `Cargo.toml` cannot yield a manifest pointing at files that do not exist.
* **Only launcher artifacts go in.** `ull` does not: the DTO has no role field and
`Release::artifact_for(target)` returns the *first* match, so listing the CLI beside the GUI
would make "which file do I overwrite" depend on array order.
* **`url`**: bare asset name by default (the `DirSource` convention in
`crates/core/src/update/source.rs`); with `--asset-base-url` a tag-pinned absolute URL,
which is what CI uses.
Usage:
python3 packaging/make_manifest.py --dist dist --version v0.2.2 --channel stable \\
--asset-base-url https://github.com/o/r/releases/download/v0.2.2
python3 packaging/make_manifest.py --self-test # golden + strict-parse self check
"""
from __future__ import annotations
import argparse
import copy
import json
import re
import sys
from pathlib import Path
from typing import NoReturn
# 本机 Python 可能是 isolated 模式(`safe_path=True`,脚本目录不进 `sys.path`),CI 的标准
# python3 则会自动加入。显式补一次,两种环境下 `import make_artifacts` 都成立。
sys.path.insert(0, str(Path(__file__).resolve().parent))
# 只借常量:产物命名(SLOTS / LAUNCHER_ASSET)、CLI 名与 SHA256SUMS 文件名都是 make_artifacts
# 的单一口径,这里不复制一份表(复制就会漂);反向依赖不存在(make_artifacts 只用子进程调本脚本)。
import make_artifacts
MANIFEST_NAME = "manifest.json"
CHANNELS = ("stable", "beta", "alpha", "nightly")
# 文件名里的平台段 -> 清单 target 的系统段(`windows_x64` -> `win-x64`)。
PLATFORM_TARGET = {"windows": "win", "macos": "mac", "linux": "linux"}
# 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有「解压即用」这一档 `portable-zip`:
# 名字带 zip,语义是「非安装器、可原地替换」(PackageFormat::needs_installer() == false)。
EXT_FORMAT = {".exe": "portable-exe", ".tar.gz": "portable-zip"}
GOLDEN = Path(__file__).resolve().parent / "tests" / "manifest.golden.json"
# DTO 侧的取值表镜像(crates/core/src/update/target.rs::parse_os / Arch::parse / PackageFormat::parse)。
TARGET_OS = {"win", "windows", "mac", "macos", "osx", "darwin", "linux"}
TARGET_ARCH = {
"x64", "x86_64", "x86-64", "amd64", "arm64", "aarch64", "arm64e", "x86", "i686", "i386", "x86_32",
}
FORMATS = {"msi", "dmg", "appimage", "deb", "rpm", "portable-zip", "zip", "portable-exe", "exe"}
SEMVER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$")
SUMS_LINE = re.compile(r"^([0-9A-Fa-f]{64})[ \t]+\*?(.+)$")
def fail(message: str) -> NoReturn:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
def normalize_version(text: str) -> str:
"""`v0.2.2` -> `0.2.2`(tag 带 `v`,产物名与清单都不带)。"""
version = text.strip()
version = version[1:] if version.startswith("v") else version
if not semver_ok(version):
fail(f"version `{text}` is not a semver")
return version
def semver_ok(text: str) -> bool:
"""Mirror of `crates/core/src/update/version.rs::Version::parse` on the accepted shapes."""
match = SEMVER.match(text)
if not match:
return False
if any(len(number) > 1 and number.startswith("0") for number in match.group(1, 2, 3)):
return False
pre, build = match.group(4), match.group(5)
if pre is not None:
for ident in pre.split("."):
if not ident or (ident.isdigit() and len(ident) > 1 and ident.startswith("0")):
return False
if build is not None and any(not ident for ident in build.split(".")):
return False
return True
def read_sums(dist: Path) -> dict[str, str]:
"""`SHA256SUMS.txt` -> {file name: lowercase sha256}. Missing/odd entries are fatal."""
sums_path = dist / make_artifacts.SUMS_NAME
if not sums_path.is_file():
fail(f"missing {sums_path}(清单的 sha256 只从这里取,不另算一份)")
sums: dict[str, str] = {}
for lineno, line in enumerate(sums_path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip():
continue
match = SUMS_LINE.match(line)
if not match:
fail(f"{sums_path.name}:{lineno} 不是 `<sha256> <文件名>` 形态:{line!r}")
digest, name = match.group(1).lower(), match.group(2)
if name in sums:
fail(f"{sums_path.name} 里 {name} 出现两次(无法确定哪个 sha256 是对的)")
sums[name] = digest
if not sums:
fail(f"{sums_path} 是空的:没有产物就没有清单")
return sums
def split_asset(name: str, version: str) -> tuple[str, str, str, str]:
"""`<name>_<version>_<platform>_<arch>[.exe|.tar.gz]` -> (name, platform, arch, ext)."""
stem, ext = name, ""
for candidate in EXT_FORMAT:
if stem.endswith(candidate):
stem, ext = stem[: -len(candidate)], candidate
break
parts = stem.rsplit("_", 3)
if len(parts) != 4:
fail(f"`{name}` 不符合 `<名字>_<版本>_<平台>_<架构>` 命名(见 make_artifacts.asset_stem)")
product, tag_version, platform_name, arch_name = parts
if tag_version != version:
fail(f"`{name}` 里的版本是 {tag_version},清单要的是 {version}(tag 与 Cargo.toml 漂了?)")
if (platform_name, arch_name) not in {
(slot_os, slot_arch) for slot_os, slot_arch, _kind in make_artifacts.SLOTS.values()
}:
fail(f"`{name}` 的平台/架构 {platform_name}/{arch_name} 不在发布矩阵里")
return product, platform_name, arch_name, ext
def build_artifacts(dist: Path, version: str, base_url: str) -> list[dict[str, object]]:
"""Launcher artifacts from `SHA256SUMS.txt`, in matrix order (= priority order)."""
sums = read_sums(dist)
by_slot: dict[tuple[str, str], dict[str, object]] = {}
for name, digest in sums.items():
product, platform_name, arch_name, ext = split_asset(name, version)
if product != make_artifacts.LAUNCHER_ASSET:
continue # `ull` 不进清单,理由见模块注释
fmt = EXT_FORMAT.get(ext)
if fmt is None:
fail(f"`{name}` 没有可识别的扩展名,无法判定包形态(format)")
path = dist / name
if not path.is_file():
fail(f"{make_artifacts.SUMS_NAME} 提到 {name},但 {path} 不存在")
size = path.stat().st_size
if size == 0:
fail(f"{path} 是 0 字节:这类产物只可能是打包漏了")
url = f"{base_url.rstrip('/')}/{name}" if base_url else name
if (platform_name, arch_name) in by_slot:
fail(f"{platform_name}/{arch_name} 出现两个启动器产物,清单无法确定该用哪一个")
by_slot[(platform_name, arch_name)] = {
"target": f"{PLATFORM_TARGET[platform_name]}-{arch_name}",
"format": fmt,
"url": url,
"size": size,
"sha256": digest,
}
order = [(slot_os, slot_arch) for slot_os, slot_arch, _kind in make_artifacts.SLOTS.values()]
artifacts_out = [by_slot[key] for key in order if key in by_slot]
if not artifacts_out:
fail(f"{make_artifacts.SUMS_NAME} 里没有任何 `{make_artifacts.LAUNCHER_ASSET}_*` 产物,清单会是空的")
return artifacts_out
def build_manifest(
dist: Path,
version: str,
channel: str,
*,
base_url: str = "",
min_supported: str | None = None,
notes: str | None = None,
released_at: str | None = None,
force: bool = False,
) -> dict[str, object]:
"""Assemble the manifest document (the shape `ReleaseManifest` deserializes)."""
if channel not in CHANNELS:
fail(f"未知渠道 `{channel}`(只认 {' / '.join(CHANNELS)})")
release: dict[str, object] = {"version": version, "channel": channel}
if released_at:
release["released_at"] = released_at
if notes:
release["notes"] = notes
release["force"] = force
if min_supported:
release["min_supported"] = normalize_version(min_supported)
release["artifacts"] = build_artifacts(dist, version, base_url)
return {"channel": channel, "releases": [release]}
def render(doc: dict[str, object]) -> str:
"""Deterministic JSON: same facts + same args ⇒ byte-identical file (golden-testable)."""
return json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
# ── 严格解析镜像(DTO 的看门规则)───────────────────────────────────────────────────────
# `manifest.rs` 用 `deny_unknown_fields` + 逐字段校验;这里是它的 Python 镜像,只为了在没有
# Rust 的机器上(本地打包、CI 发布步)也能当场炸掉拼错的字段。字段/取值的权威永远是 .rs。
def check(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def check_keys(where: str, obj: object, allowed: set[str], required: set[str]) -> dict:
check(isinstance(obj, dict), f"{where} 必须是对象")
unknown = set(obj) - allowed
check(not unknown, f"{where} 出现未知字段 {sorted(unknown)}(DTO 是 deny_unknown_fields)")
missing = required - set(obj)
check(not missing, f"{where} 缺字段 {sorted(missing)}")
return obj
def strict_parse(doc: object) -> None:
"""Raise `ValueError` on anything `ReleaseManifest::from_json` would reject."""
top = check_keys("清单", doc, {"channel", "releases"}, {"channel"})
check(top["channel"] in CHANNELS, f"清单渠道 `{top['channel']}` 无法识别")
releases = top.get("releases", [])
check(isinstance(releases, list), "releases 必须是数组")
seen: list[str] = []
for index, raw in enumerate(releases):
where = f"releases[{index}]"
release = check_keys(
where,
raw,
{"version", "channel", "released_at", "notes", "force", "min_supported", "artifacts"},
{"version"},
)
version = release["version"]
check(isinstance(version, str) and semver_ok(version), f"{where}.version 不是合法 semver")
check(version not in seen, f"{where}.version {version} 在清单里重复出现")
seen.append(version)
if "channel" in release:
check(release["channel"] in CHANNELS, f"{where}.channel 无法识别")
for key in ("released_at", "notes"):
check(release.get(key) is None or isinstance(release.get(key), str), f"{where}.{key} 必须是字符串")
check(isinstance(release.get("force", False), bool), f"{where}.force 必须是布尔")
if release.get("min_supported") is not None:
check(semver_ok(str(release["min_supported"])), f"{where}.min_supported 不是合法 semver")
artifacts_list = release.get("artifacts", [])
check(isinstance(artifacts_list, list), f"{where}.artifacts 必须是数组")
for art_index, raw_art in enumerate(artifacts_list):
art_where = f"{where}.artifacts[{art_index}]"
art = check_keys(
art_where,
raw_art,
{"target", "format", "url", "size", "sha256", "sha1"},
{"target", "format", "url", "size"},
)
os_part, _, arch_part = str(art["target"]).strip().partition("-")
check(
bool(arch_part) and os_part.lower() in TARGET_OS and arch_part.lower() in TARGET_ARCH,
f"{art_where}.target `{art['target']}` 无法识别(形如 win-x64)",
)
check(str(art["format"]).lower() in FORMATS, f"{art_where}.format 无法识别")
check(isinstance(art["url"], str) and art["url"].strip(), f"{art_where}.url 为空")
size = art["size"]
check(isinstance(size, int) and not isinstance(size, bool) and size > 0, f"{art_where}.size 必须 > 0")
sha256, sha1 = art.get("sha256"), art.get("sha1")
check(sha256 is not None or sha1 is not None, f"{art_where} 既没有 sha256 也没有 sha1")
digest_hex = str(sha256 if sha256 is not None else sha1)
expect = 64 if sha256 is not None else 40
check(
len(digest_hex) == expect and all(c in "0123456789abcdefABCDEF" for c in digest_hex),
f"{art_where} 的摘要应为 {expect} 位十六进制",
)
def self_test() -> int:
"""Golden 对拍(`.gitattributes` 的 `eol=lf` 保证跨平台同字节)+ 严格解析正负例:证明写出来的东西能被 DTO 吃下。"""
import tempfile
with tempfile.TemporaryDirectory() as tmp:
dist = Path(tmp)
version = "9.9.9"
# 两种启动器形态(裸 exe / 归档)+ 一个 CLI 资产:既覆盖 target/format 映射,
# 也钉住「`ull` 不进清单」这条决定。
payloads = {
f"{make_artifacts.LAUNCHER_ASSET}_{version}_windows_x64.exe": b"gui payload\n",
f"{make_artifacts.LAUNCHER_ASSET}_{version}_linux_x64.tar.gz": b"tar payload\n",
f"{make_artifacts.CLI_BIN}_{version}_windows_x64.exe": b"cli payload\n",
}
for name, payload in payloads.items():
(dist / name).write_bytes(payload)
(dist / make_artifacts.SUMS_NAME).write_text(
"".join(f"{make_artifacts.sha256(dist / name)} {name}\n" for name in payloads),
encoding="utf-8",
newline="\n",
)
doc = build_manifest(
dist,
version,
"stable",
min_supported="9.0.0",
notes="golden 样例(自验用,不是真实发布)",
released_at="2026-01-02T03:04:05Z",
)
text = render(doc)
if "--write-golden" in sys.argv:
GOLDEN.parent.mkdir(parents=True, exist_ok=True)
GOLDEN.write_text(text, encoding="utf-8", newline="\n")
print(f"golden: {GOLDEN} 已重写")
expected = GOLDEN.read_text(encoding="utf-8")
if text != expected:
print("--- generated ---", text, "--- golden ---", expected, sep="\n", file=sys.stderr)
fail("生成的清单与 golden 不一致(改了口径就 --write-golden 重出并对 diff)")
print(f"golden: 与 {GOLDEN.name} 逐字节一致")
strict_parse(doc)
print("strict: 生成物通过严格解析镜像(deny_unknown_fields + 逐字段校验)")
# 负例:只测「顺利通过」等于没测——每条篡改都必须被拒。
for label, mutate in (
("未知字段", lambda d: d.update(extra=1)),
("未知版本字段", lambda d: d["releases"][0].update(extra=1)),
("未知产物字段", lambda d: d["releases"][0]["artifacts"][0].update(extra=1)),
("缺 sha256", lambda d: d["releases"][0]["artifacts"][0].pop("sha256")),
("摘要长度错", lambda d: d["releases"][0]["artifacts"][0].update(sha256="deadbeef")),
("size 为 0", lambda d: d["releases"][0]["artifacts"][0].update(size=0)),
("size 是字符串", lambda d: d["releases"][0]["artifacts"][0].update(size="12")),
("未知 target", lambda d: d["releases"][0]["artifacts"][0].update(target="solaris-x64")),
("未知 format", lambda d: d["releases"][0]["artifacts"][0].update(format="msix")),
("空 url", lambda d: d["releases"][0]["artifacts"][0].update(url=" ")),
("版本重复", lambda d: d["releases"].append(copy.deepcopy(d["releases"][0]))),
("版本号不合法", lambda d: d["releases"][0].update(version="1.0")),
("渠道不认识", lambda d: d.update(channel="canary")),
("缺顶层 channel", lambda d: d.pop("channel")),
):
tampered = copy.deepcopy(doc)
mutate(tampered)
try:
strict_parse(tampered)
except ValueError:
print(f"strict: 负例「{label}」被正确拒绝")
continue
fail(f"严格解析放过了负例「{label}」——这条校验形同虚设")
print("self-test: ok")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--dist", default="dist", help="directory holding the assets + SHA256SUMS.txt (default: dist/)")
parser.add_argument("--version", help="release version, e.g. v0.2.2 (must match the asset file names)")
parser.add_argument("--channel", default="stable", choices=CHANNELS, help="manifest channel (D50: stable only for now)")
parser.add_argument("--out", help=f"output path (default: <dist>/{MANIFEST_NAME})")
parser.add_argument("--asset-base-url", default="", help="prefix for artifact urls; omit for bare file names")
parser.add_argument("--min-supported", help="lowest version still supported (omitted when not given)")
parser.add_argument("--notes", help="plain-text release notes for the update prompt")
parser.add_argument("--notes-file", help="read --notes from this file")
parser.add_argument("--released-at", help="RFC3339 timestamp (display only)")
parser.add_argument("--force", action="store_true", help="mark this release as force-update")
parser.add_argument("--self-test", action="store_true", help="golden + strict-parse self check (no files written)")
parser.add_argument("--write-golden", action="store_true", help=argparse.SUPPRESS)
args = parser.parse_args()
if args.self_test:
return self_test()
if not args.version:
parser.error("--version is required (it must match the asset file names)")
dist = Path(args.dist)
if not dist.is_absolute():
dist = make_artifacts.REPO_ROOT / dist
notes = args.notes
if args.notes_file:
notes = (make_artifacts.REPO_ROOT / args.notes_file).read_text(encoding="utf-8").strip()
doc = build_manifest(
dist,
normalize_version(args.version),
args.channel,
base_url=args.asset_base_url,
min_supported=args.min_supported,
notes=notes,
released_at=args.released_at,
force=args.force,
)
text = render(doc)
strict_parse(doc)
out = Path(args.out) if args.out else dist / MANIFEST_NAME
if not out.is_absolute():
out = make_artifacts.REPO_ROOT / out
out.write_text(text, encoding="utf-8", newline="\n")
release = doc["releases"][0]
artifacts_list = release["artifacts"]
print(f"manifest: {out.name} channel={doc['channel']} version={release['version']} artifacts={len(artifacts_list)}")
for item in artifacts_list:
print(f" {item['target']:<14} {item['format']:<13} {item['size']:>10} B sha256={str(item['sha256'])[:12]} {item['url']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -59,7 +59,7 @@ Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt
| Linux arm64, X11 | not built (unverified) | first class (unverified) | glibc 2.35+, `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` | | Linux arm64, X11 | not built (unverified) | first class (unverified) | glibc 2.35+, `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` |
| Linux arm64, Wayland | not built (unverified) | supported (unverified) | the above plus `wayland-client` | | Linux arm64, Wayland | not built (unverified) | supported (unverified) | the above plus `wayland-client` |
| Windows 8.1/8/7 | not built | unsupported | the baseline is Windows 10 | | Windows 8.1/8/7 | not built | unsupported | the baseline is Windows 10 |
| Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI and GUI from source only; GUI verified once on CPU rendering (see `packaging/docs/termux-glibc-build.md`) | | Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI and GUI from source only; GUI verified on-device twice: CPU software rendering and self-built Mesa/turnip GPU acceleration (see `packaging/docs/termux-glibc-build.md` and `README.termux.md`) |
`windows-x64` is the only published slot this release; Windows ARM64 is kept out until its build is green `windows-x64` is the only published slot this release; Windows ARM64 is kept out until its build is green
(see Downloads above). Both arm64 rows are formal matrix slots, not the experimental Termux path, and (see Downloads above). Both arm64 rows are formal matrix slots, not the experimental Termux path, and
+29
View File
@@ -0,0 +1,29 @@
{
"channel": "stable",
"releases": [
{
"version": "9.9.9",
"channel": "stable",
"released_at": "2026-01-02T03:04:05Z",
"notes": "golden 样例(自验用,不是真实发布)",
"force": false,
"min_supported": "9.0.0",
"artifacts": [
{
"target": "win-x64",
"format": "portable-exe",
"url": "unlucky-launcher_9.9.9_windows_x64.exe",
"size": 12,
"sha256": "8ac2dbaad20a7b318a75485359b0302d6d2550093b69296cea39db18d4836ef0"
},
{
"target": "linux-x64",
"format": "portable-zip",
"url": "unlucky-launcher_9.9.9_linux_x64.tar.gz",
"size": 12,
"sha256": "dcd549d0571bb74eaee60af5c0bc702714fb0de052094336ab10e1e0c7faea0e"
}
]
}
]
}