47 Commits
Author SHA1 Message Date
AskaEth b2d9f3927f Merge branch 'feat/m160-modpackpick-per-file-overwrite-deci'
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 13:57:08 +08:00
worker-modpackpick d568cc6f37 fix(m160-r1): 预检失败不再静默空转 + 写侧条目数闸 + 判据归一 + 冲突页对齐
复审 reviewer-modpackpick-r1(p2-1items / fix-then-merge)的返工。

P2-1 预检失败被吞掉(错误呈现回归)
- host::import::spawn_scan 在 `scan.error.is_some()` 时**再发一条 PackEvent::Report**
  (`convert::failed_report_of` 把 core 的 ImportError 含 hint 逐字段搬过去),于是页面回到
  既有的「致命 —— 已回滚」结果块,不再落回入口态。
- convert 里把失败报告的组装收成一处(`failed_report` / `failed_report_of` → `failed_report_with`)。
- 证据:`a_failed_scan_still_reaches_the_screen_as_a_report` 断言**事件序列**
  `["scan-with-error", "report-failed"]` + 报告里 code/message 与预检同源(MultiMC
  `formatVersion: 2`,detect 认得、read 才失败);`a_clean_scan_does_not_emit_a_report` 是对照。
  变异验证:删掉那条 Report 发送 ⇒ 该用例红。

P3-1 写侧补条目数闸
- `WriteLimits` 增加 `entries`(生产口径 = `PACK_MAX_ENTRIES`);`preflight` 在**读盘之前**
  按「清单 1 条 + 覆盖文件 N 条」判 `count > limit`。
- 证据:`the_write_side_refuses_a_pack_over_the_unified_entry_count`(20001 条声明只放 1 个
  真文件 ⇒ 报 `pack_limits` 而不是 `pack_io`,即闸在读盘前)、
  `the_entry_count_gate_is_the_same_number_as_the_read_side`(两侧同一个数)。
  变异验证:注释掉 `guard_entries` 调用 ⇒ 前一条红。

P3-2 「内容相同」判据归一
- 删掉 `scan::judge` 里与 `merge::decide` 重复的 `disk.sha1 == pack_sha1` 早退,只留
  `decide().reason?`;两处文档互相指路(`merge.rs` 注明这是唯一判据)。
- 变异验证:把 `decide` 的相同分支改成"冲突" ⇒ **8 条**红(含 4 条 scan 用例与 merge 单测)。

P3-3/P3-4 口径与注释
- `TogglePick` 注释改成与代码一致(保留项留在表里,靠 `decisions()` 过滤)。
- `unknown_remote` 的计数与文案统一:判不了的两种来路(补全前没路径 / 有路径但缺 sha1)都算,
  注记载明两者;新增 `a_remote_entry_with_a_path_but_no_sha1_is_counted_as_unjudgeable`。

视觉(tower 看图给的三条)
- 冲突行两种取值都改成**同几何的 pill**(保留=描边、覆盖=tonal),各自右对齐进同宽度的槽 ⇒
  右边缘同一条线;路径列吃 `Fill` 把槽推到行右缘。
- 列表与动作行之间加 8dp 呼吸;分页行的 Previous/Next 用 `Space::Fill` 顶到右端,
  与内容列对齐(不再是右侧一大片留白)。
- 重新出图:C:/Temp/m160-shots/pick-light.png / pick-dark.png。

顺手
- `format/write.rs` 的单测拆到 `format/write/tests.rs`(纯搬移,守 400 行),并把里面两个
  自己拼 `%TEMP%` 路径的用例改用 `TempRoot`(断言红了也不留垃圾)。
- `import_decisions.rs` 的夹具拆到 `tests/decisions_support.rs`(纯搬移,守 400 行)。

证据:fmt 干净、clippy -D warnings 干净、`cargo test --workspace --all-targets -j 4`
= 2512 passed / 0 failed / **4 ignored**(51 个 test binary);跑前/跑后 `%TEMP%` 的
`ull-pack-*` 计数 1922→1922、`ull-pack-*-zip64-*` 0→0、`ull-import-*` 1360→1360(增量 0),
df 106G→106G 余量不变。
2026-11-07 13:52:44 +08:00
worker-modpackpick 4defc41e8d feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一
主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
  逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
  缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
  全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
  spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
  预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。

限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
  (20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
  不等式在 pack/tests/limits.rs 里用 const 断言钉死。

测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
  under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
  multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。

测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
  以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。

证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
2026-11-07 13:52:43 +08:00
AskaEth a4f096eea3 Merge branch 'feat/m159-updategui-auto-update-client-wiring'
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 13:35:22 +08:00
AskaEth 04ac90911e fix(m159): r1 复审返工 —— parse.rs 拆出子模块 + 五条 P3 真问题
按独立复审 `reviewer-updategui-r1` 的 p1-1 / fix-then-merge 返工。

P1-1(挡门)
- `crates/cli/src/cli/parse.rs` 406 → **382** 行:`fn parse_update` 搬到
  `crates/cli/src/cli/parse/update.rs`(与 `parse/{accounts,agent,instances}.rs` 同形状)。
  全仓 `crates/**/*.rs` 最大仍是 400(`design/tests/card_neutral.rs` 等三处,未越线)。
  上一轮报告里「所有改动文件都在 400 行内」那句是错的,已在 r2 报告里更正。

P3-6 `Job::apply` 不查包形态
- 新增 `self_replaceable(format)`(只有 `PortableExe` 允许)+ `ensure_self_replaceable`,
  **挡在取包之前**;错误分类 `InvalidManifest`(发布侧挂错,换源/重试无用)。
  比复审建议的 `needs_installer()` 更严:`portable-zip`/`appimage` 同样不能原地当 exe。
- 用例:`crates/core/src/update/tests/job_cases.rs`(msi/zip 被拒 + 目标零改动 + 暂存目录
  没被建 + 便携 exe 整环成功 + Nothing 不碰目标 + 闸门的源码锚点)。

P3-7 `ULL_UPDATE_DIR` 生产可用
- `default_source` 改走 `local_dir_override()`:`if !cfg!(debug_assertions) { return None }`,
  发布构建里整段被常量折叠掉(这条路径在 release 二进制里不存在)。判据与三档取舍写在
  `job.rs` 的文档表里(为什么不用 CLI 参数、为什么不用运行期开关)。

P3-1「忽略这个版本」语义
- `UpdateView.dismissed: bool` → `skipped: Option<String>`(记**版本号**):忽略 0.2.3 之后
  同一次运行里冒出 0.2.4 **照常提示**;手动检查清空(`begin_check(manual)`),静默检查不清。
  文案保持「忽略这个版本」。presenter / app 侧视图模型 / 两处用例同步。

P3-3 CLI `--json` 失败信封缺结构化分类
- `CliError` 加 `detail`(`Option<Box<Value>>`,装箱避免 `result_large_err`)+ `with_detail`,
  `to_json` 输出 `error.detail`;`commands/update.rs` 的 failure 填
  `{"kind": core 分类 id, "retryable": 可重试位}`。
- 上一轮报告里引的 `{"kind":"digest_mismatch"}` 形状确实不存在(那是 CLI 的 user/internal),
  r2 报告换成了实机 dump。

P3-4 update 命令没有解析测试
- `crates/cli/tests/parse_test.rs`:`update_subcommands`(check / apply / `--dest` 两种写法)
  + `update_usage_errors`(无子命令 / 未知子命令 / 多余位置参数 / 缺少取值 / 未知选项);
  `help_text_covers_every_command` 的 topic 列表补 `"update"`。

P3-5 `install.rs` 模块文档与实测矛盾
- 改成「可以改名、不能覆盖/删除」,写明 M159 在 Windows 真机验过运行中的 exe 自替换,
  并提醒别再照旧文档去设计「退出自身 → 另一个进程搬运」的双进程方案。

顺手(复审 P3-2/P3-8 里低成本的两条)
- 设置页补「下载中」态(`SettingsUpdateApplying` 不再是死 key,下载期间不画「下载并安装」);
  渠道显示改读 `update.channel`(`stable` 走 key,其它原样);删掉从未使用的
  `UpdateBannerRestartLater`(catalog + 金表同步,29 条 key 全部有引用)。
- `adopt` 只让**检查**刷新 `checked_at`(应用回报不再冒充「上次检查」);
  `UpdateView::default()` 的 `current_version` 取本进程版本号(mock 下不再空白)。

门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-159`):fmt 绿、clippy -D warnings 绿、
`cargo test --workspace --all-targets --no-fail-fast -j 4` **exit 0**(51 个测试二进制全 ok,
`core::addon::pack` 未被 skip)。该族用例复现了复审记的泄漏:一次全量在 `/tmp` 留 72 个
`ull-pack-*`(`/tmp` 149M → 8853M),跑完已 `rm -rf` 清回 150M。
2026-11-07 13:03:18 +08:00
AskaEth 1c8f6aa1b3 feat(m159): 自更新 GUI 客户端接线 + CLI update check/apply 同一内核
D50 的「检查 → 提示 → 可拒绝 → 下载校验 → 下一次启动生效」整环接线,GUI 与
CLI 共用 core 的一条内核(`update::Job`),并顺带修掉 M157 最后一轮复审的三处 P3。

内核(core::update)
- 新增 job.rs:`Job { source, target, staging, dest }` + `check` / `apply`(复查 →
  取包 → 长度/SHA256 双检 → 原子替换),`default_source`(GitHub,`ULL_UPDATE_DIR`
  可切本地目录夹具)、`process_dest`、`staging_dir`。
- `mod.rs` 导出 job 一族;`source.rs` 为 `Box<dyn UpdateSource>` 补 trait 转发
  (壳层要运行期选源,`UpdateChecker` 是泛型的)。

presenter(宿主通道)
- `dto/update.rs`:`UpdateView` / `UpdateOutcome`(Idle/Checking/UpToDate/Available/
  MissingArtifact/Failed 六态,**失败与最新不折叠**)/ `UpdateBanner` 优先级判据。
- `host/update.rs`:Factory(生产 GitHub / 单测本地目录)+ core 结论 → DTO 的唯一映射
  + 更新域偏好(`cache/update/prefs.json`)。
- `host/update_state.rs`:Runtime(会话状态机:begin_check/adopt/begin_apply/dismiss/
  set_auto_check)+ `CoreHost` 派发与后台线程。
- `AppCommand::{CheckUpdate, ApplyUpdate, DismissUpdate, SetUpdateAutoCheck}`、
  `CoreEvent::Update`、`AppState.update`;`refresh_all` 按开关派发静默检查。

app-iced
- 设置页新增第 5 组「更新」(当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次检查与
  结果 / 下载并安装 / 就位后立即重启),走 `settings::view` 的 `update` 形参 + 按下标
  分发(与 M106 语言行同一取舍)。
- 顶部提示条(每页可见):有新版本(版本号 + 体积 + 说明摘要,可安装/可忽略)、下载中、
  已就位(立即重启);静默检查失败**不**弹条(原因进设置页)。
- `pages::Message::Update(UpdateAction)` → `app/update/update_action.rs`;「立即重启」
  拉同参数的新进程后退出(截图通道不重启)。

CLI
- `ull update check` / `ull update apply [--dest <path>]`:复用同一 `Job`;四种结论分开
  报(`--json` 的 `status`),失败退出码 1(`update_check_failed` / `update_apply_failed`)。

i18n
- 新增 `catalog/update.rs`(30 条,链尾追加)+ 金表对应 30 行。

M157 复审 P3 三件
- `app/update/instances.rs` 模块文档改成「清脏的唯一时机是回程快照等于表单」。
- `pages/instances/demo.rs` 的 `demo_form` 注释改成现状(两个参数框早已是单行输入)。
- `pages/instances/launch_args.rs`:门限不满足时只灰 `opengl`/`vulkan`,`default` 仍可点
  (它是「不下发」的安全档);判据抽成 `backend_chip_enabled` 并补 3 条用例。浅色 + 灰化
  两张图:`C:/Temp/m159-shots/instances-launch-args-{light,gated}.png`。

顺带:harness 支持 `ULL_SETTINGS_GROUP=<下标>`(拍第 5 组的验收图需要它)。

验证:cargo fmt --check 绿;cargo clippy --workspace --all-targets -D warnings 绿;
cargo test --workspace --all-targets 51 个测试二进制全绿(唯一例外:core::addon::pack
一族要造 4 GiB 稀疏文件,本机磁盘 99% 满 ⇒ StorageFull,与本次改动无关);CLI 用本地
目录夹具跑通「发现 9.9.9 → 下载 → 校验 → 替换运行中的 exe → 备份留旧文件」,并验证
摘要不符时拒绝应用、目标文件零改动、暂存目录零残留。
2026-11-07 12:09:42 +08:00
AskaEth a80362915a Merge branch 'feat/m157-instargs-per-instance-launch-args-a'
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 11:28:53 +08:00
AskaEth ee67965b9b fix(m157): r4 修复未收集的周快照用例 + 两处 clippy 红,并补整环回归(复审 p1-2)
P1-1 crates/core/src/launch/overrides/tests.rs:modded_weekly_snapshot_needs_the_root_version_for_the_gate 原先写在 backend_allowed_combines_capability_and_threshold 函数体内(unnameable_test_items,永不执行)。移到模块层;移动后首次真正执行暴露出用例自身的 EmptyJavaPath(AdapterOptions 默认 java 为空),补 JavaSelection 后绿。--list 现在 10 条 launch::overrides::tests::(含该名字)。

P1-2 clippy -D warnings 两处:cli/commands/instance_launch.rs needless_borrows_for_generic_args(去 &);presenter/mock_data.rs items_after_test_module(mock_launch 移到 mod tests 之前)。

P3-a 新增整环回归 app/tests/instances_save_loop.rs:真 Message → dispatch → 后端回错误 Toast → pump → refresh_view → sync_launch_form,断言 4g 草稿与脏标记都还在。
P3-b LaunchForm::matches 内存改按数值比(解析失败退文本),补 memory_is_compared_by_value_not_by_text(04096 / +4096 / 清空三档)。
P3-c 门槛文案不再在 catalog 硬编码 26.2:InstancesLaunchBackendGated 改用 {min} 占位符,页面从 LaunchFacts::min_version(← core GRAPHICS_BACKEND_MIN_VERSION)代入;金表同步;灰化态截图重拍。
2026-11-07 11:06:18 +08:00
AskaEth 211e07b470 fix(launch): M157 r3 周快照门限按继承根版本 + 保存失败保留草稿(复审 P2-1/P2-2)
P2-1 周快照 + 加载器的 --graphicsBackend 静默丢失:presenter host::launch::build_request 与 cli snapshot::prepare 给 SnapshotInput.game_version 传 None,装配按带加载器后缀的 version_id(26w14a-fabric-0.16.5)判门限,GameVersion::is_weekly_snapshot 的 6 字符形态认不出来。两个调用点改为传继承链的 MC 根版本(25 行注释说明「门槛与拼装必须同一口径」)。回归:core launch::overrides 的周快照实际装配用例(正/负两档)、presenter host::launch::tests、cli snapshot::tests 各一条根版本断言。

P2-2 保存失败丢草稿:app/update/instances.rs 的 Save 分支派发前就把 launch_form.dirty 清掉,失败事件后 pump 用旧快照重建视图并覆盖表单。改为派发不清脏;LaunchForm::sync 只在「回程快照已等于表单」(= 落盘确认)时清脏并重播种,否则保留草稿。回归:failed_save_keeps_the_draft / confirmed_save_clears_dirty_and_reseeds / save_dispatch_keeps_the_dirty_flag。

顺带:launch::overrides 测试拆到 overrides/tests.rs(守 400 行)。
2026-11-07 10:36:37 +08:00
AskaEth c76a1437e6 fix(app-iced): M157 r2 详情列加滚动区 + 启动参数卡对齐信息卡(D30 复审)
- 详情列(含卡片与动作行)套 scrollable:1180×1000 下被裁的内容可达、滚动条与左列同一套几何;右侧 X2 内缩,卡片描边不再被滚动条压住
- 启动参数卡改用 frame::card(card_padding 21 + OutlinedCard 描边),标题 LabelMedium 不加粗——与相邻信息卡同款
- 段间距 X3→X2,卡片不再比内容长
- 演示夹具补 Game arguments(--fullscreen)
- presenter host 单测临时目录加序号(同一进程并行用例互删导致 flake)
2026-11-07 10:36:35 +08:00
AskaEth e2021645d7 feat(launch): M157 实例级启动参数(五项回落 + graphicsBackend 三态)
core: 新增 game/launch_overrides(LaunchOverrides 五项 All-Option + 三级回落 + 文本补丁校验)与 launch/overrides(拼装落位:全局 java.extra_args 进 user.jvm、实例 jvmArgs 进 override_jvm、gameArgs 进 user.game、memory 进 -Xmx、backend 进 --graphicsBackend 并复用既有版本门槛告警);实例 schema 登记 launch.* 五键。

presenter: dto/LaunchArgsView+LaunchArgsPatch,主机侧读/写与门槛判定(backend_allowed),AppCommand::SetInstanceLaunchArgs。

cli: 新命令 instances launch-args(读/写/--clear),launch 装配链消费回落值。

app-iced: 实例详情页「启动参数」区(JVM/游戏参数、内存、Java、三态后端 chips + 灰化提示 + 保存/恢复),表单状态归 App、i18n 中英文案同步。
2026-11-07 10:36:35 +08:00
AskaEth d68523b02a Merge branch 'feat/m156-termxmerge-merge-termux-glibc-into'
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: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
204 changed files with 16482 additions and 1301 deletions

No files matched your search

+14 -3
View File
@@ -1,5 +1,9 @@
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):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
@@ -54,7 +58,11 @@ jobs:
run: |
set -uo pipefail
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"
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
@@ -109,7 +117,9 @@ jobs:
exit 1
fi
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
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
printf '%s\n' "$hits" | sed 's/^/ - /'
@@ -318,9 +328,10 @@ jobs:
}
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
# 所以这个预筛不会漏)。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
if scan "$f"; then printf '%s\n' "$f"; fi
done | LC_ALL=C sort)
done | LC_ALL=C sort || true)
echo "期望(恰好这一个):$EXPECTED"
echo "实际扫到的集合:"
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
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 说明(支持矩阵模板)
shell: bash
env:
@@ -141,7 +162,12 @@ jobs:
;;
esac
# 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
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
gh release upload "$TAG" "${assets[@]}" --clobber
Generated
+9 -8
View File
@@ -4982,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]]
name = "unlucky-agent"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"axum",
"futures-util",
@@ -4997,12 +4997,13 @@ dependencies = [
[[package]]
name = "unlucky-app-iced"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"iced",
"image",
"log",
"unlucky-cli",
"unlucky-core",
"unlucky-design",
"unlucky-i18n",
"unlucky-presenter",
@@ -5010,7 +5011,7 @@ dependencies = [
[[package]]
name = "unlucky-cli"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"serde",
"serde_json",
@@ -5021,7 +5022,7 @@ dependencies = [
[[package]]
name = "unlucky-core"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"flate2",
"futures-util",
@@ -5040,15 +5041,15 @@ dependencies = [
[[package]]
name = "unlucky-design"
version = "0.2.1"
version = "0.2.2"
[[package]]
name = "unlucky-i18n"
version = "0.2.1"
version = "0.2.2"
[[package]]
name = "unlucky-palette-extract"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"image",
"unlucky-design",
@@ -5056,7 +5057,7 @@ dependencies = [
[[package]]
name = "unlucky-presenter"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -30,7 +30,7 @@ members = [
exclude = ["crates/app"]
[workspace.package]
version = "0.2.1"
version = "0.2.2"
edition = "2021"
license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"]
+21 -18
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
management from the GUI is the instance-wizard work still outstanding, and settings change as you
edit them rather than on a Save.
- **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`,
`linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not
been downloaded and run end to end. Treat them as untested output, not as "supported because the
matrix lists them".
- **Linux and macOS artifacts are unverified, and this release does not even build them.** The
reference machine is Windows; the `linux-x64`, `linux-arm64`, `macos-arm64` and `macos-x64` slots
sit in the CI matrix but the default release run skips them, and they have never been downloaded and
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
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
@@ -193,8 +194,9 @@ This list is the reason to believe the rest. None of it is hidden behind marketi
report lists every conflict so nothing is overwritten silently.
- **Install progress is stage-level**, not byte-level, on the content-library install 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, and the GUI is
unverified there.
- **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:
[`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
@@ -207,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-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-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same |
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same |
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same |
| every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| `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 — **not yet published** |
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not yet published** |
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not yet published** |
| every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| 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
missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress.
The row stays because the architecture is still supported and the slot still builds (unpublished) in
the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in
`.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the
only downloadable Windows asset.
**This release publishes `windows-x64` and nothing else.** The publish job keeps only the slots named
in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before
it writes `SHA256SUMS.txt`, so a row marked **not yet published** is a naming rule, not a downloadable
asset. Of those rows `windows-arm64` is the one CI still builds — natively, on GitHub's
`windows-11-arm` runner, for evidence — but that slot's toolchain check has not gone green yet, so its
`.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
directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
@@ -293,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/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
+59
View File
@@ -0,0 +1,59 @@
# Unlucky Launcher · Termux 适配(`termux-glibc` 分支)
这条分支把启动器带到 **Termux(Android)**:用 glibc 工具链编出 `aarch64-unknown-linux-gnu`
二进制,在 **termux-x11** 里跑 GUI;同时补上 Termux 平台识别、系统 JDK 安装与中文字体。
> 给主线的改动清单、兼容性说明与合并指南见
> [`TERMUX-ADAPTATION.md`](TERMUX-ADAPTATION.md)。
## 已验证
| 项 | 状态 | 证据 |
| --- | --- | --- |
| 源码构建(GUI + CLI) | ✅ | Rust 1.99.1 gnu + clang/lld + glibc 运行时;GPU 构建(wgpu)默认带 CPU 兜底 |
| 窗口显示 | ✅ | termux-x11 `:0`,横屏 1657×1067,首帧 ~60 ms(GPU) |
| 中文 | ✅ | 启动时注册 `/system/fonts/NotoSansCJK-Regular.ttc` |
| 微软登录 id | ✅ | 构建期注入,日志 `client_id=configured source=build` |
| 横屏适配 | ✅ | 默认窗口按 X 屏幕等比自适应(`source=termux-screen`) |
| Java 发现 | ✅ | `JAVA_HOME`/PATH + `$PREFIX/lib/jvm` 扫描 |
| Java 自动安装 | ✅ | `ull java install 17` 由内置镜像装完 openjdk-17(约 20 s) |
| GPU(wgpu) | ✅ | 自编 Mesa 26.2.6 + KGSL;wgpu 选中 Adreno 840(Vulkan/turnip) |
| Minecraft 运行 | ✅ | 真机 1.20.4 跑通:glibc JRE + arm64 natives + softpipe GL,主菜单在 X11 渲染 |
## 快速开始
完整配方(工具链、patchelf、linker、门禁)见
[`packaging/docs/termux-glibc-build.md`](packaging/docs/termux-glibc-build.md)。最短路径:
```sh
termux-x11 :0 -ac &
DISPLAY=:0 ./unlucky-launcher-iced # 默认 auto:有驱动走 GPU,没有则自动降级 CPU
```
- GPU 驱动不随 Termux 源提供,需要按构建文档 §5 自己编一次 Mesa 26 + ICD;
不装也能跑,只是 `auto` 会落到 tiny-skia(CPU)。
- 想强制某一侧:`--renderer=gpu` / `--software-renderer`。
- 窗口尺寸默认按屏幕自适应;`ULL_WINDOW_SIZE=1520x900` 可覆盖。
- Java:`ull java install 17|21|25` 在 Termux 上默认用系统包(内置镜像 + 隔离 apt 状态)。
- **数据根**:Portable 模式按**可执行文件所在目录**生成 `.ULL` / `.minecraft`;
换目录启动会看到另一套实例(`--data-dir` 目前接受并忽略)。
## 已知边界
- 仅单机(OnePlus 15 / Android 17)验证,试验级、不在发布矩阵内。
- **GPU**:已打通,但驱动是自编的(Mesa 26.2.6 + KGSL),装到
`$PREFIX/glibc/{lib,share/vulkan/icd.d}` 后应用会自动启用;Termux 官方源里的
glibc Mesa 太旧(24.2.6 不认识 Adreno 840)。驱动缺失时自动降级 CPU,不会崩。
- **MC**:Android 原生 natives 走不通(LWJGL 没有官方 Android/bionic 产物,且 GLFW 的
窗口模型与 termux-x11 的 X11 不兼容)。有效路线是 glibc JVM,已真机跑通 1.20.4:
Temurin 21 JRE(patchelf 后直接执行)+ Linux arm64 natives(启动器自动改写/镜像兜底)
+ softpipe GL。非致命项:narrator 的 flite 缺失、LWJGL JNI 版本告警;
softpipe 是软件渲染,帧率有限,zink(硬件 GL)仍卡 DRI3。
- Termux 上三条测试失败是环境差异(`/proc/*/children`、长路径脱敏、mtime flaky),
PC 的 Linux CI 不受影响。
## 后续
1. zink 的 DRI3 问题(硬件 GL;当前 softpipe 可跑但慢)——这是 MC 帧率的主要瓶颈
2. 剪贴板/IME 在 termux-x11 下的行为验证
3. narrator/flite 与音频设备(当前 missing 但不阻断)
+15 -13
View File
@@ -140,8 +140,9 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
- **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、
`macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是
- **Linux 与 macOS 产物未验证,而且本版连构建都不跑。** 参考机是 Windows;`linux-x64`、`linux-arm64`、
`macos-arm64`、`macos-x64` 四档只躺在 CI 矩阵里,默认的发布流程会跳过它们,也还没被下载下来端到端跑过;
`gh workflow run release.yml -f all_slots=true` 才编它们来取证。把它们当**未经测试的产出**,而不是
「矩阵里有就算支持」。
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉
@@ -157,7 +158,7 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
文件是被静默覆盖的。
- **内容库的安装进度是阶段级**,不是字节级。
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产,那一侧的 GUI 也未验证。
- **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-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 |
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 |
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 |
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 |
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 |
| 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 —— **暂未发布** |
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **暂未发布** |
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— **暂未发布** |
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— **暂未发布** |
| 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
`windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的
ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进
Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`,
它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。
**本版只发布 `windows-x64`。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
(`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
**暂未发布** 的行是命名规则,不是本版可下载的资产。这几行里 `windows-arm64` 是 CI 唯一真跑的那档:M140 起在
GitHub 原生 `windows-11-arm` runner 上编来取证,但该槽位的工具链体检还没绿,所以它的 `.exe` 不进 Release;
行不删是因为架构仍要支持,绿了就把键加回 `PUBLISH_SLOTS`。
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 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/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` 才整表六槽位取证) |
## 架构与模块地图
+144
View File
@@ -0,0 +1,144 @@
# Termux 适配:方案、改动清单与合并指南
> 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
## TL;DR
- **构建**:为 Termux 编出 `aarch64-unknown-linux-gnu`(glibc)二进制,在 termux-x11 里跑 GUI。
Android 原生 target 走不通(winit 的 android 后端需要 NativeActivity),所以不用它。
- **已跑通**:GUI(CPU 与 GPU 两条渲染路径)、中文、横屏自适应、JDK 发现与系统包安装、
MC 1.20.4(glibc JRE + arm64 natives + softpipe GL,真机进游戏)。
- **未收口**:MC 启动参数支持(由主线后续补)、MC 硬件 GL(zink 卡 DRI3)、剪贴板/IME。
- **对主线的影响**:所有行为都由 **运行时 Termux 识别 / `cfg(target_os = "linux")` /
文件存在** 三选一 gate 住,Windows / macOS / 普通 Linux 的默认路径不变。
## 方案
### 1. 平台识别
`crates/core/src/platform/termux.rs`:用 `TERMUX_VERSION` 或默认 `PREFIX`
(`/data/data/com.termux/files/usr`)识别 Termux,推导 `$PREFIX` 与 `$PREFIX/lib/jvm`。
不把路径写死到调用方,也不改 `OsFamily`(Android 上仍按 Linux 家族处理)。
### 2. JDK:发现 + 安装
- **发现**:`game/java/discover.rs` 的 Unix 扫描根追加 `$PREFIX/lib/jvm`,
环境可注入(`standard_roots_in`)。补上「Termux 装了 openjdk 但没设 JAVA_HOME/PATH」的盲点。
- **安装**:新增 `game/java/termux.rs`。Termux 的 openjdk 是 `.deb`(bionic),
与 Temurin 的 zip/tar.gz 不是同一形态,所以**委托包管理器**:
- 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序 `apt-get update && install`;
- lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不污染用户 apt 配置;装完清理;
- 装完从 `$PREFIX/lib/jvm` 定位,幂等(已装则不走网络)。
- **vendor 接线**:`JdkVendor::Termux`(17/21/25)。Termux 上缺省安装先试系统包,
失败再走归档链;`--vendor termux` 只走系统包。设置白名单与契约测试同步。
### 3. 字体
`crates/app-iced/src/fonts.rs`:Termux 上把 `/system/fonts/NotoSansCJK-Regular.ttc`
注册进 cosmic-text(fontdb 不扫 Android 字体目录),中文不再显示方框。
### 4. 窗口(横屏优先)
`app/boot/termux_window.rs`:Termux 上默认窗口按当前 X 屏幕等比自适应
(日志 `size_source=termux-screen`),横屏铺满、竖屏不溢出;
`ULL_WINDOW_SIZE` 覆盖与 1180×760 验收基准不变。
### 5. GPU(启动器)
- 驱动是自编的 **Mesa 26.2.6 + KGSL**,装在 `$PREFIX/glibc/{lib,share/vulkan/icd.d}`;
构建/安装步骤见 `packaging/docs/termux-glibc-build.md` §5。
- `app/boot/termux_gpu.rs`:glibc 构建 + Termux + ICD 文件存在时,在探测前设
`VK_ICD_FILENAMES` / `VK_DRIVER_FILES`;用户显式设置不覆盖。
- 实测 wgpu 选中 Adreno 840(turnip Mesa 26.2.6);无驱动自动降级 tiny-skia。
### 6. Minecraft 运行链
- **natives**:1.19+ 的清单把 natives 作为独立库条目(`org.lwjgl:...:natives-linux`),
Linux/aarch64 上改写成 `natives-linux-arm64`(`Library::linux_arm64_native_name`,
install 与 launch 共用);Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404,
下载兜底到阿里云 Maven(`install/libraries.rs`)。
- **JVM**:用 glibc 的 Temurin JRE(patchelf 后直接执行),bionic 的 Termux JVM
加载不了 glibc natives。
- **GL**:glibc Mesa 的 zink 在 termux-x11 下卡 `DRI3 not available`,
子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast` + `LIBGL_ALWAYS_SOFTWARE=1`
(`launch/defaults.rs`,用户自定义环境变量可覆盖)。
## 改动清单
| 文件 | 改动 | 平台隔离 |
| --- | --- | --- |
| `core/platform/termux.rs`(新) | Termux 识别、`$PREFIX`/JVM 根 | 纯函数,按环境判断 |
| `core/platform/mod.rs` | 导出新模块 | — |
| `core/game/java/discover.rs` | `$PREFIX/lib/jvm` 扫描 | Unix + Termux 判断 |
| `core/game/java/termux.rs`(新) | 镜像/隔离 apt 安装、`installed_home` | 仅 Termux 可执行 |
| `core/game/java/vendor.rs` / `source.rs` / `install.rs` | `JdkVendor::Termux` 与安装分派 | Termux 上才启用 |
| `core/setting/keys.rs` + 契约测试 | 白名单加 `termux` | 跨平台可选,非 Termux 明确报错 |
| `cli/commands/java_vendors.rs`、`cli/cli/help.rs`、`presenter/host/java.rs` | 展示与帮助文案 | 展示层 |
| `core/game/library.rs` | `linux_arm64_native_name` / 分类器改写 | 仅 linux+arm64 |
| `core/game/install/libraries.rs` | 现代 natives 条目改写 + Maven 兜底 | 仅 linux+arm64 |
| `core/launch/defaults.rs` | Termux softpipe 环境默认值 | `cfg(linux)` + Termux |
| `presenter/host/launch.rs`、`cli/commands/snapshot.rs` | classpath 用改写后的 arm64 坐标 | 仅 linux+arm64 |
| `app-iced/fonts.rs` | Android CJK 字体注册 | 仅 Termux |
| `app-iced/boot/termux_window.rs`(新) | 横屏自适应 | 仅 Termux |
| `app-iced/boot/termux_gpu.rs`(新) | 自动启用自编 ICD | 仅 glibc+Termux+文件存在 |
| `app-iced/Cargo.toml` | 新增 `unlucky-core` 依赖 | 复用平台识别 |
| `README.termux.md`(新)、`packaging/docs/termux-glibc-build.md`(新) | 分支说明与构建配方 | 文档 |
| `README.md` / `README.zh-CN.md` / 支持矩阵两处 | Termux 行同步 | 文档 |
## 行为变化与兼容性
- 新增 `JdkVendor::Termux` 会改变 `VENDOR_IDS` / `JDK_VENDORS` 的允许值集合
(契约测试已同步)。非 Termux 上显式选它会在安装时明确报「只在 Termux 里可用」,
默认回退序**不含** termux,PC 行为不变。
- Linux/aarch64 的 LWJGL3 natives 会被改写;x86_64、Windows、macOS 不受影响。
- `app-iced` 直接依赖 `unlucky-core`(此前通过 cli/presenter 间接依赖)。
- 所有 Termux 专属路径都需要运行时识别或文件存在才会生效;普通 Linux 桌面不会触发。
## 合并指南
1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
(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` /
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
3. 本机已知、**不影响 PC CI** 的三条失败:
- `unlucky-core --test launch`:`/proc/<pid>/task/<pid>/children`(Android 内核没有)
- 同测试:长路径被脱敏成 `<opaque>`
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
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`,
行为不变、用例原样留在原处。
## 验证矩阵(本机)
| 项 | 结果 |
| --- | --- |
| GUI 截图(横屏 1657×1067) | ✅ 中文正常,`client_id=configured source=build` |
| GPU 渲染 | ✅ wgpu 选中 Adreno 840(turnip Mesa 26.2.6);降级路径也验证过 |
| `ull java install 17` | ✅ SAU 镜像约 20 s,装完清理,`java list` 可见 |
| MC 1.20.4 | ✅ 真机安装(740 MB)→ 主菜单 → 建世界 → 进游戏 |
| fmt / clippy / 核心测试 | ✅ 475 条核心测试通过,clippy 零告警 |
## 后续路线
1. **MC 启动参数**(主线功能,另行排期):当前 `ull launch` 不接受自定义 JVM/游戏参数。
2. **MC 硬件 GL**:修 glibc GLX 的 DRI3(让 zink 生效),或改用 VulkanMod。
3. 剪贴板 / IME 在 termux-x11 下的行为验证。
4. narrator/flite 与音频设备(当前缺失但不阻断)。
5. GPU 驱动分发:目前每次都要按文档自编 Mesa;可考虑脚本化/预编译产物。
## 回滚策略
全部改动按提交分组(platform → fonts → docs → java → gpu → natives → mc),
可按组 revert;Termux 专属逻辑都有运行时/文件存在 gate,回滚不会波及 PC 路径。
+1 -1
View File
@@ -13,7 +13,7 @@ publish.workspace = true
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies]
unlucky-core = { path = "../core", version = "0.2.1" }
unlucky-core = { path = "../core", version = "0.2.2" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。
+7 -4
View File
@@ -22,12 +22,15 @@ path = "src/lib.rs"
[dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.1" }
unlucky-design = { path = "../design", version = "0.2.2" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.2" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 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**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
@@ -57,11 +60,11 @@ image = { version = "0.25", default-features = false, features = [
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
unlucky-cli = { path = "../cli", version = "0.2.1" }
unlucky-cli = { path = "../cli", version = "0.2.2" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`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:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
+16 -2
View File
@@ -30,6 +30,8 @@ use super::{MIN_SIZE, WINDOW_SIZE};
mod diagnostics;
mod fallback;
mod termux_gpu;
mod termux_window;
use fallback::{on_failure, respawn, OnFailure};
@@ -39,6 +41,8 @@ pub(crate) fn run(options: Options) -> iced::Result {
let boot_started = Instant::now();
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
termux_gpu::enable();
let boot = boot_probe(&options);
let decision = boot.decision;
diagnostics::report_boot(&options, &boot.probe, &decision);
@@ -131,10 +135,20 @@ fn launch(
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
let (size, size_source) = acceptance::window_size(
// Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先),
// 失败就退回 1180×760 的验收尺寸。
let (default_size, default_source) = termux_window::default_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height),
);
)
.unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default"));
let (size, size_source) =
acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height));
let size_source = if size_source == "default" {
default_source
} else {
size_source
};
let settings = diagnostics::window_settings(size);
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
diagnostics::report_window(&settings, options.scale_factor, size_source);
@@ -0,0 +1,58 @@
//! Termux 上自动启用随附的 Vulkan ICD(glibc 侧 turnip,`ull_freedreno.json`)。
//!
//! 背景:Termux 软件源里的 glibc Mesa(24.2.6)不认识 Adreno 840,硬件 GPU 需要用
//! `packaging/docs/termux-glibc-build.md` §5 里自编的 Mesa 26(KGSL)。驱动装在
//! `$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json` 时,这里在探测之前把它设进
//! `VK_ICD_FILENAMES` / `VK_DRIVER_FILES`,让 `auto` 也能选到 GPU。
//!
//! 只在 **glibc 构建**(`target_os = "linux"`)上生效:Termux 原生(bionic)构建的
//! `target_os = "android"`,glibc 驱动对它没有意义。用户显式设了这两个变量时不覆盖。
use std::path::{Path, PathBuf};
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
/// 随附 ICD 的相对路径(相对 `$PREFIX`)。
const ICD_REL: &str = "glibc/share/vulkan/icd.d/ull_freedreno.json";
/// 在 GPU 探测之前调用:装了就启用,没装就什么都不做。
pub(super) fn enable() {
if !cfg!(target_os = "linux") {
return;
}
if std::env::var_os("VK_ICD_FILENAMES").is_some()
|| std::env::var_os("VK_DRIVER_FILES").is_some()
{
return;
}
let Some(prefix) = termux::prefix(&ProcessEnv) else {
return;
};
let icd = icd_path(&prefix);
if icd.is_file() {
std::env::set_var("VK_ICD_FILENAMES", &icd);
std::env::set_var("VK_DRIVER_FILES", &icd);
}
}
/// `$PREFIX` 下随附 ICD 的路径(纯函数,测试用)。
fn icd_path(prefix: &Path) -> PathBuf {
prefix.join(ICD_REL)
}
#[cfg(test)]
mod tests {
use super::icd_path;
#[test]
fn icd_path_is_under_the_glibc_prefix() {
let path = icd_path(std::path::Path::new("/data/data/com.termux/files/usr"));
assert_eq!(
path,
std::path::PathBuf::from(
"/data/data/com.termux/files/usr/glibc/share/vulkan/icd.d/ull_freedreno.json"
)
);
}
}
@@ -0,0 +1,115 @@
//! Termux / termux-x11 下的窗口尺寸自适应(横屏优先)。
//!
//! 验收尺寸(1180×760)是 D30 的硬要求,不能改;但手机显示器可能横屏远大于它
//! (例如 2066×1272)或竖屏略小。这里只在**运行时**把「默认尺寸」换成按当前 X
//! 屏幕算出的最大等比矩形;`ULL_WINDOW_SIZE` 覆盖仍然优先(见 `acceptance::window_size`)。
//!
//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。
use std::process::Command;
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
/// 屏幕四周留白(像素)。
const MARGIN: f32 = 32.0;
/// 最大放大倍数:大屏上也不把窗口撑成巨屏。
const MAX_SCALE: f32 = 2.0;
/// `xdpyinfo` 里尺寸那一行的前缀。
const DIMENSIONS_PREFIX: &str = "dimensions:";
/// Termux 上的默认窗口尺寸:按 X 屏幕等比放大/缩小,失败返回 `None`(用调用方默认)。
///
/// `default` / `min` 由调用方传入([`super::WINDOW_SIZE`] / [`super::MIN_SIZE`]),
/// 这里不复制常量。
#[must_use]
pub(super) fn default_size(
default: (f32, f32),
min: (f32, f32),
) -> Option<((f32, f32), &'static str)> {
if !termux::is_termux(&ProcessEnv) || std::env::var_os("DISPLAY").is_none() {
return None;
}
let screen = x_screen_size()?;
Some((fit_screen(default, min, screen), "termux-screen"))
}
/// 在 `screen` 里放一个保持 `default` 宽高比的最大矩形(保留留白,不突破 `min`)。
#[must_use]
pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32)) -> (f32, f32) {
if screen.0 <= 0.0 || screen.1 <= 0.0 || default.0 <= 0.0 || default.1 <= 0.0 {
return default;
}
let available = (
(screen.0 - MARGIN * 2.0).max(1.0),
(screen.1 - MARGIN * 2.0).max(1.0),
);
let scale = (available.0 / default.0)
.min(available.1 / default.1)
.min(MAX_SCALE);
let width = (default.0 * scale).max(min.0).min(screen.0);
let height = (default.1 * scale).max(min.1).min(screen.1);
(width, height)
}
/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。
fn x_screen_size() -> Option<(f32, f32)> {
let output = Command::new("xdpyinfo").output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let line = text
.lines()
.find(|line| line.trim_start().starts_with(DIMENSIONS_PREFIX))?;
let value = line.split_whitespace().nth(1)?;
let (width, height) = value.split_once('x')?;
Some((width.parse().ok()?, height.parse().ok()?))
}
#[cfg(test)]
mod tests {
use super::fit_screen;
const DEFAULT: (f32, f32) = (1180.0, 760.0);
const MIN: (f32, f32) = (880.0, 560.0);
fn close(actual: (f32, f32), expected: (f32, f32)) {
assert!(
(actual.0 - expected.0).abs() < 0.5 && (actual.1 - expected.1).abs() < 0.5,
"expected {expected:?}, got {actual:?}"
);
}
#[test]
fn landscape_screen_scales_up_keeping_aspect() {
// 2066×1272:留白后 2002×1208,高度是瓶颈 → 1875.6×1208。
close(
fit_screen(DEFAULT, MIN, (2066.0, 1272.0)),
(1875.58, 1208.0),
);
}
#[test]
fn portrait_screen_still_fits_width() {
// 1272×2066:宽度是瓶颈 → 1208×778。
close(fit_screen(DEFAULT, MIN, (1272.0, 2066.0)), (1208.0, 778.03));
}
#[test]
fn huge_screen_is_capped_at_two_times() {
close(fit_screen(DEFAULT, MIN, (4000.0, 3000.0)), (2360.0, 1520.0));
}
#[test]
fn tiny_screen_never_goes_below_min_or_overflow() {
let (width, height) = fit_screen(DEFAULT, MIN, (800.0, 600.0));
assert_eq!(width, 800.0, "宽度受屏幕限制");
assert_eq!(height, 560.0, "高度不小于最小尺寸");
}
#[test]
fn invalid_screen_falls_back_to_default() {
assert_eq!(fit_screen(DEFAULT, MIN, (0.0, 0.0)), DEFAULT);
}
}
+6
View File
@@ -178,6 +178,12 @@ pub struct App {
pub(super) account_form: pages::account::Form,
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
pub(super) account: pages::account::Page,
/// 实例页「启动参数」区的表单(M157):五项 + 脏标记 + 绑定的实例 id。
///
/// 住在 `App` 的理由与 `download_draft` 完全一致:`refresh_view` 每次都整棵重建
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
pub(super) launch_form: pages::LaunchForm,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
@@ -124,6 +124,7 @@ impl App {
agent_host: super::presenter::AgentHost::from_env(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(),
launch_form: pages::LaunchForm::default(),
install_cancelled_seen: 0,
content: pages::content::PageState::default(),
account_form: pages::account::Form::new(),
+27
View File
@@ -8,6 +8,8 @@ use unlucky_presenter::toast::ToastKind;
use crate::pages;
use unlucky_presenter::router::Route;
use super::App;
impl App {
@@ -52,10 +54,35 @@ impl App {
self.lang(),
&self.content,
);
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
self.sync_launch_form();
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width);
self.view_dirty = false;
self.refreshes += 1;
}
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
///
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
/// 免得「A 的草稿被当成 B 的初值」。
fn sync_launch_form(&mut self) {
let Route::Detail(id) = self.state.route() else {
self.launch_form.bound = None;
return;
};
match self
.state
.instances
.iter()
.find(|instance| &instance.id == id)
{
Some(instance) => {
let view = instance.launch.clone();
self.launch_form.sync(&view);
}
None => self.launch_form.bound = None,
}
}
}
+2
View File
@@ -12,6 +12,8 @@ use unlucky_presenter::settings_bridge::SettingsBridge;
#[cfg(test)]
mod appearance;
#[cfg(test)]
mod instances_save_loop;
#[cfg(test)]
mod presenter;
use super::message::Message;
+2 -2
View File
@@ -202,13 +202,13 @@ fn the_settings_page_renders_the_real_appearance_group() {
assert_eq!(app.view.screen, Screen::Settings);
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
drop(_tree);
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
app.appearance.apply(Change::SelectGroup(0));
assert_eq!(app.appearance.selected_group, 0);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
}
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
@@ -0,0 +1,145 @@
//! 「启动参数保存失败」的**整环**回归(M157 r4 复审 P3-a)。
//!
//! 仓里原来只有这条环的两半:`LaunchForm::sync` 的单测(本 crate 的 `pages/instances/state.rs`)
//! 与「Save 只派发不清脏」(`app/update/instances.rs`)。两半各自绿,但**接线**(消息 →
//! `dispatch` → 后端回事件 → `pump` → `refresh_view` → `sync_launch_form`)没有任何活着的断言。
//! 把 `dirty = false` 提前到派发前,只有这条整环测试会红(单测两半都不会)。
//!
//! 造的后端与真 host 的失败路径同形:收到 `SetInstanceLaunchArgs` 只回一条 Toast(错误的用户
//! 可见反馈),**不回** `InstancesChanged`——盘上没改,快照还是旧的。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime, InstanceSummary, LaunchArgsView,
LoaderInfo, LoaderKind,
};
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::toast::{Toast, ToastKind};
use crate::app::message::Message;
use crate::app::state::{App, TICK};
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 失败路径的后端:命令进台账;`SetInstanceLaunchArgs` 只回一条错误 Toast。
#[derive(Debug, Default)]
struct FailingSaveBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
queued: Vec<AppEvent>,
}
impl Backend for FailingSaveBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut self.queued)
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command.clone());
if matches!(command, AppCommand::SetInstanceLaunchArgs { .. }) {
// 真 host 在 `dispatch_inner` 的 Err 分支只发 toast(不发 InstancesChanged)。
self.queued.push(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Error,
text: "内存超出范围".to_string(),
remaining_ms: 0,
}));
}
}
}
/// 盘上的实例快照:内存 4096、其余留空(= 保存失败时 pump 会带回来的那份)。
fn snapshot() -> Vec<InstanceSummary> {
vec![InstanceSummary {
id: "survival".to_string(),
name: "生存".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Vanilla,
version: String::new(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
launch: LaunchArgsView {
instance_id: "survival".to_string(),
memory_mb: Some(4096),
memory_effective_mb: 4096,
..LaunchArgsView::default()
},
}]
}
fn app_with_snapshot() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let dispatched = Rc::new(RefCell::new(Vec::new()));
let backend = FailingSaveBackend {
dispatched: Rc::clone(&dispatched),
queued: vec![AppEvent::Core(CoreEvent::InstancesChanged(snapshot()))],
};
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
);
(app, dispatched)
}
/// **保存失败不许吃掉草稿**(整环):输入 `4g` → 点保存 → 后端回错误事件 → pump 重建视图。
///
/// 抓得住:①`Save` 在派发前清脏(草稿被旧快照覆盖成 4096);②`LaunchForm::sync` 去掉
/// `matches` 判据(同样覆盖);③`pump` 没把事件喂进 `apply`/`refresh_view`(表单不会被播种,
/// 下面的前置断言会红)。
/// 抓不住:真 host 的落盘失败(那是 `host::launch_args` 的单测射程)。
#[test]
fn failed_save_keeps_the_draft_through_the_whole_loop() {
let (mut app, dispatched) = app_with_snapshot();
// 首屏快照到位 → 自动选中 → 表单按盘上的值播种。
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
app.advance(TICK);
assert_eq!(app.launch_form.memory, "4096", "首帧要按快照播种");
assert!(!app.launch_form.dirty);
// 用户把内存改成非法值(host 会拒),然后点保存。
let _ = app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::Memory("4g".to_string()),
)));
assert_eq!(app.launch_form.memory, "4g");
let _ = app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::Save,
)));
assert!(
dispatched
.borrow()
.iter()
.any(|command| matches!(command, AppCommand::SetInstanceLaunchArgs { .. })),
"保存要把命令派发出去"
);
// 失败回程:一条错误 Toast → pump → refresh_view → sync_launch_form(旧快照)。
app.advance(TICK);
assert_eq!(
app.launch_form.memory, "4g",
"保存失败后,pump 用旧快照把用户刚敲的内容冲掉了"
);
assert!(app.launch_form.dirty, "草稿还在,脏标记也不能掉");
}
+3
View File
@@ -47,9 +47,12 @@ mod appearance;
mod boot_env;
mod clock;
mod content;
mod content_pack;
mod download;
mod instances;
mod page_messages;
mod settings;
mod update_action;
impl App {
/// 一条消息进来,改状态并返回后续任务。
+14
View File
@@ -111,12 +111,26 @@ impl App {
if events.is_empty() {
return;
}
let mut clean_scan = false;
for event in events {
// M160:预检之后**没有冲突**就不打扰用户——直接带着"全部保留"往下导。
if matches!(
&event,
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Pack(
pack
)) if matches!(&**pack, unlucky_presenter::dto::PackEvent::Scan(scan) if scan.is_clean())
) {
clean_scan = true;
}
self.state.apply(event);
}
self.view_dirty = true;
self.auto_select();
self.refresh_view();
// 派发放在循环之后:`dispatch` 自己会再 pump 一次,在循环里调会把事件顺序搅乱。
if clean_scan {
self.continue_import_after_clean_scan();
}
}
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
+1 -140
View File
@@ -18,7 +18,6 @@
use unlucky_presenter::dto::{
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
ImportRequestView, PackCommand,
};
use crate::pages::content::{Blocker, Content};
@@ -104,149 +103,11 @@ impl App {
Task::none()
}
/// 导入/导出区的消息(M113)。
///
/// 两条口径:
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
PackMessage::Dropped(path) => {
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
self.content.pack.draft = path.display().to_string();
self.content.pack.hover = false;
self.view_dirty = true;
}
PackMessage::Hover(hover) => {
self.content.pack.hover = hover;
self.view_dirty = true;
}
PackMessage::PathChanged(path) => {
self.content.pack.draft = path;
self.view_dirty = true;
}
PackMessage::SetInstance(instance) => {
self.content.pack.instance = instance;
self.view_dirty = true;
}
PackMessage::SetInstanceName(name) => {
self.content.pack.instance_name = name;
self.view_dirty = true;
}
PackMessage::SetFormat(format) => {
self.content.pack.format = format;
self.view_dirty = true;
}
PackMessage::SetExportName(name) => {
self.content.pack.export_name = name;
self.view_dirty = true;
}
PackMessage::SetExportVersion(version) => {
self.content.pack.export_version = version;
self.view_dirty = true;
}
PackMessage::SetOutput(path) => {
self.content.pack.output = path;
self.view_dirty = true;
}
PackMessage::ToggleConflict(path) => {
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
{
None
} else {
Some(path)
};
self.view_dirty = true;
}
PackMessage::OpenExport => {
self.content.pack.export_open = true;
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
if self.content.pack.export_name.trim().is_empty() {
self.content.pack.export_name =
self.content.pack.instance.clone().unwrap_or_default();
}
self.view_dirty = true;
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import => {
if let Some(request) = self.pack_import_request(false) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档)
if let Some(request) = self.pack_import_request(true) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false`
/// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。
fn pack_import_request(&self, overwrite: bool) -> Option<ImportRequestView> {
let draft = self.content.pack.draft.trim();
if draft.is_empty() {
return None;
}
let name = self.content.pack.instance_name.trim();
Some(ImportRequestView {
pack: std::path::PathBuf::from(draft),
instance: self.content.pack.instance.clone(),
instance_name: (!name.is_empty()).then(|| name.to_string()),
overwrite,
})
}
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
///
/// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
fn pack_export_request(&self) -> Option<ExportRequestView> {
pub(super) fn pack_export_request(&self) -> Option<ExportRequestView> {
let instance = self.content.pack.instance.clone()?;
Some(pages::content::request_export(
&self.content.pack,
@@ -0,0 +1,240 @@
//! 整合包导入/导出区的消息落点(M113 + M160):**从 `content.rs` 拆出来守 400 行硬线**。
//!
//! ## 为什么单独一个文件
//!
//! M160 给这一区加了预检、冲突清单、逐项决策三条通道,`content.rs` 到 409 行越线。拆的口径与
//! 仓库其它拆分一致:**纯搬移**,`on_pack` 及其私有 helper(组请求)原样搬过来,别处一行不改。
//!
//! ## 两条口径(与原来同一句)
//!
//! 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——它们不属于业务
//! 状态,不进 presenter;
//! 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认 / 关面板):进度、报告、预检结果全部由
//! `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
//!
//! ## M160 加的那条纪律
//!
//! 点「导入」先发 `PackCommand::Scan`(只读),拿到 `PackEvent::Scan` 之后:**没有冲突就直接往下导**
//! (`clock::pump` 里的 `continue_import_after_clean_scan`),有冲突才画清单等用户点头。决策的缺省
//! 档永远是 `KeepAll`——没问过用户就不会覆盖。
use unlucky_presenter::dto::{
AppCommand, ConflictChoiceView, ConflictDecisionsView, ImportRequestView, PackCommand,
};
use crate::pages;
use super::*;
impl App {
/// 导入/导出区的消息(M113)。
///
/// 两条口径:
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
PackMessage::Dropped(path) => {
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
self.content.pack.draft = path.display().to_string();
self.content.pack.hover = false;
self.view_dirty = true;
}
PackMessage::Hover(hover) => {
self.content.pack.hover = hover;
self.view_dirty = true;
}
PackMessage::PathChanged(path) => {
self.content.pack.draft = path;
self.view_dirty = true;
}
PackMessage::SetInstance(instance) => {
self.content.pack.instance = instance;
self.view_dirty = true;
}
PackMessage::SetInstanceName(name) => {
self.content.pack.instance_name = name;
self.view_dirty = true;
}
PackMessage::SetFormat(format) => {
self.content.pack.format = format;
self.view_dirty = true;
}
PackMessage::SetExportName(name) => {
self.content.pack.export_name = name;
self.view_dirty = true;
}
PackMessage::SetExportVersion(version) => {
self.content.pack.export_version = version;
self.view_dirty = true;
}
PackMessage::SetOutput(path) => {
self.content.pack.output = path;
self.view_dirty = true;
}
PackMessage::ToggleConflict(path) => {
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
{
None
} else {
Some(path)
};
self.view_dirty = true;
}
PackMessage::OpenExport => {
self.content.pack.export_open = true;
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
if self.content.pack.export_name.trim().is_empty() {
self.content.pack.export_name =
self.content.pack.instance.clone().unwrap_or_default();
}
self.view_dirty = true;
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import => {
// 先预检(M160):没有冲突就直接导,有冲突就把清单画出来等用户点头。
self.content.pack.pick_page = 0;
self.content.pack.pick_choices.clear();
self.content.pack.pick_overwrite_all = false;
if let Some(request) = self.pack_import_request() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Scan(Box::new(
request,
)))));
}
}
PackMessage::PickPage(page) => {
self.content.pack.pick_page = page;
self.view_dirty = true;
}
PackMessage::TogglePick(path) => {
// 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。
// 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住
// 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉
// (所以"一次都没点过"仍然等于 `KeepAll`)。
let choice = self.content.pack.pick_choice(&path);
let flipped = match choice {
ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite,
ConflictChoiceView::Overwrite => ConflictChoiceView::KeepUser,
};
self.content.pack.pick_choices.insert(path, flipped);
self.view_dirty = true;
}
PackMessage::PickKeepAll => {
self.content.pack.pick_overwrite_all = false;
self.content.pack.pick_choices.clear();
self.view_dirty = true;
}
PackMessage::PickOverwriteAll => {
self.content.pack.pick_overwrite_all = true;
self.content.pack.pick_choices.clear();
self.view_dirty = true;
}
PackMessage::ConfirmImport => {
let decisions = self
.content
.pack
.decisions(self.state.pack.import.scan.as_ref());
if let Some(request) = self.pack_import_request_with(decisions) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::CancelImport => {
// 取消 = 什么都不导(预检是只读的,没有东西要撤)
self.content.pack.pick_page = 0;
self.content.pack.pick_choices.clear();
self.content.pack.pick_overwrite_all = false;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:这条是**导入之后**从报告里点的,所以直接带整批覆盖再跑一遍。
if let Some(request) =
self.pack_import_request_with(ConflictDecisionsView::OverwriteAll)
{
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 预检干净(没有冲突)时直接往下导。
///
/// 这条是"不打扰不该打扰的文件"的另一半:清单为空时弹一屏"共 0 项冲突"只会让人以为出错了。
/// 决策固定给 [`ConflictDecisionsView::KeepAll`]——即使预检与导入之间磁盘上突然多出个同名文件,
/// 结论也只会是"保留用户的"。
pub(in crate::app) fn continue_import_after_clean_scan(&mut self) {
if let Some(request) = self.pack_import_request_with(ConflictDecisionsView::KeepAll) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// **决策缺省是「全部保留」**([`ConflictDecisionsView::default`]):预检时还不知道有没有冲突,
/// 而 core 侧那份缺省同样落在保留上——所以"还没问过用户"绝不会变成"覆盖"。
fn pack_import_request(&self) -> Option<ImportRequestView> {
self.pack_import_request_with(ConflictDecisionsView::default())
}
/// 组一份带决策的导入请求(逐项 / 整批都从这里出去)。
fn pack_import_request_with(
&self,
decisions: ConflictDecisionsView,
) -> Option<ImportRequestView> {
let draft = self.content.pack.draft.trim();
if draft.is_empty() {
return None;
}
let name = self.content.pack.instance_name.trim();
Some(ImportRequestView {
pack: std::path::PathBuf::from(draft),
instance: self.content.pack.instance.clone(),
instance_name: (!name.is_empty()).then(|| name.to_string()),
decisions,
})
}
}
+174
View File
@@ -0,0 +1,174 @@
//! 实例页「启动参数」区的消息落点(M157)。
//!
//! 三件事:
//! 1. **编辑**只改页面状态(`App::launch_form`)并置脏——不 dispatch,不打网络;
//! 2. **保存**派一条 [`AppCommand::SetInstanceLaunchArgs`](整份五项补丁),回程由
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
//!
//! **清脏的唯一时机是「回程快照等于表单」**:`LaunchForm::sync` 在「同实例 + 脏」时只在
//! `matches(&view)` 成立时才清脏并重播种,其余情况原样保留草稿。保存本身**不清脏**——落盘成败
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task;
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
use crate::pages::InstanceMessage;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 启动参数区的一条消息。
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
let Some(instance) = self.selected_instance_id() else {
return Task::none();
};
if !self.launch_form.bound_to(&instance) {
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
return Task::none();
}
match message {
InstanceMessage::Jvm(text) => {
self.launch_form.jvm = text;
self.launch_form.dirty = true;
}
InstanceMessage::Game(text) => {
self.launch_form.game = text;
self.launch_form.dirty = true;
}
InstanceMessage::Memory(text) => {
self.launch_form.memory = text;
self.launch_form.dirty = true;
}
InstanceMessage::Java(text) => {
self.launch_form.java = text;
self.launch_form.dirty = true;
}
InstanceMessage::Backend(id) => {
self.launch_form.backend = id;
self.launch_form.dirty = true;
}
InstanceMessage::Reset => {
self.launch_form.reset();
}
InstanceMessage::Save => {
// **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。
// 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉,
// `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。
// 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。
let patch: LaunchArgsPatch = self.launch_form.patch();
self.dispatch(AppCommand::set_launch_args(&instance, patch));
}
}
// 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在
// 消息处理完之后本来就会画一帧,所以这里什么都不排。
Task::none()
}
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
fn selected_instance_id(&self) -> Option<String> {
match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::LaunchArgsView;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::{InstanceMessage, LaunchForm};
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn app_with_form() -> App {
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::mock::MockBackend::new()),
SettingsBridge::memory_only(),
);
// 进详情页 + 直接放一份「已播种」的表单:本测试只验消息落点,
// 不验播种(那在 `LaunchForm::sync` 的单测里)。
app.state.navigate(unlucky_presenter::router::Route::Detail(
INSTANCE.to_string(),
));
app.launch_form = LaunchForm::from_view(
Some(INSTANCE),
&LaunchArgsView {
instance_id: INSTANCE.to_string(),
..LaunchArgsView::default()
},
);
app
}
/// 测试用的实例 id(mock 后端的实例快照里有它)。
const INSTANCE: &str = "survival";
/// 编辑 → 脏;恢复 → 五项清空仍需保存;保存前不发命令。
#[test]
fn editing_marks_dirty_and_reset_waits_for_save() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("6144".to_string()));
assert_eq!(app.launch_form.memory, "6144");
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Backend("vulkan".to_string()));
assert_eq!(app.launch_form.backend, "vulkan");
let _ = app.on_instance_message(InstanceMessage::Reset);
assert_eq!(app.launch_form.memory, "");
assert_eq!(app.launch_form.backend, "default");
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘");
}
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
#[test]
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
let mut app = app_with_form();
app.launch_form = LaunchForm::from_view(
Some("other"),
&LaunchArgsView {
instance_id: "other".to_string(),
..LaunchArgsView::default()
},
);
let _ = app.on_instance_message(InstanceMessage::Memory("9999".to_string()));
assert_eq!(app.launch_form.memory, "");
assert!(!app.launch_form.dirty);
}
/// **保存只派发、不清脏**(M157 r3 复审 P2-2):落盘成败要等回程事件,
/// 派发前清掉脏标记会让失败后的重播覆盖用户草稿。
///
/// 抓得住:Save 分支里又写回 `dirty = false`(那条路径在失败时丢草稿)。
/// 抓不住:回程事件的真实时序——清脏判据在 `LaunchForm::sync` 的
/// `confirmed_save_clears_dirty_and_reseeds` / `failed_save_keeps_the_draft` 里。
#[test]
fn save_dispatch_keeps_the_dirty_flag() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("4g".to_string()));
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Save);
assert!(
app.launch_form.dirty,
"派发后仍要保留脏标记,等回程快照确认"
);
assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
}
}
@@ -54,6 +54,8 @@ impl App {
}
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
// M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查)
pages::Message::Update(action) => return self.on_update_action(action),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
@@ -88,6 +90,8 @@ impl App {
self.view_dirty = true;
self.refresh_view();
}
// M157:实例页「启动参数」区(编辑写进页面状态;保存才派发命令)
pages::Message::Instances(message) => return self.on_instance_message(message),
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
pages::Message::Account(message) => return self.on_account(message),
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
@@ -0,0 +1,158 @@
//! 自更新动作的落点(M159):`pages::Message::Update(UpdateAction)` → presenter 指令 / 重启。
//!
//! 三条纪律:
//!
//! 1. **不做乐观回显**:点了「下载并安装」不改本地状态,等 `CoreEvent::Update` 回来才变
//! (与启动/安装同一条:本地状态与事件流打架时,界面会自说一套);
//! 2. 更新的**判断**(要不要提示、失败算什么、版本比较)全在 core/presenter,这里只转发;
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
use iced::Task;
use unlucky_presenter::dto::AppCommand;
use crate::pages::UpdateAction;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 一条更新动作。
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
match action {
UpdateAction::CheckNow => self.dispatch(AppCommand::CheckUpdate),
UpdateAction::Apply => self.dispatch(AppCommand::ApplyUpdate),
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
UpdateAction::RestartNow => return self.restart_now(),
}
Task::none()
}
/// 立即重启:按同样的命令行拉起自己,然后退出。
///
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
/// 文件、并把取证进程拖成死循环。
fn restart_now(&mut self) -> Task<Message> {
if self.options.screenshot.is_some() {
eprintln!("[update] 截图通道不重启(忽略「立即重启」)");
return Task::none();
}
match std::env::current_exe() {
Ok(exe) => {
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
match std::process::Command::new(&exe).args(&args).spawn() {
Ok(child) => eprintln!(
"[update] 已拉起新进程 pid={}:{}",
child.id(),
exe.display()
),
Err(error) => {
eprintln!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
return Task::none();
}
}
}
Err(error) => {
eprintln!("[update] 取 current_exe 失败:{error}");
return Task::none();
}
}
iced::exit()
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::UpdateAction;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 记录命令流的假后端:把 `dispatch` 收到的每一条抄进共享表(动作接线断没断靠它判)。
struct Recorder {
seen: Arc<Mutex<Vec<AppCommand>>>,
}
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.seen
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(command);
}
fn tick(&mut self, _elapsed: std::time::Duration) {}
}
fn app() -> (App, Arc<Mutex<Vec<AppCommand>>>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder {
seen: Arc::clone(&seen),
}),
SettingsBridge::memory_only(),
);
(app, seen)
}
fn last(seen: &Arc<Mutex<Vec<AppCommand>>>) -> AppCommand {
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.last()
.cloned()
.expect("应当派发过一条命令")
}
/// 四个转发动作各自交给后端**对应的**指令(串线会红)。
#[test]
fn every_update_action_reaches_the_backend() {
let (mut app, seen) = app();
let _: Task<_> = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(last(&seen), AppCommand::CheckUpdate);
let _ = app.on_update_action(UpdateAction::Apply);
assert_eq!(last(&seen), AppCommand::ApplyUpdate);
let _ = app.on_update_action(UpdateAction::Dismiss);
assert_eq!(last(&seen), AppCommand::DismissUpdate);
let _ = app.on_update_action(UpdateAction::SetAutoCheck(false));
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
}
/// 截图通道不重启:取证进程不许把自己重启成死循环。
#[test]
fn the_screenshot_channel_never_restarts() {
let (mut app, seen) = app();
app.options.screenshot = Some(std::path::PathBuf::from("C:/Temp/shot.png"));
let _ = app.on_update_action(UpdateAction::RestartNow);
assert!(
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty(),
"重启不是一条后端指令(不该派发任何命令)"
);
}
}
+6
View File
@@ -24,9 +24,11 @@ mod account_page;
mod instances;
mod logs;
mod rail;
mod update;
use instances::{detail_of, runtime_status, supporting_line};
use logs::logs_model;
use update::update_of;
impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
@@ -92,6 +94,8 @@ impl App {
},
// M106:设置页 General 组要画「当前档位 + 生效语言」。
self.language(),
// M157:实例页「启动参数」区的表单(编辑态归 `App`,页面只借一帧)。
&self.launch_form,
)
.map(Message::Page),
agent::docked(&self.ui, session, agent_catalog, self.width)
@@ -176,6 +180,8 @@ pub(super) fn model(
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
logs: logs_model(state).with_status(lang),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
lang,
forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
+12 -1
View File
@@ -21,7 +21,7 @@
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
use crate::pages::{Detail, Meta};
use crate::pages::{Detail, LaunchFacts, Meta};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
@@ -135,6 +135,17 @@ pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail
instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
),
// M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照,
// 页面不复制任何一条判据;表单值在 `App::launch_form`)。
launch: LaunchFacts {
allowed: instance.launch.graphics_backend_allowed,
mc_version: instance.launch.mc_version.clone(),
min_version: instance.launch.graphics_backend_min_version.clone(),
memory_effective_mb: instance.launch.memory_effective_mb,
java_effective: instance.launch.java_effective.clone(),
backend_effective: instance.launch.graphics_backend_effective.clone(),
options: instance.launch.graphics_backend_options.clone(),
},
}
}
+1
View File
@@ -22,6 +22,7 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
launch: unlucky_presenter::dto::LaunchArgsView::default(),
}
}
+157
View File
@@ -0,0 +1,157 @@
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
//!
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
//!
//! | DTO | 页面视图 |
//! |---|---|
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
use crate::pages::{Update, UpdateStatus};
/// 快照 → 视图模型。
pub(super) fn update_of(view: &UpdateView) -> Update {
Update {
current_version: view.current_version.clone(),
channel: view.channel.clone(),
auto_check: view.auto_check,
status: status_of(&view.outcome),
checked_at: view.checked_at.clone(),
applying: view.applying,
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
skipped: view.skipped.clone(),
silent: view.silent,
}
}
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
match outcome {
UpdateOutcome::Idle => UpdateStatus::Idle,
UpdateOutcome::Checking => UpdateStatus::Checking,
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
latest: latest.clone(),
},
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
version: candidate.version.clone(),
size: candidate.size,
notes: candidate.notes.clone(),
forced: candidate.forced,
},
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
version: version.clone(),
},
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
reason: failure.message.clone(),
retryable: failure.retryable,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
fn view(outcome: UpdateOutcome) -> UpdateView {
let mut view = UpdateView::new("0.2.2", "stable", true);
view.outcome = outcome;
view
}
#[test]
fn failure_is_never_mapped_to_up_to_date() {
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"取更新清单失败:连接被重置",
true,
))));
assert!(
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
"失败必须原样带原因,实际 {:?}",
mapped.status
);
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"连不上",
true,
)));
silent.silent = true;
let mapped = update_of(&silent);
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
assert!(mapped.banner().is_none());
}
#[test]
fn the_four_outcomes_stay_distinct() {
let available = update_of(&view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 21_000_000,
notes: "修掉启动崩溃".to_string(),
forced: false,
},
))));
assert!(available.available().is_some());
assert!(matches!(
available.banner(),
Some(crate::pages::Banner::Available { .. })
));
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
}));
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
assert!(up_to_date.banner().is_none());
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
version: "0.2.3".to_string(),
}));
assert!(matches!(
missing.status,
UpdateStatus::MissingArtifact { .. }
));
}
#[test]
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
let mut view = view(UpdateOutcome::UpToDate {
latest: Some("0.2.3".to_string()),
});
view.ready = Some(ReadyUpdate {
version: "0.2.3".to_string(),
path: "C:/ULL/unlucky-launcher.exe".to_string(),
backup: None,
});
let mapped = update_of(&view);
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
assert!(matches!(
mapped.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
#[test]
fn a_skipped_update_keeps_the_snapshot_but_hides_the_banner() {
let mut view = view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
)));
view.skipped = Some("0.2.3".to_string());
let mapped = update_of(&view);
assert_eq!(mapped.skipped.as_deref(), Some("0.2.3"));
assert!(mapped.available().is_some(), "候选要留着给设置页");
assert!(mapped.banner().is_none());
}
}
+35
View File
@@ -139,8 +139,43 @@ pub fn resolve() -> Resolved {
resolve_with(None)
}
/// Termux 上把 Android 的系统 CJK 字体注册进 cosmic-text 的字体库。
///
/// 这套 glibc 构建跑在 Termux 里(`target_os = "linux"`),fontdb 只扫 Linux 的常规字体目录,
/// Android 的 `/system/fonts` 不在其中——不注册的话中文实例名与界面文案全是方框。
/// 第一次调用加载一次;找不到任何候选字体时静默放过(由既有回退链兜底)。
fn register_termux_fonts() {
use std::sync::OnceLock;
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
static REGISTERED: OnceLock<()> = OnceLock::new();
REGISTERED.get_or_init(|| {
if !termux::is_termux(&ProcessEnv) {
return;
}
const CANDIDATES: &[&str] = &[
"/system/fonts/NotoSansCJK-Regular.ttc",
"/system/fonts/NotoSansCJKSC-Regular.otf",
"/system/fonts/DroidSansFallback.ttf",
];
let Ok(mut font_system) = iced::advanced::graphics::text::font_system().write() else {
return;
};
let db = font_system.raw().db_mut();
for path in CANDIDATES {
if db.load_font_file(path).is_ok() {
return;
}
}
});
}
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
pub fn resolve_with(setting: Option<&str>) -> Resolved {
register_termux_fonts();
let platform = Platform::current();
let faces = system_faces();
@@ -223,6 +223,7 @@ fn can_install_needs_a_selection_and_no_blockers() {
isolation: "isolated".to_string(),
version_id: "1.20.1".to_string(),
version_present: true,
launch: unlucky_presenter::dto::LaunchArgsView::default(),
}];
let catalog = Lang::En.catalog();
let page = PageState {
+11 -3
View File
@@ -28,14 +28,22 @@
//! | [`view`] | 画法(进度**整块**复用下载页的安装面板) |
//! | `demo` | 出图夹具(`ULL_CONTENT_PACK` 选态) |
//!
//! ## M160 新增的一屏
//!
//! `model/pick.rs` + `view/pick.rs` 是**导入前的冲突清单**:分页(每页 `PAGE_SIZE` 条)、
//! 三个入口、计数摘要、确认/取消。它出现的前提是 core 的预检给出了至少一条冲突——
//! 没有冲突时页面连这一屏都不画(直接往下导)。
//!
//! ## 本版明确的边界(报告里同样登记)
//!
//! - **没有系统文件选择框**:依赖树里没有 `rfd` 一类的对话框依赖,加它要动根 `Cargo.toml`、
//! 过许可门禁、还要给 Linux 选后端(GTK / xdg-portal),直接影响「单文件产物」。所以入口是
//! **拖拽(真能用)+ 路径输入框**,并且把原因写在界面上([`model::Pack::no_dialog_note`])。
//! - **冲突没有逐文件决策**:core 的 `ConflictPolicy` 是整次导入一个全局档(`KeepUser` 默认 /
//! `Overwrite`),`ConflictFile` 是**事后账**。所以这一块给的是「逐文件看 + 整体翻档」,
//! 文案如实写「两者都留(改名)暂不支持」。逐文件策略已登记为后续 core mission。
//! - **冲突是逐文件决策了(M160)**:点「导入」先跑一次**只读预检**(core 的 `scan_pack`),
//! 没有冲突就直接导(不打扰用户);有冲突就画那一屏清单——分页([`model::PAGE_SIZE`] 条一页)、
//! 「全部保留 / 全部覆盖 / 逐项」三个入口、计数摘要、确认与取消。**缺省永远是保留**:
//! 没点过的文件一个都不会被包内内容替换。仍然不支持的是「两者都留(改名)」——那要改落盘
//! 布局,不在本轮。
mod demo;
mod message;
@@ -8,7 +8,7 @@
//!
//! | 变量 | 取值 | 作用 |
//! |---|---|---|
//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
//! | `ULL_CONTENT_PACK` | `importing` / `pick` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
//!
//! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`),
//! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行,
@@ -68,6 +68,21 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
match state {
"importing" => import.progress = Some(progress()),
// M160:导入前的冲突清单(分页 + 三个入口 + 计数摘要)
"pick" => {
import.scan = Some(scan(312));
// 停在第 2 页,且已经逐项点过两条「覆盖」——图上能同时看见分页与逐项状态
page.pick_page = 1;
// 两条落到**第 2 页**的路径上:图上能同时看见「保留」与「覆盖」两种行状态
page.pick_choices.insert(
"config/generated/module-041.toml".to_string(),
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
);
page.pick_choices.insert(
"config/generated/module-044.toml".to_string(),
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
);
}
"partial" => import.report = Some(report(ImportStatusView::Partial, false)),
"conflict" => {
import.report = Some(report(ImportStatusView::Partial, true));
@@ -194,6 +209,59 @@ fn conflict(path: &str, reason: ConflictReasonView) -> ConflictFileView {
}
}
/// 一条待决冲突(M160 预览清单的一行)。
fn pending(index: usize) -> unlucky_presenter::dto::ImportConflictView {
// 前几条用真实形态的路径,其余按序号生成:截图里既要有可读的真实感,也要有"很多条"的证据。
let path = match index {
0 => "config/sodium-options.json".to_string(),
1 => "mods/create-1.2.3.jar".to_string(),
2 => "config/jei/jei-client.ini".to_string(),
3 => "options.txt".to_string(),
_ => format!("config/generated/module-{index:03}.toml"),
};
unlucky_presenter::dto::ImportConflictView {
path,
source: if index.is_multiple_of(3) {
unlucky_presenter::dto::ConflictSourceView::ClientOverrides
} else {
unlucky_presenter::dto::ConflictSourceView::Overrides
},
reason: if index.is_multiple_of(2) {
unlucky_presenter::dto::ConflictReasonView::PreExisting
} else {
unlucky_presenter::dto::ConflictReasonView::UserModified
},
disabled: false,
pack_sha1: "9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5".to_string(),
pack_size: 3_800 + (index as u64 * 37) % 900,
disk_sha1: "c1d2e3f4050617283940a5b6c7d8e9f0a1b2c3d4".to_string(),
disk_size: 4_100 + (index as u64 * 53) % 1_200,
base_sha1: (!index.is_multiple_of(2))
.then(|| "3a7d90ff11bc22445566ef7788990011aabbccdd".to_string()),
}
}
/// 预检结果(`count` 条冲突;图上靠分页只画当前这一页)。
fn scan(count: usize) -> unlucky_presenter::dto::ImportScanView {
let conflicts: Vec<unlucky_presenter::dto::ImportConflictView> =
(0..count).map(pending).collect();
let lost_bytes = conflicts.iter().map(|each| each.disk_size).sum();
let incoming_bytes = conflicts.iter().map(|each| each.pack_size).sum();
unlucky_presenter::dto::ImportScanView {
target: PathBuf::from("C:/ULL/instances/survival/.minecraft"),
format: Some(PackFormatView::Modrinth),
pack_name: Some("Better MC".to_string()),
pack_version: Some("1.2.0".to_string()),
game_version: Some("1.20.1".to_string()),
conflicts,
unknown_remote: 4,
lost_bytes,
incoming_bytes,
notes: Vec::new(),
error: None,
}
}
/// 导出预检:12 内联 / 1 跳过(包不完整)。
fn preview() -> ExportPreviewView {
let mut files = vec![ExportFileView {
@@ -30,10 +30,22 @@ pub enum Message {
Cancel,
/// 关掉结果面板(报告 / 致命错误都走这一条)
Dismiss,
/// 以包为准重新导入(对应 `overwrite = true`;**只在用户显式点过之后**)
/// 以包为准重新导入(**只在用户显式点过之后**;载荷是整批覆盖档)
Overwrite,
/// 展开/收起某一条冲突的三方明细
ToggleConflict(String),
/// 冲突清单翻页(M160;页码会夹在 `[0, pages)` 里)
PickPage(usize),
/// 逐项切换某一条的处置(保留 ⇄ 覆盖)
TogglePick(String),
/// 整批档:全部保留(逐项里"覆盖"的那些一起清掉)
PickKeepAll,
/// 整批档:全部覆盖
PickOverwriteAll,
/// 按当前选择开始导入
ConfirmImport,
/// 取消这次导入(不写盘,回到入口态)
CancelImport,
/// 换目标实例(`None` = 按包名新建)
SetInstance(Option<InstanceId>),
/// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字)
@@ -26,15 +26,22 @@
//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
mod export;
mod pick;
mod progress;
mod report;
pub use export::{format_label, request_of as request_export, Export, Preview};
pub use pick::{Pick, PickRow};
pub use progress::Running;
pub use report::{ConflictRow, Report, Row};
use std::collections::BTreeMap;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{ExportState, ImportState, InstanceId, PackFormatView, ProbeView};
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictDecisionsView, ExportState, ImportState, InstanceId,
PackFormatView, ProbeView,
};
use crate::pages::content::Target;
@@ -61,6 +68,12 @@ pub struct PackPageState {
pub export_open: bool,
/// 展开的那条冲突路径
pub expanded: Option<String>,
/// 冲突清单翻到第几页(0 起;M160)
pub pick_page: usize,
/// 逐项决策(**只存"覆盖"的路径**:保留 = 表里没有这条,于是"没点过"永远是保留)
pub pick_choices: BTreeMap<String, ConflictChoiceView>,
/// 「全部覆盖」这一档是否被点过(整批档;逐项决策仍然优先于它)
pub pick_overwrite_all: bool,
}
impl Default for PackPageState {
@@ -78,10 +91,65 @@ impl Default for PackPageState {
output: String::new(),
export_open: false,
expanded: None,
pick_page: 0,
pick_choices: BTreeMap::new(),
pick_overwrite_all: false,
}
}
}
impl PackPageState {
/// 用户在冲突清单上点的处置(逐项优先,其次整批档,缺省保留)。
#[must_use]
pub fn pick_choice(&self, path: &str) -> ConflictChoiceView {
if let Some(choice) = self.pick_choices.get(path) {
return *choice;
}
if self.pick_overwrite_all {
ConflictChoiceView::Overwrite
} else {
ConflictChoiceView::KeepUser
}
}
/// 把这一屏的选择变成发给宿主的那份决策。
///
/// 一个都没点 ⇒ `KeepAll`(与 core 的"全部保留"同一个语义);点过 ⇒ `PerItem { default, items }`,
/// 其中 `default` 就是整批档。于是"全部覆盖之后又单独放回两条"也能表达(不需要把剩下几百条
/// 全点一遍),而"没点过"永远落在保留上。
#[must_use]
pub fn decisions(
&self,
scan: Option<&unlucky_presenter::dto::ImportScanView>,
) -> ConflictDecisionsView {
use unlucky_presenter::dto::{ConflictDecisionView, ConflictDecisionsView};
let default = if self.pick_overwrite_all {
ConflictChoiceView::Overwrite
} else {
ConflictChoiceView::KeepUser
};
// 只发"与缺省档不同"的项:其余在 core 侧自然落到缺省档。
let items: Vec<ConflictDecisionView> = self
.pick_choices
.iter()
.filter(|(_, choice)| **choice != default)
.map(|(path, choice)| ConflictDecisionView {
path: path.clone(),
choice: *choice,
})
.collect();
// 冲突清单为空时,`PerItem` 没有对象,直接给整批档(语义等价、日志更短)。
let empty = scan.is_none_or(|scan| scan.conflicts.is_empty());
if items.is_empty() || empty {
return match default {
ConflictChoiceView::KeepUser => ConflictDecisionsView::KeepAll,
ConflictChoiceView::Overwrite => ConflictDecisionsView::OverwriteAll,
};
}
ConflictDecisionsView::per_item(default, items)
}
}
/// 一个实例候选(导入目标与导出源共用)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackTarget {
@@ -132,6 +200,8 @@ pub struct Pack {
pub cancelled_note: &'static str,
/// 导入结果
pub report: Option<Report>,
/// **导入前的冲突清单**(M160:`Some` 就画那一屏,别的东西都让位)
pub pick: Option<Pick>,
/// 导出块
pub export: Export,
}
@@ -181,6 +251,15 @@ impl Pack {
.report
.as_ref()
.map(|report| report::report_of(report, page, catalog)),
pick: import.scan.as_ref().and_then(|scan| {
pick::pick_of(
scan,
page.pick_page,
&page.pick_choices,
page.pick_overwrite_all,
catalog,
)
}),
export: export::export_of(export, page, candidates, catalog),
}
}
@@ -190,6 +269,7 @@ impl Pack {
self.running.is_none()
&& self.probe.is_none()
&& self.report.is_none()
&& self.pick.is_none()
&& !self.cancelled
&& !self.export.open
&& self.draft.is_empty()
@@ -0,0 +1,260 @@
//! 导入**冲突清单**的视图模型(M160):分页、计数、逐项处置。
//!
//! # 为什么是分页而不是"一次画完"
//!
//! 冲突清单可能有几千条(一个用户改过很多配置的老实例)。`iced` 的每一行都是真实 widget,
//! 一次画几千行会同时拖慢布局与滚动,而用户真正要看的永远只有"这一屏"。所以模型里只出
//! **当前这一页**([`PAGE_SIZE`] 条),页面上的「上一页 / 下一页」改的是
//! [`PackPageState::pick_page`]——数据本身留在 presenter 的 `ImportScanView` 里,页面不复制。
//!
//! # 三个入口如何体现在计数上
//!
//! | 用户点了 | 决策表 | 计数 |
//! |---|---|---|
//! | 全部保留 | [`ConflictDecisionsView::KeepAll`] | 覆盖 0 |
//! | 全部覆盖 | [`ConflictDecisionsView::OverwriteAll`] | 覆盖 = 全部 |
//! | 逐项 | `PerItem`(只列"覆盖"的那些,其余按保留) | 覆盖 = 点过的条数 |
//!
//! 页面显示的两个数直接来自 [`PackPageState::decisions`]、[`PackPageState::pick_choice`] 的计算,
//! 与发给 host 的那份决策**同源**——不会出现"界面说覆盖 3 个、实际覆盖 0 个"。
use std::collections::BTreeMap;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
human_bytes, ConflictChoiceView, ConflictReasonView, ConflictSourceView, ImportConflictView,
ImportScanView,
};
/// 一页画多少行(一次画几千行是性能陷阱;40 行在一屏内可读且不滚动太多)。
pub const PAGE_SIZE: usize = 40;
/// 冲突清单块(导入前那一屏)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pick {
/// 标题(`导入前:有 12 个文件已存在且内容不同`)
pub title: String,
/// 计数摘要(`保留 9 · 覆盖 3 · 覆盖将丢弃 2.4 MB`)
pub summary: String,
/// 缺省说明("没改动的一律按保留")
pub default_note: &'static str,
/// 判不了的远端条目(`Some` 才画那一行)
pub unknown_note: Option<String>,
/// **当前页**的行(最多 [`PAGE_SIZE`] 条)
pub rows: Vec<PickRow>,
/// 当前页(0 起)
pub page: usize,
/// 总页数(至少 1)
pub pages: usize,
/// 区间说明(`第 1-40 项,共 312 项`)
pub range: String,
/// 保留几条
pub keep_count: usize,
/// 覆盖几条
pub overwrite_count: usize,
/// 能不能翻页
pub can_prev: bool,
/// 能不能翻页
pub can_next: bool,
/// 三个入口的文案
pub keep_all: &'static str,
/// 三个入口的文案
pub overwrite_all: &'static str,
/// 逐项按钮(保留)的文案
pub row_keep: &'static str,
/// 逐项按钮(覆盖)的文案
pub row_overwrite: &'static str,
/// 翻页文案
pub prev: &'static str,
/// 翻页文案
pub next: &'static str,
/// 确认导入
pub confirm: String,
/// 取消导入
pub cancel: &'static str,
/// 「全部保留」这一档当前是否生效(整批档切到保留时高亮)
pub keep_all_active: bool,
/// 「全部覆盖」这一档当前是否生效
pub overwrite_all_active: bool,
}
/// 清单里的一行。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickRow {
/// 实例内路径
pub path: String,
/// 来源(`包内 overrides/` / `清单 files[]`)
pub source: String,
/// 成因(`本来就在` / `你改过`)
pub reason: String,
/// 两侧体积(`你的:2.1 MB · 包内:1.8 MB`)
pub sizes: String,
/// 当前处置([`ConflictChoiceView::KeepUser`] = 保留)
pub choice: ConflictChoiceView,
/// 这一行按钮上写的字(点了就变成另一侧)
pub toggle: &'static str,
}
impl PickRow {
/// 这一行是"覆盖"吗。
#[must_use]
pub const fn overwrites(&self) -> bool {
matches!(self.choice, ConflictChoiceView::Overwrite)
}
}
/// 从预检结果 + 页面选择造出这一块(`None` = 没有待决冲突,页面不该画它)。
#[must_use]
pub fn pick_of(
scan: &ImportScanView,
page_number: usize,
per_item: &BTreeMap<String, ConflictChoiceView>,
batch_overwrite: bool,
catalog: Catalog,
) -> Option<Pick> {
if scan.conflicts.is_empty() {
return None;
}
let total = scan.conflicts.len();
let pages = total.div_ceil(PAGE_SIZE).max(1);
let page = page_number.min(pages - 1);
let from = page * PAGE_SIZE;
let to = (from + PAGE_SIZE).min(total);
let rows: Vec<PickRow> = scan.conflicts[from..to]
.iter()
.map(|conflict| row_of(conflict, per_item, batch_overwrite, catalog))
.collect();
let overwrite_count = scan
.conflicts
.iter()
.filter(|conflict| {
effective(conflict.path.as_str(), per_item, batch_overwrite)
== ConflictChoiceView::Overwrite
})
.count();
let keep_count = total - overwrite_count;
// 覆盖会丢掉多少:只算"你真选了覆盖"的那些,避免用最坏数字吓人。
let lost = scan
.conflicts
.iter()
.filter(|conflict| {
effective(conflict.path.as_str(), per_item, batch_overwrite)
== ConflictChoiceView::Overwrite
})
.map(|conflict| conflict.disk_size)
.fold(0_u64, u64::saturating_add);
Some(Pick {
title: catalog.render(Key::PackPickTitle, &[("count", &total.to_string())]),
summary: catalog.render(
Key::PackPickSummary,
&[
("keep", &keep_count.to_string()),
("overwrite", &overwrite_count.to_string()),
("bytes", &human_bytes(lost)),
],
),
default_note: catalog.text(Key::PackPickDefaultNote),
unknown_note: (scan.unknown_remote > 0).then(|| {
catalog.render(
Key::PackPickUnknownRemote,
&[("count", &scan.unknown_remote.to_string())],
)
}),
rows,
page,
pages,
range: catalog.render(
Key::PackPickPage,
&[
("from", &(from + 1).to_string()),
("to", &to.to_string()),
("total", &total.to_string()),
],
),
keep_count,
overwrite_count,
can_prev: page > 0,
can_next: page + 1 < pages,
keep_all: catalog.text(Key::PackPickKeepAll),
overwrite_all: catalog.text(Key::PackPickOverwriteAll),
row_keep: catalog.text(Key::PackPickRowKeep),
row_overwrite: catalog.text(Key::PackPickRowOverwrite),
prev: catalog.text(Key::PackPickPrev),
next: catalog.text(Key::PackPickNext),
confirm: catalog.render(Key::PackPickConfirm, &[]),
cancel: catalog.text(Key::PackPickCancel),
keep_all_active: !batch_overwrite,
overwrite_all_active: batch_overwrite,
})
}
/// 一条冲突的**有效**处置:逐项优先,其次整批档,缺省保留(与 core 侧同一条规则)。
fn effective(
path: &str,
per_item: &BTreeMap<String, ConflictChoiceView>,
batch_overwrite: bool,
) -> ConflictChoiceView {
if let Some(choice) = per_item.get(path) {
return *choice;
}
if batch_overwrite {
ConflictChoiceView::Overwrite
} else {
ConflictChoiceView::KeepUser
}
}
fn row_of(
conflict: &ImportConflictView,
per_item: &BTreeMap<String, ConflictChoiceView>,
batch_overwrite: bool,
catalog: Catalog,
) -> PickRow {
let choice = effective(conflict.path.as_str(), per_item, batch_overwrite);
PickRow {
path: conflict.path.clone(),
source: source_label(conflict.source, catalog).to_owned(),
reason: reason_label(conflict.reason, catalog).to_owned(),
sizes: format!(
"{} · {}",
catalog.render(
Key::PackPickDisk,
&[("size", &human_bytes(conflict.disk_size))]
),
catalog.render(
Key::PackPickIncoming,
&[("size", &human_bytes(conflict.pack_size))]
)
),
choice,
// 按钮写"点了会变成什么":当前保留 ⇒ 按钮是「覆盖」。
toggle: if matches!(choice, ConflictChoiceView::Overwrite) {
catalog.text(Key::PackPickRowKeep)
} else {
catalog.text(Key::PackPickRowOverwrite)
},
}
}
/// 来源的人读串。
fn source_label(source: ConflictSourceView, catalog: Catalog) -> &'static str {
match source {
ConflictSourceView::Overrides => catalog.text(Key::PackPickSourceOverrides),
ConflictSourceView::ClientOverrides => catalog.text(Key::PackPickSourceClientOverrides),
ConflictSourceView::CurseForge | ConflictSourceView::Modrinth => {
catalog.text(Key::PackPickSourceManifest)
}
}
}
/// 成因的人读串(复用导入报告那三句,不另造一套说法)。
fn reason_label(reason: ConflictReasonView, catalog: Catalog) -> &'static str {
match reason {
ConflictReasonView::UserModified => catalog.text(Key::PackConflictUserModified),
ConflictReasonView::UserDeleted => catalog.text(Key::PackConflictUserDeleted),
ConflictReasonView::PreExisting => catalog.text(Key::PackConflictPreExisting),
}
}
@@ -3,6 +3,8 @@
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
mod pick;
use std::path::PathBuf;
use unlucky_i18n::Lang;
@@ -0,0 +1,189 @@
//! 冲突清单(M160)的视图模型守门:分页、计数、三个入口 → 决策表。
//!
//! 这里钉的是**不画**的那部分——一屏只出 `PAGE_SIZE` 行、翻页只换数据、计数与最终决策同源。
//! 画法本身靠出图(`ULL_CONTENT_PACK=pick`)。
use std::collections::BTreeMap;
use super::super::pick::{pick_of, PAGE_SIZE};
use super::super::PackPageState;
use super::catalog;
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictDecisionsView, ConflictReasonView, ConflictSourceView,
ImportConflictView, ImportScanView,
};
/// `count` 条冲突的预检结果。
fn scan(count: usize) -> ImportScanView {
ImportScanView {
target: std::path::PathBuf::from("instance"),
format: None,
pack_name: Some("Better MC".to_string()),
pack_version: None,
game_version: None,
conflicts: (0..count)
.map(|index| ImportConflictView {
path: format!("config/row-{index:03}.toml"),
source: ConflictSourceView::Overrides,
reason: ConflictReasonView::PreExisting,
disabled: false,
pack_sha1: "aa".repeat(20),
pack_size: 1_000,
disk_sha1: "bb".repeat(20),
disk_size: 2_000,
base_sha1: None,
})
.collect(),
unknown_remote: 0,
lost_bytes: 0,
incoming_bytes: 0,
notes: Vec::new(),
error: None,
}
}
fn pick(
scan: &ImportScanView,
page: usize,
per_item: &BTreeMap<String, ConflictChoiceView>,
batch: bool,
) -> super::super::pick::Pick {
pick_of(scan, page, per_item, batch, catalog()).expect("有冲突就该画这一块")
}
#[test]
fn only_the_current_page_is_drawn() {
let scan = scan(312);
let first = pick(&scan, 0, &BTreeMap::new(), false);
assert_eq!(first.rows.len(), PAGE_SIZE, "一屏只许画一页的行");
assert_eq!(first.pages, 8, "312 条 / 40 条一页 = 8 页");
assert_eq!(first.page, 0);
assert!(first.can_next, "还有下一页");
assert!(!first.can_prev, "第一页没有上一页");
assert_eq!(first.range, "Items 1-40 of 312");
assert_eq!(first.rows[0].path, "config/row-000.toml");
let second = pick(&scan, 1, &BTreeMap::new(), false);
assert_eq!(second.page, 1);
assert_eq!(
second.rows[0].path,
format!("config/row-{PAGE_SIZE:03}.toml")
);
assert_eq!(second.range, "Items 41-80 of 312");
assert!(second.can_prev && second.can_next);
}
#[test]
fn the_last_page_is_short_and_the_page_number_is_clamped() {
let scan = scan(312);
let last = pick(&scan, 7, &BTreeMap::new(), false);
assert_eq!(last.rows.len(), 312 - 7 * PAGE_SIZE, "最后一页只剩尾巴");
assert_eq!(last.range, "Items 281-312 of 312");
assert!(!last.can_next);
// 页码越界(例如用户先在 300 条的清单上翻到第 6 页,再重扫一份 10 条的)⇒ 夹到最后一页。
let clamped = pick(&scan, 99, &BTreeMap::new(), false);
assert_eq!(clamped.page, 7);
}
#[test]
fn the_summary_counts_what_the_user_actually_picked() {
let scan = scan(10);
let keep_all = pick(&scan, 0, &BTreeMap::new(), false);
assert_eq!(keep_all.keep_count, 10);
assert_eq!(keep_all.overwrite_count, 0);
assert!(keep_all.summary.contains("Keep 10"), "{}", keep_all.summary);
assert!(keep_all.keep_all_active && !keep_all.overwrite_all_active);
let mut per_item = BTreeMap::new();
per_item.insert(
"config/row-001.toml".to_string(),
ConflictChoiceView::Overwrite,
);
per_item.insert(
"config/row-004.toml".to_string(),
ConflictChoiceView::Overwrite,
);
let mixed = pick(&scan, 0, &per_item, false);
assert_eq!(mixed.overwrite_count, 2);
assert_eq!(mixed.keep_count, 8);
// 丢弃量只算"真选了覆盖"的那两条(各 2 000 字节)。
assert!(
mixed.summary.contains("Overwrite 2") || mixed.summary.contains("overwrite 2"),
"{}",
mixed.summary
);
assert_eq!(
mixed.rows[1].choice,
ConflictChoiceView::Overwrite,
"点过的那一行要显示成覆盖"
);
assert_eq!(mixed.rows[0].choice, ConflictChoiceView::KeepUser);
let all = pick(&scan, 0, &BTreeMap::new(), true);
assert_eq!(all.overwrite_count, 10);
assert!(all.overwrite_all_active && !all.keep_all_active);
}
#[test]
fn no_conflict_means_no_block() {
let scan = scan(0);
assert!(
pick_of(&scan, 0, &BTreeMap::new(), false, catalog()).is_none(),
"没有冲突就不该画这一屏(不打扰用户)"
);
}
#[test]
fn decisions_default_to_keep_all() {
let page = PackPageState::default();
assert_eq!(page.decisions(None), ConflictDecisionsView::KeepAll);
// 逐项点一个"覆盖" ⇒ 逐项表里只有它,缺省档仍是保留。
let mut page = PackPageState::default();
page.pick_choices
.insert("mods/a.jar".to_string(), ConflictChoiceView::Overwrite);
let decisions = page.decisions(Some(&scan(3)));
assert_eq!(decisions.default_choice(), ConflictChoiceView::KeepUser);
assert_eq!(decisions.overwrite_count(), 1);
assert_eq!(decisions.len(), 1);
}
#[test]
fn batch_overwrite_plus_a_kept_file_is_expressible() {
// 全部覆盖之后单独放回一条:缺省档是覆盖、逐项里那条是保留。
let mut page = PackPageState {
pick_overwrite_all: true,
..PackPageState::default()
};
assert_eq!(
page.decisions(Some(&scan(3))),
ConflictDecisionsView::OverwriteAll,
"只点整批覆盖时不需要逐项表"
);
page.pick_choices
.insert("mods/pin.jar".to_string(), ConflictChoiceView::KeepUser);
let decisions = page.decisions(Some(&scan(3)));
assert_eq!(decisions.default_choice(), ConflictChoiceView::Overwrite);
assert_eq!(decisions.keep_count(), 1);
assert_eq!(decisions.overwrite_count(), 0);
}
#[test]
fn toggling_flips_the_row_and_clearing_the_batch_drops_per_item_choices() {
let mut page = PackPageState::default();
let path = "mods/a.jar";
assert_eq!(page.pick_choice(path), ConflictChoiceView::KeepUser);
page.pick_choices
.insert(path.to_string(), ConflictChoiceView::Overwrite);
assert_eq!(page.pick_choice(path), ConflictChoiceView::Overwrite);
// 整批覆盖之后,那条逐项"保留"仍然优先于整批档。
page.pick_overwrite_all = true;
page.pick_choices
.insert(path.to_string(), ConflictChoiceView::KeepUser);
assert_eq!(page.pick_choice(path), ConflictChoiceView::KeepUser);
assert_eq!(
page.pick_choice("mods/other.jar"),
ConflictChoiceView::Overwrite
);
}
@@ -8,8 +8,8 @@ use unlucky_i18n::Lang;
use super::demo::pack_in;
use super::model::Pack;
/// D30 要求的四态(导入中 / 部分成功 / 冲突选择 / 导出预检)。
const STATES: [&str; 4] = ["importing", "partial", "conflict", "export"];
/// 出图态:导入中 / **导入前冲突清单(M160)** / 部分成功 / 导入后冲突账 / 导出预检。
const STATES: [&str; 5] = ["importing", "pick", "partial", "conflict", "export"];
#[test]
fn every_screenshot_state_fills_something() {
@@ -28,6 +28,33 @@ fn every_screenshot_state_fills_something() {
}
}
#[test]
fn the_pick_state_shows_a_paginated_conflict_list() {
// M160 的那一屏:只画一页、三个入口都在、计数与逐项状态一致。
let (model, page) = pack_in("pick", Lang::En);
let pick = model.pick.expect("pick 态必须有冲突清单");
// 一屏只画一页:行数上限来自 `model::pick::PAGE_SIZE`(40)。
assert_eq!(pick.rows.len(), 40, "一屏一页(PAGE_SIZE)");
assert_eq!(pick.pages, 8, "312 条 / 40 条一页");
assert_eq!(pick.page, 1, "夹具停在第 2 页(0 起)");
assert!(pick.can_prev && pick.can_next);
assert_eq!(pick.overwrite_count, 2, "夹具逐项点了两条覆盖");
assert!(
pick.keep_all_active && !pick.overwrite_all_active,
"整批档停在缺省那一档(全部保留)"
);
assert_eq!(
pick.rows[0].choice,
unlucky_presenter::dto::ConflictChoiceView::KeepUser,
"没点过的那一行必须是保留"
);
// 页面自留的选择与模型一致(逐项那条显示成覆盖)
assert_eq!(
page.pick_choice("config/generated/module-041.toml"),
unlucky_presenter::dto::ConflictChoiceView::Overwrite
);
}
#[test]
fn importing_shows_the_shared_install_panel() {
let (model, _page) = pack_in("importing", Lang::En);
@@ -41,6 +41,7 @@
mod export;
mod parts;
mod pick;
mod progress;
mod report;
@@ -64,6 +65,7 @@ use crate::pages::Message as PageMessage;
use crate::theme::Ui;
use self::export::export_block;
use self::pick::pick_block;
use self::progress::{probe_block, running_block};
use self::report::report_block;
use super::model::Pack;
@@ -88,6 +90,9 @@ pub(crate) fn section<'a>(
// 事件,任何时候把文件拖进来都算数。
if model.export.open {
block = block.push(export_block(ui, catalog, &model.export));
} else if let Some(pick) = &model.pick {
// 冲突清单优先于一切:用户此刻要做的决定就是"哪几个文件覆盖"
block = block.push(pick_block(ui, catalog, pick));
} else if let Some(running) = &model.running {
block = block.push(running_block(ui, catalog, running));
} else if let Some(report) = &model.report {
@@ -0,0 +1,311 @@
//! 冲突清单那一屏的画法(M160):**分页** + 三个入口 + 计数摘要 + 确认/取消。
//!
//! ## 版面
//!
//! ```text
//! ┌ 导入前:有 312 个文件已存在且内容不同 ───────────────┐
//! │ 保留 309 · 覆盖 3 · 覆盖将丢弃 2.4 MB │
//! │ 没有改动的一律按「保留」:… │
//! │ [全部保留我的] [全部覆盖] │
//! ├ 第 1-40 项,共 312 项 [上一页] [下一页] │
//! │ config/sodium-options.json 包内 overrides/ · 本来就在 │
//! │ 你的:4.1 KB · 包内:3.8 KB [覆盖]│
//! │ …(**只画当前这一页**) │
//! ├ [按这些选择导入] [取消导入] │
//! └─────────────────────────────────────────────────────┘
//! ```
//!
//! ## 三处「不误导」
//!
//! 1. **缺省说明写在按钮下面**:用户不必猜"没点过的那 309 条会怎样";
//! 2. **计数摘要里的丢弃量只算真选了覆盖的**:用最坏数字吓人不算如实;
//! 3. **按钮写的是"点了会变成什么"**(当前保留 ⇒ 按钮写「覆盖」),所以点一下就知道后果。
//!
//! ## 为什么分页而不是滚动
//!
//! 几千条冲突一次画完 = 几千个 widget:布局与滚动都卡。分页把 widget 数量钉在
//! [`super::super::model::PAGE_SIZE`] 条以内,翻页只是换数据。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::Catalog;
use crate::app::components::buttons::{self, Button};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame;
use crate::pages::Message as PageMessage;
use crate::theme::Ui;
use super::super::model::{Pick, PickRow};
use super::super::PackMessage;
use super::intent;
/// 处置按钮槽的宽度(px):取最宽状态「⟳ Overwrite」的实际占用再留余量,
/// 保证「Keep」与「Overwrite」两种状态的右边缘落在同一条线上。
const TOGGLE_SLOT: f32 = 132.0;
/// 整块。
pub(super) fn pick_block<'a>(
ui: &'a Ui,
catalog: Catalog,
pick: &'a Pick,
) -> Element<'a, PageMessage> {
let mut block = column![
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
pick.title.as_str(),
ui.color(Role::OnSurface),
),
// 计数摘要:这是"我要丢掉多少"的唯一一处数字,用强调色
frame::text_role(
ui,
TypeRole::BodyMedium,
true,
pick.summary.as_str(),
ui.color(Role::Primary),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
pick.default_note,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if let Some(note) = &pick.unknown_note {
block = block.push(
row![
frame::glyph(IconKind::Warn, ui.color(Role::Tertiary), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
note.as_str(),
ui.color(Role::Tertiary),
),
]
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center),
);
}
// 三个入口:整批保留 / 整批覆盖(逐项就是下面每行那个按钮)
block = block.push(
row![
chip(
ui,
Chip::filter(
pick.keep_all,
pick.keep_all_active,
intent(PackMessage::PickKeepAll),
),
),
chip(
ui,
Chip::filter(
pick.overwrite_all,
pick.overwrite_all_active,
intent(PackMessage::PickOverwriteAll),
),
),
]
.spacing(ui.space(Space::X2)),
);
// 分页头(当前页之外的行一律不画)
block = block.push(bounded(
column(
std::iter::once(pager(ui, catalog, pick))
.chain(pick.rows.iter().map(|item| pick_row(ui, item)))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
.width(Length::Fill),
375.0,
));
// 列表与动作行之间留出与其它区块一致的呼吸(复审 r1 视觉 2):滚到底时最后一行会紧贴
// 动作行,靠这层 padding 把两者分开(直接调列 spacing 会把行间距也一起拉开)。
block = block.push(
container(
row![
buttons::filled(
ui,
Button::new(pick.confirm.as_str(), intent(PackMessage::ConfirmImport))
.leading(IconKind::Check),
),
buttons::text(
ui,
Button::new(pick.cancel, intent(PackMessage::CancelImport)),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
)
.padding(Padding {
top: ui.space(Space::X2),
..Padding::ZERO
}),
);
container(block)
.width(Length::Fill)
.padding(ui.space(Space::X3))
.style(move |_theme| container::Style {
border: Border {
color: ui.color(Role::Outline),
width: 1.0,
radius: ui.radius_dp(ShapeScale::Large),
},
background: Some(Background::Color(ui.color(Role::Surface))),
..container::Style::default()
})
.into()
}
/// 分页头:区间 + 上一页 / 下一页。
fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageMessage> {
let previous = if pick.can_prev {
buttons::text(
ui,
Button::new(
pick.prev,
intent(PackMessage::PickPage(pick.page.saturating_sub(1))),
)
.leading(IconKind::ChevronDown),
)
} else {
// 到边了就把按钮指回当前页(点了不动的死按钮比"没有按钮"更让人困惑)
buttons::text(
ui,
Button::new(pick.prev, intent(PackMessage::PickPage(pick.page))),
)
};
let next = if pick.can_next {
buttons::text(
ui,
Button::new(pick.next, intent(PackMessage::PickPage(pick.page + 1)))
.leading(IconKind::ChevronDown),
)
} else {
buttons::text(
ui,
Button::new(pick.next, intent(PackMessage::PickPage(pick.page))),
)
};
// 分页行与行内控件共用同一条右锚点(复审 r1 视觉 3):区间文案靠左,两个按钮用
// `horizontal_space` 顶到右边,于是「Previous · Next」的右边缘与下面每一行的处置槽对齐。
// 两个按钮进一个**右对齐的槽**,槽的右边缘就是行的右边缘 —— 于是它们的右端与下面每行的
// 处置槽落在同一条线上(按钮自身的内边距也一起对齐,不会差那十几像素)。
let buttons_slot = container(
row![previous, next]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
)
.width(Length::Fixed(TOGGLE_SLOT * 2.0))
.align_x(Alignment::End);
row![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
pick.range.as_str(),
ui.color(Role::OnSurfaceVariant),
),
iced::widget::Space::new().width(Length::Fill),
buttons_slot,
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
/// 一条冲突:**第一行**路径 + 处置按钮,**第二行**来源 · 成因 · 两侧体积。
///
/// 为什么两行:单行里塞四个字段(路径 / 来源 / 体积 / 按钮)会把路径压成一条窄柱,
/// 出图实测每行折成四五段——扫读成本比两行高得多。
fn pick_row<'a>(ui: &'a Ui, item: &'a PickRow) -> Element<'a, PageMessage> {
// 两种取值都是**同一种几何**(等高的 pill),只是填充不同:
// · 当前=覆盖 ⇒ **tonal 填充**(这一行会被盖掉,是"待反悔"的那一档)
// · 当前=保留 ⇒ **描边**(缺省档;点了才会覆盖)
// 两种 pill 再各自右对齐进同宽度的槽,于是**右边缘落在同一条线**(复审 r1 视觉 1 的必修项)——
// 之前一边是描边 pill、一边是无容器的文字按钮,宽窄与右端都不一致。
let toggle = if item.overwrites() {
buttons::tonal(
ui,
Button::new(
item.toggle,
intent(PackMessage::TogglePick(item.path.clone())),
)
.leading(IconKind::Refresh),
)
} else {
buttons::outlined(
ui,
Button::new(
item.toggle,
intent(PackMessage::TogglePick(item.path.clone())),
)
.leading(IconKind::Refresh),
)
};
// 路径那半列必须自己吃掉剩余宽度(`Length::Fill`),否则处置槽会紧跟文字、离右边缘一大截,
// 与分页行的右锚点对不上(出图实测:行内按钮停在 x≈680、分页按钮在 x≈780)。
let path_slot = container(frame::text_role(
ui,
TypeRole::BodySmall,
false,
item.path.as_str(),
ui.color(Role::OnSurface),
))
.width(Length::Fill);
// 处置按钮自己那一格:定宽 + 右对齐 ⇒ 两种取值的 pill 右边缘落在同一条线上。
// 右侧再留 8dp:滚动条就贴在内容右边缘(出图实测 x≈791),pill 紧贴着它显得挤。
let toggle_slot = container(toggle)
.width(Length::Fixed(TOGGLE_SLOT))
.align_x(Alignment::End)
.padding(Padding {
right: ui.space(Space::X2),
..Padding::ZERO
});
let head = row![path_slot, toggle_slot]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill);
let detail = frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
format!("{} · {} · {}", item.source, item.reason, item.sizes),
ui.color(Role::OnSurfaceVariant),
);
column![head, detail]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 有界容器:内容多时自己滚(与结果块的 `bounded` 同一条口径,但这里限高不同)。
fn bounded<'a>(
content: impl Into<Element<'a, PageMessage>>,
height: f32,
) -> Element<'a, PageMessage> {
iced::widget::scrollable(container(content).width(Length::Fill))
.height(Length::Fixed(height))
.into()
}
+5 -1
View File
@@ -62,6 +62,7 @@ use crate::renderer;
use crate::shot;
use crate::theme::Ui;
use super::instances;
use super::{app_with_appearance, demo_model, Model};
mod app;
@@ -118,6 +119,8 @@ struct Harness {
model: Model,
/// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。
appearance: Option<appearance::State>,
/// 实例页「启动参数」区的表单(M157):harness 没有 `App`,自己持有演示表单。
form: instances::LaunchForm,
window: Option<window::Id>,
/// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读,
/// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。
@@ -308,7 +311,8 @@ impl Harness {
}
fn view(&self) -> Element<'_, Message> {
app_with_appearance(&self.ui, &self.model, self.appearance.as_ref()).map(Message::Page)
app_with_appearance(&self.ui, &self.model, self.appearance.as_ref(), &self.form)
.map(Message::Page)
}
}
+9
View File
@@ -106,6 +106,14 @@ pub fn run(request: Request) -> Result<(), String> {
request.flavor,
);
state.apply_env();
// M159:`ULL_SETTINGS_GROUP=<下标>` 在 harness 里也生效(产品路径走
// `app/state/construct.rs` 的同一条判据)。没有它就只能拍默认的「General」组,
// 而设置页有五组——「更新」组的验收图正是靠它拍出来的。
if let Some(index) =
crate::ui::lang::screenshot_settings_group(request.screenshot.is_some())
{
state.selected_group = index;
}
eprintln!(
"[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \
配色来源={:?} 选中分组={}",
@@ -144,6 +152,7 @@ pub fn run(request: Request) -> Result<(), String> {
ui: Ui::new(boot.flavor, families),
model,
appearance: appearance.clone(),
form: crate::pages::demo_launch_form(),
request: boot.clone(),
window: None,
hwnd: None,
+12 -4
View File
@@ -48,6 +48,8 @@
mod data;
mod demo;
mod detail;
mod launch_args;
mod state;
mod text;
#[cfg(test)]
@@ -55,7 +57,8 @@ mod tests;
#[cfg(test)]
pub(crate) use data::{DEMO, NOTE, SELECTED};
pub(super) use demo::demo;
pub(crate) use demo::{demo, demo_form};
pub use state::{LaunchForm, Message as InstanceMessage};
pub(super) use text::Texts;
use iced::widget::{column, container, row, scrollable};
@@ -97,7 +100,12 @@ pub(super) const LIST_BOTTOM_INSET: Space = Space::X2;
///
/// 文案全部走 [`Texts`](`model.lang` 的语言):这个函数是实例页唯一的取词入口,
/// 页面里不留任何可见英文字面量(守门见 `instances/tests/i18n.rs`)。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<'a, Message> {
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Model,
form: &'a LaunchForm,
) -> Element<'a, Message> {
let block = &model.instances;
let forced = model.forced;
let texts = Texts::of(model.lang.catalog());
@@ -123,12 +131,12 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<
metrics,
list_column(ui, block, forced, texts),
match &block.detail {
Some(detail) => detail::detail_column(ui, metrics, detail, forced, texts),
Some(detail) => detail::detail_column(ui, metrics, detail, form, forced, texts),
None => detail::detail_empty(ui, texts),
},
),
_ => match &block.detail {
Some(detail) => detail::narrow_detail(ui, metrics, detail, forced, texts),
Some(detail) => detail::narrow_detail(ui, metrics, detail, form, forced, texts),
None => frame::body(metrics, detail::detail_empty(ui, texts)),
},
};
+62 -1
View File
@@ -4,9 +4,11 @@
//! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::LaunchArgsView;
use super::super::model::{Detail, Instances, Meta, Row};
use super::super::model::{Detail, Instances, LaunchFacts, Meta, Row};
use super::data::{DEMO, NOTE, SELECTED};
use super::state::LaunchForm;
/// 演示路径的目录:**恒英文**。
///
@@ -51,6 +53,7 @@ pub(crate) fn demo() -> Instances {
status: None,
can_launch: true,
can_stop: false,
launch: launch_facts(),
};
Instances {
@@ -65,3 +68,61 @@ fn meta(label: &str, value: &str) -> Meta {
value: value.to_string(),
}
}
/// 启动参数区的演示事实(M157 的 D30 出图夹具)。
///
/// `ULL_LAUNCH_ARGS_FIXTURE=gated` 出「版本不够、三态灰化」那一屏;缺省(`open`)是
/// 「26.2 实例 + Vulkan 可选」的正常态。**这是出图夹具,不是产品逻辑**:产品路径的门槛
/// 来自 presenter 快照(core 的 `graphics_backend_allowed`),与这个环境变量无关。
pub(crate) fn launch_facts() -> LaunchFacts {
let gated = gated_fixture();
LaunchFacts {
allowed: !gated,
mc_version: if gated { "1.20.4" } else { "26.2-snapshot-2" }.to_string(),
min_version: "26.2-snapshot-2".to_string(),
memory_effective_mb: if gated { 2048 } else { 4096 },
java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(),
backend_effective: if gated { "default" } else { "vulkan" }.to_string(),
options: ["default", "opengl", "vulkan"]
.iter()
.map(|id| (*id).to_string())
.collect(),
}
}
/// 演示用的表单初值(页面状态的所有者是 `App`;harness 没有 `App`,自己持有一份)。
///
/// 返回**拥有**的表单而不是 `&'static`:出图/单测各自持有一份即可(一帧借用一次),
/// 而 `static` 会让所有出图路径共享同一份可编辑状态(两个参数的控件现在都是单行
/// `field::text_field` + `String`,不再是早先的多行编辑器)。
pub(crate) fn demo_form() -> LaunchForm {
LaunchForm::from_view(Some(DEMO[SELECTED].name), &demo_view())
}
fn demo_view() -> LaunchArgsView {
let gated = gated_fixture();
LaunchArgsView {
instance_id: DEMO[SELECTED].name.to_string(),
jvm_args: "-XX:+UseG1GC".to_string(),
game_args: "--fullscreen".to_string(),
memory_mb: (!gated).then_some(4096),
memory_effective_mb: if gated { 2048 } else { 4096 },
java_path: String::new(),
java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(),
graphics_backend: if gated { "default" } else { "vulkan" }.to_string(),
graphics_backend_effective: if gated { "default" } else { "vulkan" }.to_string(),
graphics_backend_allowed: !gated,
mc_version: if gated { "1.20.4" } else { "26.2-snapshot-2" }.to_string(),
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
graphics_backend_options: ["default", "opengl", "vulkan"]
.iter()
.map(|id| (*id).to_string())
.collect(),
}
}
fn gated_fixture() -> bool {
std::env::var("ULL_LAUNCH_ARGS_FIXTURE")
.map(|value| value.trim().eq_ignore_ascii_case("gated"))
.unwrap_or(false)
}
+39 -5
View File
@@ -4,8 +4,8 @@
//! → 一条分隔线 → 正文段 / 运行状态行 → 动作行(Duplicate / Open folder / Delete)。
//! 窄窗(<840)时列表退成上一屏,本文件只画详情 + 一个「返回」按钮。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
@@ -23,6 +23,7 @@ use super::super::layout::{self, FrameMetrics};
use super::super::model::Detail;
use super::super::{Action, Message};
use super::forced_state;
use super::state::LaunchForm;
use super::Texts;
// --------------------------------------------------------------- 详情列
@@ -31,6 +32,7 @@ pub(super) fn detail_column<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
detail: &'a Detail,
form: &'a LaunchForm,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
@@ -43,15 +45,23 @@ pub(super) fn detail_column<'a>(
.width(Length::Fill);
body = push_text(ui, metrics, body, detail);
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)
frame::left_aligned(metrics.column, body.push(action_row(ui, forced, texts)))
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)。
//
// M157 r2(D30 复审):详情列的内容(正文段 + 启动参数卡 + 动作行)在 1180×1000 下**超出一屏**,
// 而这里原来没有滚动区 ⇒ Max memory 以下的控件被静默裁掉、够不着。现在整列套一个滚动区
// (与左列列表同一套滚动条样式与宽度),被裁内容可达;滚动条是唯一入口,没有第二份状态。
let content = body.push(action_row(ui, forced, texts));
frame::left_aligned(metrics.column, scroller(ui, content))
}
pub(super) fn narrow_detail<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
detail: &'a Detail,
form: &'a LaunchForm,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
@@ -74,6 +84,7 @@ pub(super) fn narrow_detail<'a>(
.width(Length::Fill);
body = push_text(ui, metrics, body, detail);
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
body = body.push(frame::text_role(
ui,
@@ -83,7 +94,30 @@ pub(super) fn narrow_detail<'a>(
ui.color(Role::OnSurfaceVariant),
));
frame::body(metrics, body.push(action_row(ui, forced, texts)))
frame::body(
metrics,
scroller(ui, body.push(action_row(ui, forced, texts))),
)
}
/// 详情列的滚动区(M157 r2):与左列列表同一套滚动条几何(宽 6 / 内缩 2 / 同一 `style`)。
///
/// 右侧留 `Space::X2` 内缩:滚动条是**覆盖**在内容上的,不留这条缝时卡片的 1dp 描边会被
/// 滚动条压住(D30 复审里「卡片右边框缺一截」就是它)。
fn scroller<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let content = container(content)
.width(Length::Fill)
.padding(Padding::default().right(ui.space(Space::X2)));
scrollable(content)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui))
.into()
}
/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。
@@ -0,0 +1,233 @@
//! 实例详情页的「启动参数」区(M157)。
//!
//! | 控件 | 语义 | 消息 |
//! |---|---|---|
//! | JVM 参数(多行) | 实例层,与全局 `java.extra_args` **叠加**(实例段最高优先级) | [`Message::Jvm`] |
//! | 游戏参数(多行) | 实例层,追加在命令行最后 | [`Message::Game`] |
//! | 最大内存(单行数字) | 实例层;留空 = 跟随全局默认 | [`Message::Memory`] |
//! | Java 可执行文件(单行) | 实例层;留空 = 跟随全局 → 自动挑 | [`Message::Java`] |
//! | 图形后端(三态 chips) | `default` / `opengl` / `vulkan`;**版本不够时只灰化后两档**(`default` 始终可点)+ 提示 | [`Message::Backend`] |
//!
//! 两条判据刻意不在这里重算:
//! 1. **能不能用 `--graphicsBackend`** 来自 [`LaunchFacts::allowed`](core 的 `graphics_backend_allowed`,
//! presenter 的 host 已经算好)——UI 不复制版本号比较;
//! 2. **生效值**(内存/Java/后端)同样来自 facts,表单里画的是实例层原文。
//!
//! 文案全走 [`Texts`](守门见 `instances/tests/i18n.rs` 的白名单与死 key 扫描)。
use iced::widget::{column, row};
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::super::model::LaunchFacts;
use super::super::Message as PageMessage;
use super::state::{LaunchForm, Message};
use super::Texts;
/// 页内消息 → 页面消息(本文件的控件树统一画 `pages::Message`)。
fn page(message: Message) -> PageMessage {
PageMessage::Instances(message)
}
/// 「启动参数」区(在详情列的正文段之后、动作行之前)。
pub(super) fn section<'a>(
ui: &'a Ui,
facts: &'a LaunchFacts,
form: &'a LaunchForm,
texts: Texts,
) -> Element<'a, PageMessage> {
// 段间距 X2(8dp):卡内 5 个字段 + 三态 + 动作行在 424 宽下已经很长,
// 用 X3 会让详情列更长一屏;行内间距仍是 X1(4dp),读起来不挤。
let mut body = column![frame_title(ui, texts.launch_title())]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
body = body.push(args_field(
ui,
texts.launch_jvm(),
texts.launch_jvm_hint(),
form.jvm.as_str(),
|value| page(Message::Jvm(value)),
));
body = body.push(args_field(
ui,
texts.launch_game(),
texts.launch_game_hint(),
form.game.as_str(),
|value| page(Message::Game(value)),
));
body = body.push(text_field_with_hint(
ui,
texts.launch_memory(),
form.memory.as_str(),
super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_memory_hint(facts.memory_effective_mb),
ui.color(Role::OnSurfaceVariant),
),
|value| page(Message::Memory(value)),
));
body = body.push(text_field_with_hint(
ui,
texts.launch_java(),
form.java.as_str(),
super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_java_hint().to_string(),
ui.color(Role::OnSurfaceVariant),
),
|value| page(Message::Java(value)),
));
body = body.push(backend_row(ui, facts, form, texts));
body = body.push(actions_row(ui, texts));
// 与相邻信息卡**同一套**卡壳(`frame::card`:`card_padding` 21dp + OutlinedCard 的圆角/描边)。
super::super::frame::card(ui, body)
}
/// 卡片标题:**与信息卡的标签同款**(`LabelMedium` 不加粗、`OnSurfaceVariant`),
/// 复审 ③⑤ 抓的就是这里(原来用了 `emphasized = true`,比同屏其它标签重一档)。
fn frame_title<'a>(ui: &'a Ui, title: &'a str) -> Element<'a, PageMessage> {
super::super::frame::text_role(
ui,
TypeRole::LabelMedium,
false,
title,
ui.color(Role::OnSurfaceVariant),
)
}
/// 一段参数输入 + 一行提示(单行受控输入;参数按空白切分,与 core 的 `tokenize` 同口径)。
fn args_field<'a>(
ui: &'a Ui,
label: &'a str,
hint: &'a str,
value: &'a str,
on_input: impl Fn(String) -> PageMessage + 'a,
) -> Element<'a, PageMessage> {
column![
text_field(ui, Field::new(value).label(label), on_input),
super::super::frame::text_role(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 单行输入 + 一行提示(提示是**拥有的**字符串,所以不能走 `Field::supporting` 的借用口径)。
fn text_field_with_hint<'a>(
ui: &'a Ui,
label: &'a str,
value: &'a str,
hint: Element<'a, PageMessage>,
on_input: impl Fn(String) -> PageMessage + 'a,
) -> Element<'a, PageMessage> {
column![
text_field(ui, Field::new(value).label(label), on_input),
hint,
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 三态后端的「不下发」档(`GraphicsBackend::Default` 的稳定 id)。
///
/// UI 侧不依赖 core(分层:UI → presenter → core),所以在页面里落一个常量;
/// 词表本身仍来自 presenter 快照([`LaunchFacts::options`]),这里只认「哪一个是安全档」。
const BACKEND_DEFAULT: &str = "default";
/// 某一档 chip 在门限不满足时**是否仍可点**。
///
/// 抽成纯函数是为了让它可断言(`Element` 没有内省接口,画出来的禁用态测不到;
/// 见 `instances/tests/launch_args.rs`)。
pub(crate) fn backend_chip_enabled(allowed: bool, id: &str) -> bool {
allowed || id == BACKEND_DEFAULT
}
/// 三态后端:**版本不够时只灰掉 `opengl` / `vulkan`**,`default` 始终可点。
///
/// 为什么 `default` 不许一起灰:它是「不下发 `--graphicsBackend`」的安全档,写它永远不会被
/// core 的版本门限拒绝。一起灰掉的话,用户手改实例文件、或在 CLI 里给旧版本实例写下
/// `graphicsBackend: vulkan` 之后,UI 上就**回不到 `default`**——唯一的出路是「恢复跟随全局」,
/// 而那会连带清空 JVM / 游戏参数 / 内存 / Java 四项(M157 复审 P3)。
fn backend_row<'a>(
ui: &'a Ui,
facts: &'a LaunchFacts,
form: &'a LaunchForm,
texts: Texts,
) -> Element<'a, PageMessage> {
let allowed = facts.allowed;
let mut chips = row![].spacing(ui.space(Space::X2));
for id in &facts.options {
let label = texts.backend_label(id).unwrap_or(id.as_str());
let selected = form.backend == *id;
let spec = Chip::filter(label, selected, page(Message::Backend(id.clone())));
let spec = if backend_chip_enabled(allowed, id) {
spec
} else {
spec.disabled()
};
chips = chips.push(chip(ui, spec));
}
let mut meta = column![
super::super::frame::text_role(
ui,
TypeRole::BodySmall,
false,
texts.launch_backend(),
ui.color(Role::OnSurface),
),
chips,
super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_effective(&facts.backend_effective),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !allowed {
// 灰化 + 明确提示:既不说「已生效」,也不静默吞掉用户的选择。
meta = meta.push(super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_backend_gated(&facts.mc_version, &facts.min_version),
ui.color(Role::Error),
));
}
meta.into()
}
fn actions_row<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, PageMessage> {
row![
buttons::filled(ui, Button::new(texts.launch_save(), page(Message::Save))),
buttons::text(ui, Button::new(texts.launch_reset(), page(Message::Reset))),
]
.spacing(ui.space(Space::X2))
.into()
}
@@ -0,0 +1,291 @@
//! 实例页的**页面状态**(M157):启动参数区的表单。
//!
//! ## 为什么状态在 app、页面只借一帧
//!
//! iced 的 `view` 不能持有跨帧状态,而启动参数区是可编辑的(两个多行框 + 两行单行框 +
//! 三态 chips)。所以表单状态的所有者是 `App`(`app/state.rs` 的 `launch_form` 字段),
//! 页面层只在这一帧借用;这与设置页「组状态归 `App`」是同一条纪律(`pages/settings/state.rs`)。
//!
//! ## 与快照的关系(唯一的重播种点)
//!
//! [`LaunchForm::sync`] 由 `App::refresh_view` 调用:
//!
//! | 情况 | 行为 |
//! |---|---|
//! | 换了一个实例 | **重播种**(用户没写完的草稿不能串到别的实例上) |
//! | 同一个实例、没改过 | 重播种(保存/外部改了配置后跟着刷新) |
//! | 同一个实例、改过(`dirty`) | **不覆盖**(每来一条事件就把输入抹掉是不可接受的) |
//!
//! 「留空 = 跟随下一层」由提交时把空串原样交给 host 表达(`Some("")` = 清空),
//! 表单自己不做任何回落计算——回落只发生在 core。
use unlucky_presenter::dto::{LaunchArgsPatch, LaunchArgsView};
/// 启动参数表单(五项 + 脏标记 + 绑定的实例 id)。
#[derive(Debug)]
pub struct LaunchForm {
/// 这份草稿属于哪个实例(`None` = 还没播种)。
pub bound: Option<String>,
/// 实例级 JVM 参数(空白分隔的文本)。
pub jvm: String,
/// 实例级游戏参数(空白分隔的文本)。
pub game: String,
/// 最大堆(文本形态,空 = 跟随全局)。
pub memory: String,
/// Java 可执行文件(空 = 跟随全局 / 自动挑)。
pub java: String,
/// 图形后端(`default` / `opengl` / `vulkan`)。
pub backend: String,
/// 用户改过、还没保存。
pub dirty: bool,
}
impl Default for LaunchForm {
fn default() -> Self {
Self::from_view(None, &LaunchArgsView::default())
}
}
impl LaunchForm {
/// 用一份快照播种(`bound` 为 `None` = 还没绑定实例)。
#[must_use]
pub fn from_view(bound: Option<&str>, view: &LaunchArgsView) -> Self {
Self {
bound: bound.map(str::to_string),
jvm: view.jvm_args.clone(),
game: view.game_args.clone(),
memory: view.memory_mb.map(|mb| mb.to_string()).unwrap_or_default(),
java: view.java_path.clone(),
backend: view.graphics_backend.clone(),
dirty: false,
}
}
/// 这份草稿是否正对着该实例。
#[must_use]
pub fn bound_to(&self, id: &str) -> bool {
self.bound.as_deref() == Some(id)
}
/// 重播种(见模块文档的三档)。
///
/// **脏草稿只在「落盘已确认」时被替换**:保存把命令派发出去之后,回程可能是一条错误事件
/// (内存写了 `4g`、实例文件写不进去),那时 pump 会带着**旧快照**重建视图——如果这里无条件
/// 覆盖,用户刚敲的东西就没了(M157 r3 复审 P2-2)。判据是「快照已经等于表单」:
/// 相等 ⇒ 这次保存落了盘,清脏并重播种;不相等 ⇒ 保留草稿等用户改。
pub fn sync(&mut self, view: &LaunchArgsView) {
let same_instance = self.bound_to(&view.instance_id);
if same_instance && self.dirty {
if self.matches(view) {
self.dirty = false;
*self = Self::from_view(Some(&view.instance_id), view);
}
return;
}
*self = Self::from_view(Some(&view.instance_id), view);
}
/// 表单当前内容是否与一份快照(= 盘上已持久化的值)完全一致。
///
/// **内存按数值比**:host 会把 `"04096"` / `"+4096"` 归一成 `4096` 落盘,回程快照给的是
/// `Some(4096)`——按文本比会永远不等,于是表单一直脏、也不再跟随外部改动(r4 复审 P3-b)。
/// 解析失败(用户正在敲半截)才退回文本口径。
/// 其余字段直接比原文(JVM/游戏参数由 core 的 `tokenize` 消费,不做归一)。
#[must_use]
pub fn matches(&self, view: &LaunchArgsView) -> bool {
self.jvm.trim() == view.jvm_args.trim()
&& self.game.trim() == view.game_args.trim()
&& self.memory_matches(view.memory_mb)
&& self.java.trim() == view.java_path.trim()
&& self.backend == view.graphics_backend
}
/// 内存字段是否等于快照里的值(数值优先,解析失败退文本)。
fn memory_matches(&self, persisted: Option<u32>) -> bool {
let typed = self.memory.trim();
match (typed.parse::<u32>(), persisted) {
(Ok(typed), Some(persisted)) => typed == persisted,
// 空输入 = 跟随全局:快照那边也应当是「没有实例覆盖」。
(Err(_), None) => typed.is_empty(),
_ => false,
}
}
/// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。
#[must_use]
pub fn patch(&self) -> LaunchArgsPatch {
LaunchArgsPatch {
jvm_args: Some(self.jvm.clone()),
game_args: Some(self.game.clone()),
memory_mb: Some(self.memory.clone()),
java_path: Some(self.java.clone()),
graphics_backend: Some(self.backend.clone()),
}
}
/// 「恢复跟随全局」:五项清空,脏标记置位(等用户点保存)。
pub fn reset(&mut self) {
self.jvm.clear();
self.game.clear();
self.memory.clear();
self.java.clear();
self.backend = "default".to_string();
self.dirty = true;
}
}
/// 启动参数区的消息(`pages::Message::Instances` 的载荷)。
///
/// 只表达**页面意图**:编辑写进表单,保存/恢复才需要 app 层理解(前者 dispatch 一条
/// `AppCommand::SetInstanceLaunchArgs`,后者只是清表单)。
#[derive(Debug, Clone)]
pub enum Message {
/// 编辑 JVM 参数。
Jvm(String),
/// 编辑游戏参数。
Game(String),
/// 编辑最大内存(单行文本)。
Memory(String),
/// 编辑 Java 路径(单行文本)。
Java(String),
/// 选图形后端(三态 chips)。
Backend(String),
/// 保存(派发命令)。
Save,
/// 恢复跟随全局(清表单,仍需保存)。
Reset,
}
#[cfg(test)]
mod tests {
use super::*;
fn view(instance: &str, jvm: &str, memory: Option<u32>, backend: &str) -> LaunchArgsView {
LaunchArgsView {
instance_id: instance.to_string(),
jvm_args: jvm.to_string(),
game_args: String::new(),
memory_mb: memory,
memory_effective_mb: memory.unwrap_or(2048),
java_path: String::new(),
java_effective: String::new(),
graphics_backend: backend.to_string(),
graphics_backend_effective: backend.to_string(),
graphics_backend_allowed: true,
mc_version: "26.2".to_string(),
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
graphics_backend_options: vec![
"default".to_string(),
"opengl".to_string(),
"vulkan".to_string(),
],
}
}
/// 换实例必重播种;同实例的脏草稿不被事件冲掉。
#[test]
fn sync_reseeds_per_instance_but_keeps_dirty_edits() {
let mut form = LaunchForm::default();
form.sync(&view("a", "-Xss4m", Some(4096), "vulkan"));
assert_eq!(form.jvm, "-Xss4m");
assert_eq!(form.memory, "4096");
assert!(!form.dirty);
// 用户改动 → 脏 → 同实例的快照刷新不许覆盖。
form.memory = "8192".to_string();
form.dirty = true;
form.sync(&view("a", "-Xss4m", Some(4096), "vulkan"));
assert_eq!(form.memory, "8192", "脏草稿被事件冲掉了");
// 换实例 → 重播种(草稿不串台)。
form.sync(&view("b", "", None, "default"));
assert_eq!(form.memory, "");
assert!(!form.dirty);
assert!(form.bound_to("b"));
}
/// **保存失败不许吃掉草稿**(M157 r3 复审 P2-2):脏表单 + 旧快照回来 ⇒ 原样保留。
///
/// 抓得住:`sync` 在「同实例 + 脏」时无条件重播种(用户敲的 `4g` 被磁盘上的 4096 覆盖)。
/// 抓不住:真实事件时序(那在 `app/update/instances.rs` 的用例里按消息顺序模拟)。
#[test]
fn failed_save_keeps_the_draft() {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
// 用户把内存改成非法值(host 会拒),此时表单是脏的。
form.memory = "4g".to_string();
form.jvm = "-Xmx3g".to_string();
form.dirty = true;
// 回程事件带来的仍是**旧**快照(保存失败 ⇒ 盘上没变)。
form.sync(&view("a", "", Some(4096), "default"));
assert_eq!(form.memory, "4g", "失败后的重播把用户正在改的内存吃掉了");
assert_eq!(form.jvm, "-Xmx3g");
assert!(form.dirty, "草稿还在,脏标记也不能掉");
}
/// **保存成功要清脏**:回程快照等于表单 ⇒ 这一步算落盘确认,之后可以跟着外部变化重播种。
#[test]
fn confirmed_save_clears_dirty_and_reseeds() {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
form.memory = "8192".to_string();
form.backend = "vulkan".to_string();
form.dirty = true;
form.sync(&view("a", "", Some(8192), "vulkan"));
assert!(!form.dirty, "盘上已经是这份值了,脏标记该清");
assert_eq!(form.memory, "8192");
// 之后外部改了配置(比如 CLI 写入)⇒ 干净的表格跟着更新。
form.sync(&view("a", "-Xss4m", Some(2048), "opengl"));
assert_eq!(form.memory, "2048");
assert_eq!(form.jvm, "-Xss4m");
}
/// **内存按数值比**(r4 复审 P3-b):`04096` / `+4096` 落盘后回程是 `4096`,必须判等。
///
/// 抓得住:`matches` 退回文本比较——那样这两类输入会永远「没保存成功」,表单一直脏、
/// 之后外部改配置也不再重播种。
#[test]
fn memory_is_compared_by_value_not_by_text() {
for typed in ["04096", "+4096", " 4096 "] {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
form.memory = typed.to_string();
form.dirty = true;
assert!(
form.matches(&view("a", "", Some(4096), "default")),
"`{typed}` 应判等"
);
form.sync(&view("a", "", Some(4096), "default"));
assert!(!form.dirty, "`{typed}` 保存确认后要清脏");
}
// 数值不等就是不等于;半截输入(解析失败)也不许判等。
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
form.memory = "8".to_string();
assert!(!form.matches(&view("a", "", Some(4096), "default")));
form.memory = "4g".to_string();
assert!(!form.matches(&view("a", "", Some(4096), "default")));
// 清空 = 跟随全局:快照也得是「没有实例覆盖」。
form.memory.clear();
assert!(form.matches(&view("a", "", None, "default")));
}
/// 补丁五项全给:空串就是「清空」,不留 `None` 让 host 猜。
#[test]
fn patch_always_covers_all_five_fields() {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", None, "default"));
form.memory = "6144".to_string();
form.backend = "opengl".to_string();
let patch = form.patch();
assert_eq!(patch.jvm_args.as_deref(), Some(""));
assert_eq!(patch.memory_mb.as_deref(), Some("6144"));
assert_eq!(patch.graphics_backend.as_deref(), Some("opengl"));
form.reset();
assert!(form.dirty);
let patch = form.patch();
assert_eq!(patch.jvm_args.as_deref(), Some(""));
assert_eq!(patch.memory_mb.as_deref(), Some(""));
assert_eq!(patch.graphics_backend.as_deref(), Some("default"));
}
}
@@ -4,6 +4,9 @@ use super::*;
/// M120 的文案守门(中文无英文词 / 切语言真的变 / 页面里不许写死英文案·白名单)。
mod i18n;
/// M157 的门限判据(三态 chips 在版本不够时谁可点)。
mod launch_args;
use super::super::layout::source;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
@@ -28,14 +28,18 @@ use super::super::Texts;
///
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
const ALLOWED: &[&str] = &["Java", "pid"];
const ALLOWED: &[&str] = &["Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB"];
/// 页面取词的五个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 5] = [
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 6] = [
("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")),
("instances/text.rs", include_str!("../text.rs")),
(
"instances/launch_args.rs",
include_str!("../launch_args.rs"),
),
(
"app/view/instances.rs",
include_str!("../../../app/view/instances.rs"),
@@ -57,13 +61,33 @@ const PAGE_SOURCES: [(&str, &str); 5] = [
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
const ALLOWED_LITERALS: [&str; 6] = [
const ALLOWED_LITERALS: [&str; 19] = [
"{date} {time} UTC",
"isolated",
"shared",
"pid",
"reason",
"Instances",
// M157:图形后端三态的**稳定 id**(与 core 的 `GraphicsBackend::IDS` 同一份词表),
// `backend_label` 只认这三档,其余原样上屏。
"default",
"opengl",
"vulkan",
// M157:`Catalog::render` 的占位符名(`{version}` / `{value}` / `{default}`),不是文案。
"version",
"value",
// M157 r4:门槛文案的占位符名(版本号由 DTO 给,目录里不写死)。
"min",
// M157:`instances/demo.rs` 的**出图夹具**(用户数据的形状,不是界面文案):
// 一个示例 JVM 参数、两个示例 MC 版本、一个示例 Java 路径,以及夹具开关的环境变量名与取值。
"-XX:+UseG1GC",
// M157 r2:示例游戏参数(同上,是夹具不是文案)。
"--fullscreen",
"26.2-snapshot-2",
"1.20.4",
"C:/Java/temurin-21/bin/java.exe",
"ULL_LAUNCH_ARGS_FIXTURE",
"gated",
];
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
@@ -248,9 +272,13 @@ fn every_english_literal_in_the_page_sources_is_whitelisted() {
"text.rs 没有引用本族的 key"
);
assert!(
PAGE_SOURCES[4].1.contains("Key::InstancesStage"),
PAGE_SOURCES[5].1.contains("Key::InstancesStage"),
"阶段标签没有走目录"
);
assert!(
PAGE_SOURCES[4].1.contains("texts.launch_title()"),
"启动参数区没有走目录"
);
}
/// **死 key 守门**:目录里每一条 `Instances*` 都必须被页面(或产品快照转换)真的引用。
@@ -0,0 +1,55 @@
//! 「启动参数」区(M157)的**门限**判据:三态 chips 在版本不够时谁可点。
//!
//! 为什么只测一个纯函数:iced 0.14 的 `Element` 没有内省接口,画出来的「禁用态」在测试里
//! 拿不到(`Chip::disabled` 只是把 `on_press` 清成 `None`)。所以把判据抽成
//! [`backend_chip_enabled`] 并在这里钉住——页面那一行必须调它(否则这条绿着也没意义,
//! 守门形状与 `instances/tests.rs` 的源码断言同一取舍)。
use super::super::launch_args::backend_chip_enabled;
use crate::pages::LaunchFacts;
/// 门限满足:三档全可点。
#[test]
fn every_backend_chip_is_clickable_when_the_gate_is_open() {
for id in ["default", "opengl", "vulkan"] {
assert!(backend_chip_enabled(true, id), "门限满足时 {id} 必须可点");
}
}
/// 门限不满足:只有 `default` 可点(它是「不下发」的安全档)。
///
/// 抓得住:把 `default` 一起灰掉(M157 复审 P3 的原状)——那时旧版本实例上写了
/// `graphicsBackend: vulkan` 的用户在 UI 里回不到 `default`,只能「恢复跟随全局」
/// (连带清空 JVM / 游戏参数 / 内存 / Java 四项)。
#[test]
fn the_default_chip_stays_clickable_when_the_gate_is_closed() {
assert!(
backend_chip_enabled(false, "default"),
"门限不满足时 default 也必须可点:它是唯一永远合法的档"
);
for id in ["opengl", "vulkan"] {
assert!(
!backend_chip_enabled(false, id),
"门限不满足时 {id} 必须灰掉(下发会被 core 拒绝)"
);
}
}
/// 词表与判据不许脱节:`default` 必须真在这份快照的 options 里,否则「安全档」无从点起。
#[test]
fn the_gate_rule_covers_the_demo_option_table() {
let facts = LaunchFacts {
options: ["default", "opengl", "vulkan"]
.iter()
.map(|id| (*id).to_string())
.collect(),
..LaunchFacts::default()
};
assert!(
facts
.options
.iter()
.any(|id| backend_chip_enabled(false, id)),
"门限关闭时至少有一档可点(否则三态整体锁死)"
);
}
@@ -85,6 +85,72 @@ impl Texts {
self.text(Key::InstancesNarrowNote)
}
// ------------------------------------------------------------- 启动参数(M157)
pub(super) fn launch_title(self) -> &'static str {
self.text(Key::InstancesLaunchTitle)
}
pub(super) fn launch_jvm(self) -> &'static str {
self.text(Key::InstancesLaunchJvm)
}
pub(super) fn launch_jvm_hint(self) -> &'static str {
self.text(Key::InstancesLaunchJvmHint)
}
pub(super) fn launch_game(self) -> &'static str {
self.text(Key::InstancesLaunchGame)
}
pub(super) fn launch_game_hint(self) -> &'static str {
self.text(Key::InstancesLaunchGameHint)
}
pub(super) fn launch_memory(self) -> &'static str {
self.text(Key::InstancesLaunchMemory)
}
/// 「留空 = 跟随全局默认({default} MiB)」,`default` 是生效值(三级回落之后)。
pub(super) fn launch_memory_hint(self, default_mb: u32) -> String {
self.0.render(
Key::InstancesLaunchMemoryHint,
&[("default", &default_mb.to_string())],
)
}
pub(super) fn launch_java(self) -> &'static str {
self.text(Key::InstancesLaunchJava)
}
pub(super) fn launch_java_hint(self) -> &'static str {
self.text(Key::InstancesLaunchJavaHint)
}
pub(super) fn launch_backend(self) -> &'static str {
self.text(Key::InstancesLaunchBackend)
}
/// 「需游戏版本 {min} 及以上(本实例 {version})」。
///
/// `min` 来自 DTO(`LaunchFacts::min_version` ← core 的 `GRAPHICS_BACKEND_MIN_VERSION`),
/// **不在目录里硬编码版本号**——否则 core 一改门槛,文案会继续骗人(r4 复审 P3-c)。
pub(super) fn launch_backend_gated(self, version: &str, min: &str) -> String {
self.0.render(
Key::InstancesLaunchBackendGated,
&[("version", version), ("min", min)],
)
}
/// 「生效:vulkan」。
pub(super) fn launch_effective(self, value: &str) -> String {
self.0
.render(Key::InstancesLaunchEffective, &[("value", value)])
}
/// 三态后端的显示名(稳定 id → 文案);**未知 id 返回 `None`**(调用方原样上屏,不猜)。
pub(super) fn backend_label(self, id: &str) -> Option<&'static str> {
Some(self.text(match id {
"default" => Key::InstancesBackendDefault,
"opengl" => Key::InstancesBackendOpengl,
"vulkan" => Key::InstancesBackendVulkan,
_ => return None,
}))
}
pub(super) fn launch_save(self) -> &'static str {
self.text(Key::InstancesLaunchSave)
}
pub(super) fn launch_reset(self) -> &'static str {
self.text(Key::InstancesLaunchReset)
}
fn text(self, key: Key) -> &'static str {
self.0.text(key)
}
+24
View File
@@ -91,12 +91,36 @@ pub enum Message {
/// 加一条内容库消息只动 `pages/content/message.rs`,不动本枚举的正文,也不动
/// `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。
Content(content::Message),
/// 实例页「启动参数」区的消息(**M157**):编辑写进页面状态,保存才 dispatch。
Instances(super::instances::InstanceMessage),
/// 自更新动作(**M159**):提示条与设置页「更新」区共用一支。
///
/// 为什么是顶层变体而不是又一个 `GroupMessage`:更新动作是**跨页的一次性意图**
/// (检查 / 下载安装 / 忽略 / 立即重启),不是某一组的内部状态;设置页的「更新」组也不持有
/// 任何字段(快照在 presenter 里)。与 M106 的 [`Self::Language`] 同一取舍,偏离已登记在
/// `pages/settings/update.rs` 的模块文档里。
Update(UpdateAction),
/// 账户页消息(**M114**:三轨登录 / 列表动作 / 表单输入;app 层翻成 `AppCommand`)。
///
/// 与 `Settings` 同一种做法:账户页自己的消息枚举在 `pages/account.rs`,这里只加一支入口。
Account(crate::pages::account::Message),
}
/// 自更新的用户动作(M159)。落点是 `app/update/update_action.rs`。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateAction {
/// 立即检查(手动;会清掉「忽略这个版本」)
CheckNow,
/// 下载 → 校验 → 替换(提示条与设置页共用一个动作)
Apply,
/// 忽略这个版本(本次会话不再弹提示条;设置页仍可手动装)
Dismiss,
/// 立即重启(新版本已就位,重启后生效)
RestartNow,
/// 开关「启动时检查更新」
SetAutoCheck(bool),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
+45 -10
View File
@@ -108,6 +108,7 @@ pub mod frame;
pub mod harness;
pub mod layout;
pub mod model;
pub mod update;
pub mod account;
pub mod content;
@@ -126,17 +127,21 @@ pub(crate) mod settings;
#[cfg(test)]
mod tests;
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
pub(crate) use instances::demo_form as demo_launch_form;
pub use instances::{InstanceMessage, LaunchForm};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message};
pub use model::{Detail, Instances, Meta, Model, Row};
pub use message::{Action, Message, UpdateAction};
pub use model::{Detail, Instances, LaunchFacts, Meta, Model, Row};
pub use screen::Screen;
pub use settings::Language;
pub use update::{Banner, Update, UpdateStatus};
use iced::widget::{column, container, row, space};
use iced::{Element, Length};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space;
use crate::theme::Ui;
@@ -150,7 +155,11 @@ use frame::filled;
///
/// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用:
/// `view(&self)` 不能返回一棵借用局部变量的树。
pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
pub fn page<'a>(
ui: &'a Ui,
model: &'a Model,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(
ui,
model,
@@ -159,6 +168,7 @@ pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
groups: settings::demo_groups(),
},
Language::default(),
form,
)
}
@@ -174,6 +184,7 @@ pub fn page_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: &'a appearance::State,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(
ui,
@@ -183,6 +194,7 @@ pub fn page_with_appearance<'a>(
groups: settings::demo_groups(),
},
Language::default(),
form,
)
}
@@ -199,8 +211,9 @@ pub fn page_with_settings<'a>(
model: &'a Model,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(ui, model, states, language)
render(ui, model, states, language, launch_form)
}
fn render<'a>(
@@ -208,17 +221,31 @@ fn render<'a>(
model: &'a Model,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
match model.screen {
Screen::Instances => instances::view(ui, metrics, model),
let page = match model.screen {
Screen::Instances => instances::view(ui, metrics, model, launch_form),
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
// M109:内容库页同样跟 `model.lang`
Screen::Content => content::view(ui, metrics, &model.content, model.lang),
Screen::Settings => settings::view(ui, metrics, states, language),
// M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。
Screen::Settings => settings::view(ui, metrics, states, language, &model.update),
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
};
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
match update::banner(ui, &model.update, model.lang.catalog()) {
Some(banner) => column![banner, page]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill)
.into(),
None => page,
}
}
@@ -227,8 +254,12 @@ fn render<'a>(
/// 产品路径**不走这里**(它走 [`page`] + `shell::view_with_content`,用真标题栏)。这个入口留给
/// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的
/// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
app_with_appearance(ui, model, None)
pub fn app<'a>(
ui: &'a Ui,
model: &'a Model,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
app_with_appearance(ui, model, None, form)
}
/// 同 [`app`],但可以带上**外观运行期状态**。
@@ -241,6 +272,7 @@ pub fn app_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: Option<&'a appearance::State>,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
// harness 路径没有真组状态:用演示状态([`settings::demo_groups`]),外观状态照传。
let body = render(
@@ -251,6 +283,7 @@ pub fn app_with_appearance<'a>(
groups: settings::demo_groups(),
},
Language::default(),
form,
);
let shell = row![nav::rail(ui, model.active), body]
@@ -278,7 +311,9 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model {
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang),
Screen::Logs => model.logs = logs::demo(model.lang),
Screen::Settings => {}
// M159:设置页「更新」组的出图夹具(一个可装的新版本 + 一次成功检查)。
// 只给这一页:其它页的基线图(尤其实例页的启动参数区)不该因为更新提示条而整体下移。
Screen::Settings => model.update = update::demo(),
}
model
}
+28
View File
@@ -21,6 +21,7 @@ use crate::app::components::interaction::Interaction;
use super::content::Content;
use super::logs::Logs;
use super::update::Update;
use super::{Download, Screen};
/// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。
@@ -64,6 +65,30 @@ pub struct Detail {
pub can_launch: bool,
/// 「Stop」可用
pub can_stop: bool,
/// 启动参数区的只读事实(M157)
pub launch: LaunchFacts,
}
/// 启动参数区(M157)的**只读事实**:门槛、生效值、三态词表。
///
/// 可编辑的值不在这里——它们在页面状态 [`super::instances::LaunchForm`](由 `App` 持有)。
/// 这样「每来一条事件就重建视图模型」不会把用户正在输入的内容冲掉。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct LaunchFacts {
/// 这个版本能不能下发 `--graphicsBackend`(core 的版本门槛判定)。
pub allowed: bool,
/// 判定依据的游戏版本(提示文案里给出)。
pub mc_version: String,
/// 门槛的最低版本(`26.2-snapshot-2`;提示与测试共用一份来源)。
pub min_version: String,
/// 生效最大堆(三级回落之后)。
pub memory_effective_mb: u32,
/// 生效 Java(空 = 自动挑)。
pub java_effective: String,
/// 生效图形后端。
pub backend_effective: String,
/// 三态词表(`default` / `opengl` / `vulkan`)。
pub options: Vec<String>,
}
/// 实例页要画的东西。
@@ -92,6 +117,8 @@ pub struct Model {
pub logs: Logs,
/// 内容库页的数据(M109:检索条件 / 命中项 / 装入闸门,见 [`super::content`])。
pub content: Content,
/// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。
pub update: Update,
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
///
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
@@ -117,6 +144,7 @@ impl Model {
logs: Logs::default(),
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
update: Update::default(),
lang: Lang::En,
forced: None,
}
+31 -4
View File
@@ -50,6 +50,12 @@
//! 字段、[`message::GroupMessage`] 加一个变体、`app/update/settings.rs` 加一条臂。**不需要**动 `frame.rs`、
//! 别的分组文件或 `pages/mod.rs`。
//!
//! **M159 的第五组(更新)是另一条形状**:它不持有组状态、也不走 `GroupMessage`,而是读
//! `settings::view` 的 `update` 形参(presenter 快照),由 [`UPDATE_GROUP`] 按下标分发——与 M106 的
//! 语言行同一取舍(那两样都不是「某一组的内部状态」)。所以加它只动了 ① `pages/settings/update.rs`、
//! ② 本文件的 [`GROUPS`] / [`GROUP_LABELS`] / `render` 的形参、③ `pages/mod.rs` 与 `pages/model.rs`
//! (模型多一格 `update`),**没有**碰 `state::Groups` 与 `message::GroupMessage`。
//!
//! ## 消息:一个投递口,四条线各写各的(M101 `SETTINGSMSG`)
//!
//! 四条填页线都要往 `pages::Message` 里加变体,而那是**一个**枚举加上
@@ -135,6 +141,7 @@ pub(crate) mod java;
pub(crate) mod language_appearance;
mod message;
mod state;
pub(crate) mod update;
#[cfg(test)]
mod tests;
@@ -154,6 +161,7 @@ use unlucky_i18n::{Lang, LangChoice};
use crate::theme::Ui;
use super::layout::{FrameMetrics, PageLayout};
use super::update::Update;
use super::Message;
/// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。
@@ -207,11 +215,14 @@ struct Group {
/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。
///
/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。
const GROUPS: [Group; 4] = [
const GROUPS: [Group; 5] = [
general::GROUP,
appearance::GROUP,
java::GROUP,
download::GROUP,
// M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标
// 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。
update::GROUP,
];
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
@@ -230,13 +241,21 @@ const GENERAL_GROUP: usize = 0;
///
/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过——
/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 4] = [
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [
unlucky_i18n::Key::SettingsGroupGeneral,
unlucky_i18n::Key::SettingsGroupAppearance,
unlucky_i18n::Key::SettingsGroupJava,
unlucky_i18n::Key::SettingsGroupDownloads,
// M159
unlucky_i18n::Key::SettingsGroupUpdate,
];
/// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。
///
/// 与 [`GENERAL_GROUP`] 同一种做法:这一组要画的是 **presenter 快照**(不在 [`States`] 里),
/// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。
const UPDATE_GROUP: usize = update::UPDATE_GROUP;
/// 整页。
///
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
@@ -246,6 +265,7 @@ pub fn view<'a>(
metrics: FrameMetrics,
states: States<'a>,
language: Language,
update: &'a Update,
) -> Element<'a, Message> {
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
let selected = states
@@ -256,9 +276,12 @@ pub fn view<'a>(
PageLayout::SupportingPane => frame::panes(
metrics,
frame::group_nav(ui, selected, language.effective),
frame::left_aligned(metrics.column, form(ui, Some(selected), states, language)),
frame::left_aligned(
metrics.column,
form(ui, Some(selected), states, language, update),
),
),
_ => frame::single_column(metrics, form(ui, None, states, language)),
_ => frame::single_column(metrics, form(ui, None, states, language, update)),
};
frame::page(ui, metrics, frame::header(ui, language.effective), body)
@@ -277,6 +300,7 @@ fn form<'a>(
only: Option<usize>,
states: States<'a>,
language: Language,
update: &'a Update,
) -> Element<'a, Message> {
let catalog = language.effective.catalog();
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
@@ -294,6 +318,9 @@ fn form<'a>(
// 无关(harness 那条路径也画得出真控件)。
let card = if index == GENERAL_GROUP {
general::body(ui, language)
} else if index == UPDATE_GROUP {
// M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。
update::body(ui, update, language.effective)
} else {
group
.body
@@ -40,6 +40,7 @@ mod appearance_section;
mod download_section;
mod general_section;
mod java_section;
mod update_section;
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*;
@@ -128,7 +129,10 @@ fn the_appearance_group_is_where_the_module_says_it_is() {
#[test]
fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
assert_eq!(
names,
vec!["General", "Appearance", "Java", "Downloads", "Updates"]
);
}
/// M101 r2:`settings/mod.rs` 里**不许出现任何一组的 `State` 类型**(r1 复审 P1 的判据钉在这里)。
@@ -193,6 +197,6 @@ fn the_settings_module_does_not_name_any_group_state() {
fn every_line_can_be_wired_alone() {
// 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事——
// ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。
assert_eq!(GROUPS.len(), 4, "四条线各一个分组");
assert_eq!(GROUPS.len(), 5, "四条填页线 + M159 的更新组");
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
@@ -0,0 +1,123 @@
//! 设置页「更新」分组(M159)的断言:注册表位置、四条状态各有各的句子。
//!
//! 为什么只测「词表 + 判据」而不测控件:iced 的 `Element` 没有内省接口(`tests/mod.rs` 顶部那条
//! 写过理由),上屏文本只能从**取词的那一个入口**取。这里守的是「四种结论的句子不是同一句」
//! ——正是 mission 里「检查失败不等于已是最新」那条硬要求的最小可测形态。
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::update::{GROUP as UPDATE_GROUP_META, UPDATE_GROUP};
use super::super::{GROUPS, SELECTED};
use crate::pages::update::UpdateStatus;
use crate::pages::{Screen, Update};
/// 更新组**必须是最后一组**:加组只追加不重排,插到中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP`
/// 两个下标一起失效。
#[test]
fn the_update_group_sits_at_the_end_of_the_registry() {
assert_eq!(GROUPS.len(), UPDATE_GROUP + 1, "更新组之后不该还有别的组");
assert_eq!(GROUPS[UPDATE_GROUP].name, UPDATE_GROUP_META.name);
assert_eq!(GROUPS[UPDATE_GROUP].name, "Updates");
assert!(SELECTED < GROUPS.len());
}
/// 四种结论的句子**互不相同**,且「失败」那一句必须把原因带出来。
///
/// 抓得住:把「检查失败」写成「已是最新」(那是 mission 明令禁止的撒谎:断网时会说「你是最新」)。
/// 抓不住:句子翻得顺不顺、颜色对不对(那是人读与截图的事)。
#[test]
fn the_four_outcomes_read_differently() {
let catalog = Lang::ZhCn.catalog();
let up_to_date = catalog.render(Key::SettingsUpdateUpToDate, &[("version", "0.2.2")]);
let available = catalog.render(
Key::SettingsUpdateAvailable,
&[("version", "0.2.3"), ("size", "21.0 MB")],
);
let missing = catalog.render(Key::SettingsUpdateMissingArtifact, &[("version", "0.2.3")]);
let failed = catalog.render(
Key::SettingsUpdateFailed,
&[("reason", "取更新清单失败:连接被重置")],
);
let all = [&up_to_date, &available, &missing, &failed];
for (index, left) in all.iter().enumerate() {
for right in all.iter().skip(index + 1) {
assert_ne!(left, right, "两条结论说的是同一句话:{left} / {right}");
}
}
assert!(
failed.contains("连接被重置"),
"失败那一句必须带出原因,实际:{failed}"
);
assert!(
!failed.contains("0.2.2"),
"失败那一句不该冒出当前版本号(读起来像「已是最新」):{failed}"
);
}
/// 页面视图模型与 presenter 的判据同序:就位 > 进行中 > 失败(仅主动查) > 有新版本。
///
/// 两处各算一次(presenter 判据 + 页面视图),顺序必须一致,否则同一份状态在提示条与设置页上
/// 会各说一套。这里只钉页面这一侧的顺序;presenter 那一侧在 `presenter` 的 `dto::update` 单测里。
#[test]
fn the_banner_priority_is_ready_then_applying_then_failure_then_available() {
let mut update = Update {
current_version: "0.2.2".to_string(),
channel: "stable".to_string(),
auto_check: true,
status: UpdateStatus::Failed {
reason: "连不上".to_string(),
retryable: true,
},
checked_at: String::new(),
applying: true,
ready: None,
skipped: None,
silent: false,
};
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Applying)
));
update.applying = false;
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Failed { .. })
));
update.status = UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1024,
notes: String::new(),
forced: false,
};
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Available { .. })
));
update.ready = Some("0.2.3".to_string());
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
/// 演示模型里更新组能画(`pages_demo` 的 harness 路径):`Model.update` 有默认值,不是 `None` 才画。
#[test]
fn the_demo_model_carries_an_update_snapshot() {
let model = crate::pages::demo_model(Screen::Settings, 1180.0);
assert_eq!(model.update, crate::pages::update::demo());
assert!(
model.update.available().is_some(),
"演示快照要带一个可装的版本"
);
// 其它页不掺演示更新状态(实例页/下载页的基线图不该多出一条提示条)。
assert_eq!(
crate::pages::demo_model(Screen::Instances, 1180.0).update,
Update::default()
);
let catalog: Catalog = Lang::En.catalog();
assert!(!catalog.text(Key::SettingsGroupUpdate).is_empty());
}
@@ -0,0 +1,273 @@
//! 设置页「更新」分组(M159):当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次结果。
//!
//! ## 为什么这一组不走 `Group.body`
//!
//! `Group.body` 的签名是 `fn(&Ui, States) -> Option<Element>`,而 `States` 只有「外观状态 + 各组
//! 自己的状态」两格。更新状态是 **presenter 快照**(不是某一组的字段),与 M106 的语言行同一种
//! 情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的 `update` 形参交给
//! [`body`],`GROUP.body` 仍是 `None`。
//!
//! 消息也**不**走 `GroupMessage`:更新动作是「跨页的一次性意图」(检查 / 下载安装 / 忽略 /
//! 重启),落点是 `app/update/update_action.rs`,`pages::Message::Update(UpdateAction)` 一支就够。
//! 偏离已登记在本文件与 mission 报告里(与 M106 的 `Message::Language` 同一取舍:这两个都不是
//! 「某一组的内部状态」,折进 `GroupMessage` 只会多一层没人用的转运)。
//!
//! ## 四条状态行必须分开
//!
//! 「已是最新」「有新版本」「没有本平台产物」「检查失败」是四种不同的结论(mission 硬要求:
//! 失败不许说成最新)。第四种一定要把原因摆出来(连不上 / 清单坏了 / 渠道不符),
//! 否则用户只会看到一句「检查失败」而无从下手。
use iced::widget::{column, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::human_bytes;
use crate::app::components::buttons::{self, Button};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::text_role_owned;
use crate::pages::update::{Update, UpdateStatus};
use crate::pages::Message;
use crate::pages::UpdateAction;
use crate::theme::Ui;
use super::frame::{card, divider};
use super::Group;
/// 「更新」分组在 [`super::GROUPS`] 里的下标(**必须是最后一个**:加组只追加不重排)。
pub(super) const UPDATE_GROUP: usize = 4;
/// 更新分组(左列名固定英文:左列是导航,与导航轨同源,不进 catalog)。
pub(super) const GROUP: Group = Group {
name: "Updates",
icon: IconKind::Download,
// 只给 harness / 窄窗「按组堆叠」路径用的演示行:产品路径走 [`body`]。
rows: &[
("Current version", "0.2.2"),
("Channel", "Stable"),
("Check for updates on startup", "On"),
],
body: None,
};
/// 渲染整组正文。
pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(0.0).width(Length::Fill);
stacked = stacked.push(info_row(
ui,
catalog.text(Key::SettingsUpdateCurrent),
update.current_version.clone(),
));
stacked = stacked.push(divider(ui));
stacked = stacked.push(info_row(
ui,
catalog.text(Key::SettingsUpdateChannel),
channel_label(catalog, &update.channel),
));
stacked = stacked.push(divider(ui));
stacked = stacked.push(auto_check_row(ui, catalog, update.auto_check));
stacked = stacked.push(divider(ui));
stacked = stacked.push(status_block(ui, catalog, update));
card(ui, stacked)
}
/// 渠道显示名:`stable` 走 key,其它渠道原样上屏(D50 现在只有 stable,但开 beta/alpha 时
/// 页面不该跟着改——渠道是数据,不是写死的字面量)。
fn channel_label(catalog: Catalog, channel: &str) -> String {
match channel {
"stable" => catalog.text(Key::SettingsUpdateChannelStable).to_string(),
other => other.to_string(),
}
}
/// 「标签 + 值」一行(与 `frame::setting_row` 同一套排版,但值要在调用处现算)。
fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Message> {
let height = ui.metrics().control_height + Space::X2.dp();
row![
iced::widget::container(text_role_owned(
ui,
TypeRole::BodyMedium,
false,
label.to_string(),
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.align_y(Alignment::Center),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
value,
ui.color(Role::OnSurfaceVariant),
),
]
.height(height)
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 自动检查开关 + 「立即检查」按钮。
fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, auto_check: bool) -> Element<'a, Message> {
row![
chip(
ui,
Chip::filter(
catalog.text(Key::SettingsUpdateAutoCheck),
auto_check,
Message::Update(UpdateAction::SetAutoCheck(!auto_check)),
),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::SettingsUpdateCheckNow),
Message::Update(UpdateAction::CheckNow),
),
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(iced::Padding::from([0.0_f32, 0.0]))
.into()
}
/// 上次检查时间 + 结论 + (有候选时)下载按钮 + 说明摘要 + 一句校验提示。
fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element<'a, Message> {
let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
let checked = if update.checked_at.trim().is_empty() {
catalog.text(Key::SettingsUpdateNever).to_string()
} else {
update.checked_at.clone()
};
block = block.push(info_row(
ui,
catalog.text(Key::SettingsUpdateLastChecked),
checked,
));
// 下载/校验/替换期间先说这一句:设置页的按钮在这一刻也点不动(见下面 `applying` 分支),
// 用户得知道「它正在干活」而不是「点了没反应」。
let (text, error) = if update.applying {
(catalog.text(Key::SettingsUpdateApplying).to_string(), false)
} else {
status_line(catalog, update)
};
let color = ui.color(if error {
Role::Error
} else {
Role::OnSurfaceVariant
});
block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color));
if let Some((_version, _size, notes, forced)) = update.available().filter(|_| !update.applying)
{
let mut actions = row![buttons::filled(
ui,
Button::new(
catalog.text(Key::SettingsUpdateApply),
Message::Update(UpdateAction::Apply),
),
)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if forced {
actions = actions.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsUpdateForced).to_string(),
ui.color(Role::Error),
));
}
block = block.push(actions);
if !notes.trim().is_empty() {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(Key::SettingsUpdateNotes, &[("notes", notes.trim())]),
ui.color(Role::OnSurfaceVariant),
));
}
}
if let Some(version) = &update.ready {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(Key::SettingsUpdateReady, &[("version", version)]),
ui.color(Role::OnSurface),
));
block = block.push(buttons::tonal(
ui,
Button::new(
catalog.text(Key::UpdateBannerRestartNow),
Message::Update(UpdateAction::RestartNow),
),
));
}
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsUpdateHint).to_string(),
ui.color(Role::OnSurfaceVariant),
));
block.into()
}
/// 一条状态行(文案 + 是不是「错」那一类)。
///
/// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。
fn status_line(catalog: Catalog, update: &Update) -> (String, bool) {
let (key, args, error) = match &update.status {
UpdateStatus::Idle => (Key::SettingsUpdateNever, Vec::new(), false),
UpdateStatus::Checking => (Key::SettingsUpdateChecking, Vec::new(), false),
UpdateStatus::UpToDate { latest } => {
let version = latest
.clone()
.unwrap_or_else(|| update.current_version.clone());
(
Key::SettingsUpdateUpToDate,
vec![("version", version)],
false,
)
}
UpdateStatus::Available { version, size, .. } => (
Key::SettingsUpdateAvailable,
vec![("version", version.clone()), ("size", human_bytes(*size))],
false,
),
UpdateStatus::MissingArtifact { version } => (
Key::SettingsUpdateMissingArtifact,
vec![("version", version.clone())],
true,
),
UpdateStatus::Failed { reason, .. } => (
Key::SettingsUpdateFailed,
vec![("reason", reason.clone())],
true,
),
};
let args: Vec<(&str, &str)> = args
.iter()
.map(|(name, value)| (*name, value.as_str()))
.collect();
(catalog.render(key, &args), error)
}
+4 -2
View File
@@ -54,10 +54,11 @@ fn screens_declare_the_layout_their_page_needs() {
#[test]
fn every_screen_builds_at_the_three_comparison_widths() {
let ui = ui();
let form = demo_launch_form();
for width in [1180.0, 900.0, 700.0] {
for screen in Screen::ALL {
let model = demo_model(screen, width);
let _element: Element<'_, Message> = app(&ui, &model);
let _element: Element<'_, Message> = app(&ui, &model, &form);
}
}
}
@@ -66,9 +67,10 @@ fn every_screen_builds_at_the_three_comparison_widths() {
#[test]
fn an_empty_snapshot_still_builds() {
let ui = ui();
let form = demo_launch_form();
for screen in Screen::ALL {
let model = Model::new(screen, 1180.0, Some(screen.nav()));
let _element: Element<'_, Message> = app(&ui, &model);
let _element: Element<'_, Message> = app(&ui, &model, &form);
}
}
+391
View File
@@ -0,0 +1,391 @@
//! 自更新(M159)的**页面视图模型**:只放「要画什么」。
//!
//! 与 `presenter::dto::UpdateView` 的分工和本目录其它页一致(见 `pages/model.rs` 模块文档):
//! DTO 是 IPC 契约(含 core 语义字段),这里是纯展示字段,于是页面可以用演示数据喂同一个渲染函数。
//! 转换点只有一个:`app/view/update.rs`。
//!
//! ## 为什么状态是「一个有载荷的枚举」
//!
//! 「检查失败」与「已是最新」必须是两个变体(mission 硬要求):把失败折叠成
//! `UpToDate` 会让界面在断网时说「已是最新」,那是撒谎。`MissingArtifact`(有新版但没本平台产物)
//! 同理——它既不是「有新版本可装」,也不是「已是最新」。
/// 一次检查的结论(页面视图)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum UpdateStatus {
/// 还没查过
#[default]
Idle,
/// 正在查
Checking,
/// 已是最新
UpToDate {
/// 该渠道内的最高版本(`None` = 还没有任何发布)
latest: Option<String>,
},
/// 有可装的更新
Available {
/// 新版本号
version: String,
/// 包体字节数
size: u64,
/// 更新说明(可能为空)
notes: String,
/// 是否强制
forced: bool,
},
/// 有更高版本,但没有本平台的产物
MissingArtifact {
/// 那个版本
version: String,
},
/// 检查/应用失败(**不是**已是最新)
Failed {
/// 人话原因
reason: String,
/// 换个时间再试是否可能变好
retryable: bool,
},
}
/// 顶部提示条此刻的形态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Banner<'a> {
/// 有新版本
Available {
/// 新版本号
version: &'a str,
/// 包体字节数
size: u64,
/// 说明摘要
notes: &'a str,
},
/// 正在下载/校验
Applying,
/// 已就位,等重启
Ready {
/// 新版本号
version: &'a str,
},
/// 失败(只在用户主动查之后出现)
Failed {
/// 人话原因
reason: &'a str,
/// 是否可重试
retryable: bool,
},
}
/// 更新页 / 提示条的全部展示状态。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Update {
/// 当前版本
pub current_version: String,
/// 渠道(`stable`)
pub channel: String,
/// 启动时是否静默检查
pub auto_check: bool,
/// 检查结论
pub status: UpdateStatus,
/// 上次检查时间(空串 = 没查过)
pub checked_at: String,
/// 正在下载/校验/替换
pub applying: bool,
/// 已就位的新版本号(`None` = 没有)
pub ready: Option<String>,
/// 用户点了「忽略这个版本」时记下的**版本号**(`None` = 没忽略)。
///
/// 只压住**这一个**版本:0.2.3 被忽略后同一次运行里冒出 0.2.4 仍然提示
/// (文案是「忽略这个版本」,不是「别再问我」)。
pub skipped: Option<String>,
/// 这一份结论来自静默检查(启动时/自动):失败时**不**弹提示条
pub silent: bool,
}
impl Update {
/// 有没有可装的候选(有的话设置页给「下载并安装」)。
#[must_use]
pub fn available(&self) -> Option<(&str, u64, &str, bool)> {
match &self.status {
UpdateStatus::Available {
version,
size,
notes,
forced,
} => Some((version, *size, notes, *forced)),
_ => None,
}
}
/// 提示条此刻要显示什么(`None` = 不显示)。
///
/// 优先级与 presenter 侧同一条(就位 > 进行中 > 失败(仅主动查) > 有新版本(未被忽略)),
/// 这里再算一次是因为页面层拿不到 DTO 的那份判据——两处判据的顺序必须一致,
/// `pages/tests.rs` 有对拍用例守着。
#[must_use]
pub fn banner(&self) -> Option<Banner<'_>> {
if let Some(version) = &self.ready {
return Some(Banner::Ready { version });
}
if self.applying {
return Some(Banner::Applying);
}
if let UpdateStatus::Failed { reason, retryable } = &self.status {
return (!self.silent).then_some(Banner::Failed {
reason,
retryable: *retryable,
});
}
match &self.status {
UpdateStatus::Available {
version,
size,
notes,
..
} if self.skipped.as_deref() != Some(version.as_str()) => Some(Banner::Available {
version,
size: *size,
notes,
}),
_ => None,
}
}
}
/// 出图 / 单测用的演示快照(一个可装的新版本 + 一次成功的检查)。
///
/// **不是产品数据**:产品路径的更新状态只来自 presenter(`app/view/update.rs` 转换)。
#[must_use]
pub fn demo() -> Update {
Update {
current_version: "0.2.2".to_string(),
channel: "stable".to_string(),
auto_check: true,
status: UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 21_500_000,
notes: "修掉启动崩溃;实例页加载更快".to_string(),
forced: false,
},
checked_at: "11-08 12:00".to_string(),
applying: false,
ready: None,
skipped: None,
silent: false,
}
}
/// 「有新版本」那条提示条的正文:**体积与更新说明摘要都要给**(mission 明列的三件之二)。
///
/// 拆成函数是为了可断言(`Element` 没有内省接口):条数与措辞由 `tests` 的两条用例钉住。
#[must_use]
pub fn available_body(catalog: Catalog, size: u64, notes: &str) -> String {
let size = human_bytes(size);
let body = catalog.render(Key::UpdateBannerBodyAvailable, &[("size", &size)]);
if notes.trim().is_empty() {
body
} else {
format!("{body} · {}", notes.trim())
}
}
// --------------------------------------------------------------- 提示条
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::human_bytes;
use crate::app::components::buttons::{self, Button};
use crate::pages::frame::text_role_owned;
use crate::pages::message::UpdateAction;
use crate::pages::Message;
use crate::theme::Ui;
/// 顶部提示条(每一页都在页头之上;`None` = 现在没有要说的)。
///
/// 形态三种:有新版本(可下载 / 可忽略)、正在下载、已就位(可立即重启)。**失败不在这里**
/// ——静默检查失败是常态,弹一条红条等于每次开机先骂网络一次;失败原因在设置页「更新」区看。
pub fn banner<'a>(
ui: &'a Ui,
update: &'a Update,
catalog: Catalog,
) -> Option<Element<'a, Message>> {
let kind = update.banner()?;
// 三种形态各自的底色:有新版 = 次级容器(可操作、非错误);进行中 = 同色但只读;
// 就位 = 主容器(「已经好了,重启即生效」是正反馈)。
let (title, body, actions, role): (String, String, Vec<Element<'a, Message>>, Role) = match kind
{
Banner::Available {
version,
size,
notes,
} => {
let mut actions = vec![
buttons::filled(
ui,
Button::new(
catalog.text(Key::UpdateBannerInstall),
Message::Update(UpdateAction::Apply),
),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerDismiss),
Message::Update(UpdateAction::Dismiss),
),
),
];
let _ = &mut actions;
(
catalog.render(Key::UpdateBannerTitleAvailable, &[("version", version)]),
available_body(catalog, size, notes),
actions,
Role::SecondaryContainer,
)
}
Banner::Applying => (
catalog.text(Key::UpdateBannerApplying).to_string(),
String::new(),
Vec::new(),
Role::SecondaryContainer,
),
Banner::Ready { version } => (
catalog.text(Key::UpdateBannerTitleReady).to_string(),
catalog.render(Key::UpdateBannerBodyReady, &[("version", version)]),
vec![buttons::filled(
ui,
Button::new(
catalog.text(Key::UpdateBannerRestartNow),
Message::Update(UpdateAction::RestartNow),
),
)],
Role::PrimaryContainer,
),
Banner::Failed { reason, .. } => (
catalog.text(Key::UpdateBannerTitleFailed).to_string(),
reason.to_string(),
vec![buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerRetry),
Message::Update(UpdateAction::CheckNow),
),
)],
Role::ErrorContainer,
),
};
let on_container = match role {
Role::PrimaryContainer => Role::OnPrimaryContainer,
Role::ErrorContainer => Role::OnErrorContainer,
_ => Role::OnSecondaryContainer,
};
let mut text_column = column![text_role_owned(
ui,
TypeRole::LabelLarge,
true,
title,
ui.color(on_container),
)]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !body.is_empty() {
text_column = text_column.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
body,
ui.color(on_container),
));
}
let mut line = row![text_column]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center);
for action in actions {
line = line.push(action);
}
let padding = Padding::from([ui.space(Space::X3), ui.space(Space::X4)]);
Some(
container(line)
.width(Length::Fill)
.padding(padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(role))),
border: Border {
radius: ui.radius(ShapeScale::Medium, 0.0),
..Border::default()
},
..container::Style::default()
})
.into(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_i18n::Lang;
/// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。
#[test]
fn the_available_banner_carries_both_size_and_notes() {
for lang in [Lang::En, Lang::ZhCn] {
let body = available_body(lang.catalog(), 21_500_000, "修掉启动崩溃");
assert!(body.contains("20.5 MB"), "{lang:?} 缺体积:{body}");
assert!(body.contains("修掉启动崩溃"), "{lang:?} 缺说明:{body}");
}
// 发布没写说明时只留体积,不留一个空尾巴。
let bare = available_body(Lang::En.catalog(), 1024, " ");
assert!(bare.contains("1.0 KB"), "{bare}");
assert!(!bare.contains('·'), "没有说明时不该留分隔符:{bare}");
}
/// 视图模型的提示条判据与 presenter 同序(就位 > 进行中 > 失败 > 新版本)。
#[test]
fn the_banner_priority_matches_the_presenter() {
let mut update = Update {
status: UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
..Update::default()
};
assert!(matches!(update.banner(), Some(Banner::Available { .. })));
update.skipped = Some("0.2.3".to_string());
assert!(update.banner().is_none(), "忽略之后不再打扰");
// 忽略的是**那一个版本**:同一次运行里冒出 0.2.4 仍然提示(与 presenter 同一语义)。
update.status = UpdateStatus::Available {
version: "0.2.4".to_string(),
size: 1,
notes: String::new(),
forced: false,
};
assert!(
matches!(update.banner(), Some(Banner::Available { version, .. }) if version == "0.2.4"),
"0.2.3 被忽略不该压住 0.2.4"
);
update.skipped = None;
update.applying = true;
assert!(matches!(update.banner(), Some(Banner::Applying)));
update.ready = Some("0.2.3".to_string());
assert!(matches!(update.banner(), Some(Banner::Ready { .. })));
}
}
+2 -2
View File
@@ -22,10 +22,10 @@ path = "src/lib.rs"
# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线,
# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。
[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 双投影,
# 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 运行时)。
tokio.workspace = true
serde.workspace = true
+47 -5
View File
@@ -14,6 +14,9 @@ pub const fn general() -> &'static str {
命令:
instances list 列出实例与已安装版本
instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P]
[--backend default|opengl|vulkan] [--clear]
读/写实例级启动参数(不带选项 = 只读;--clear = 全部回跟随全局)
install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
下载并安装版本本体(--loader fabric|quilt|neoforge|forge
时再叠一层加载器)→ 登记实例;--dry-run 只出计划
@@ -34,11 +37,13 @@ pub const fn general() -> &'static str {
config set <key> <value> 写配置(走 setting schema 校验)
java list 列出可用 Java(含每个 JDK 的实际落点)
java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版;
--vendor temurin|oracle|microsoft|zulu 钉死一家)
java vendors 列出四家发行版的镜像/支持版本/当前优先级
--vendor temurin|oracle|microsoft|zulu|termux 钉死一家)
java vendors 列出各发行版的镜像/支持版本/当前优先级
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
agent config [--port N] 打印 Claude Code / Cursor 的配置样例
update check 查一次更新(只读:不动任何文件;失败退出码 1)
update apply [--dest <path>] 下载 → SHA256 校验 → 替换(缺省替换本进程 exe)
version 版本 + 系统摘要(同 --version)
help [命令] 本帮助
@@ -69,7 +74,24 @@ pub fn topic(name: &str) -> Option<&'static str> {
version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串
注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine / liteloader 只能由别的
启动器装好后继承进来,install --loader 不接受这两个值;kind=vanilla 只表示 id 里没有加载器
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。",
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。
%prog% instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P] \
[--backend default|opengl|vulkan] [--clear]
读/写实例配置页「启动参数」区(M157)。不带取写选项时只读:
--jvm <text> 实例级 JVM 参数(空白分隔、支持引号);与全局 java.extra_args **叠加**,
实例这层在最高优先级段(同名选项实例赢,`-Xmx` 也由它赢过内存字段)
--game <text> 实例级游戏参数(追加在命令行最后,可覆盖前面一切游戏参数)
--memory <MB> 实例级最大堆(256~65536);留空/不写 = 跟随全局 java.default_memory_mb
--java <path> 实例级 Java 可执行文件;留空 = 跟随全局 java.selected_path 再退到自动挑
--backend <id> default(不下发)/ opengl / vulkan;只对 MC 26.2-snapshot-2+ 下发,
老版本会被版本门槛拦下(启动时给诊断告警,不静默)
--clear 先把五项清空再应用其它选项(= 全部回「跟随全局」)
每一项都是「实例 → 全局 → 默认」的独立回落,只填一项不会顶掉别的项。
值以 `--` 开头时(多数游戏参数都是)写成 `--game=--fullscreen`:单独一个 `--fullscreen`
会被当成下一个选项(缺少取值),这是全项目 CLI 的既有解析口径。
--json 输出 {\"data\":{\"launch\":{…实例层…},\"effective\":{…回落后的生效值…}, …}}",
),
"pack" => Some(
"\
@@ -91,6 +113,24 @@ pub fn topic(name: &str) -> Option<&'static str> {
退出码:0 成功 / 3 部分成功 / 1 失败(2 内部错误)。
--json:信封恒为 {\"ok\":true,\"data\":{…}},分流看 data.status 与 data.error.code;
data.stages 列出实际走过的阶段。",
),
"update" => Some(
"%prog% update check
%prog% update apply [--dest <path>]
自更新(D50:GitHub Releases 固定清单地址,先只发 stable)。
check 查一次更新,只读:报告当前版本、渠道、有没有新版(版本号/体积/说明摘要)。
四种结论分开报,不折叠:up_to_date / available / missing_artifact(有新版但没本平台
产物,属发布侧漏项)/ 失败(退出码 1,code=update_check_failed)。
apply 先复查一次(清单只有几 KB),有新版才下载 → 长度 + SHA256 双检 → 原子替换。
校验不符或替换被占用时**不会**留下暂存文件、也不会破坏现有 exe(自动回滚)。
缺省替换本进程的 exe:**下次启动生效**(正在跑的这一份仍是旧版本)。
--dest 钉到别的路径(取证 / 自定义安装位置)。不写 = 本进程 exe。
环境变量 ULL_UPDATE_DIR=<dir>:改走本地目录源(`<dir>/stable.json` + 产物),
用于内网手工分发与「发现新版本」这条分支的整环取证;不设就走 GitHub。
--json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。",
),
"install" => Some(
"\
@@ -157,7 +197,7 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
并打印每个 JDK 的实际落点(managed 项带目录)
%prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
%prog% java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序
%prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
@@ -168,7 +208,9 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms)
→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序
(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。
每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 --json 的 chain 块与人读的「降级:」行。",
Termux 上缺省先用系统包(pkg install openjdk-<major>,支持 17/21/25),失败再走归档链;
--vendor termux 只走系统包。每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进
--json 的 chain 块与人读的「降级:」行。",
),
"agent" => Some(
"\
+2 -1
View File
@@ -13,7 +13,8 @@ pub mod program;
pub use model::{
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, PackImportArgs,
DiagArgs, Globals, InstallArgs, InstanceLaunchArgs, Invocation, LaunchArgs, LogsArgs,
PackImportArgs, UpdateApplyArgs, UpdateCheckArgs,
};
pub use parse::parse;
+49
View File
@@ -26,6 +26,8 @@ pub enum Command {
Help(Option<String>),
/// `instances list`。
InstancesList,
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
InstancesLaunchArgs(InstanceLaunchArgs),
/// `install <version> [--loader X] [--name N]`。
Install(InstallArgs),
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
@@ -67,6 +69,10 @@ pub enum Command {
},
/// `agent serve`:对外 MCP server(长驻)。
AgentServe(AgentServeArgs),
/// `update check`:查一次更新,不动任何文件。
UpdateCheck(UpdateCheckArgs),
/// `update apply [--dest <path>]`:下载 → 校验 → 替换(默认替换本进程 exe)。
UpdateApply(UpdateApplyArgs),
/// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。
AgentConfig {
/// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。
@@ -74,6 +80,17 @@ pub enum Command {
},
}
/// `update check` 的参数(现在没有选项,留一个结构体以免将来加选项时改签名)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateCheckArgs {}
/// `update apply` 的参数。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateApplyArgs {
/// 替换目标(缺省 = 本进程的 exe;取证/自定义安装位置时才给)。
pub dest: Option<PathBuf>,
}
/// `agent serve` 的参数(命令行只做覆盖,默认值来自设置键 `agent.mcp.*`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentServeArgs {
@@ -185,6 +202,38 @@ pub struct DiagArgs {
pub tail: usize,
}
/// `instances launch-args` 的参数(M157:实例级启动参数)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InstanceLaunchArgs {
/// 实例 id 或实例名。
pub instance: String,
/// 实例级 JVM 参数(空白分隔,支持引号)。
pub jvm_args: Option<String>,
/// 实例级游戏参数。
pub game_args: Option<String>,
/// 实例级最大堆(MiB 文本;空串由 `--memory ""` 表达「跟随全局」)。
pub memory_mb: Option<String>,
/// 实例级 Java 可执行文件。
pub java: Option<String>,
/// 图形后端(`default` / `opengl` / `vulkan`)。
pub backend: Option<String>,
/// 先清空五项再应用给出的项(`--clear`;不带其它选项时等于「全部跟随全局」)。
pub clear: bool,
}
impl InstanceLaunchArgs {
/// 这次调用是不是纯读取(没有任何写入口)。
#[must_use]
pub fn is_read_only(&self) -> bool {
!self.clear
&& self.jvm_args.is_none()
&& self.game_args.is_none()
&& self.memory_mb.is_none()
&& self.java.is_none()
&& self.backend.is_none()
}
}
/// `config get` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigGetArgs {
+4 -15
View File
@@ -9,6 +9,8 @@ use crate::error::CliError;
mod accounts;
mod agent;
mod instances;
mod update;
use super::model::{
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
@@ -81,7 +83,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
match name.as_str() {
"version" => Ok(Command::Version),
"help" => Ok(Command::Help(tail.first().cloned())),
"instances" => parse_instances(tail),
"instances" => instances::parse_instances(tail),
"install" => parse_install(tail, globals),
"pack" => parse_pack(tail),
"launch" => parse_launch(tail),
@@ -92,6 +94,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
"java" => parse_java(tail),
"accounts" => accounts::parse_accounts(tail),
"agent" => agent::parse_agent(tail),
"update" => update::parse_update(tail),
other => {
Err(CliError::usage(format!("未知命令 `{other}`"))
.with_hint("%prog% --help 查看命令列表"))
@@ -99,20 +102,6 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
}
}
fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some(other) => {
return Err(CliError::usage(format!(
"instances 的子命令只有 list,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &[], &[])?;
ensure_no_positionals(&parsed, "instances list")?;
Ok(Command::InstancesList)
}
fn parse_install(tail: &[String], globals: &Globals) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?;
let version = require_single_positional(&parsed, "install <version>")?;
+45
View File
@@ -0,0 +1,45 @@
//! `instances` 的两个子命令解析(`list` / `launch-args`)。
//!
//! 从 `parse.rs` 拆出来只为守 400 行硬线(M157 的 `launch-args` 让那边到 415 行);
//! 纯搬移,语义未变。
use super::super::model::InstanceLaunchArgs;
use super::*;
pub(super) fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some("launch-args") => {
return parse_instances_launch_args(tail.get(1..).unwrap_or_default())
}
Some(other) => {
return Err(CliError::usage(format!(
"instances 的子命令只有 list / launch-args,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &[], &[])?;
ensure_no_positionals(&parsed, "instances list")?;
Ok(Command::InstancesList)
}
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
///
/// 五个取值选项都接受空串(`--jvm ""`)= 该清空,与 `--clear` 的「先清后改」互补。
fn parse_instances_launch_args(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(
tail,
&["jvm", "game", "memory", "java", "backend"],
&["clear"],
)?;
let instance = require_single_positional(&parsed, "instances launch-args <instance>")?;
Ok(Command::InstancesLaunchArgs(InstanceLaunchArgs {
instance,
jvm_args: parsed.value("jvm"),
game_args: parsed.value("game"),
memory_mb: parsed.value("memory"),
java: parsed.value("java"),
backend: parsed.value("backend"),
clear: parsed.has("clear"),
}))
}
+46
View File
@@ -0,0 +1,46 @@
//! `ull update` 子命令的解析(从 `parse.rs` 拆出,避免解析器主文件顶到 400 行)。
//!
//! 语法:
//! - `ull update check`
//! - `ull update apply [--dest <path>]`
//!
//! 两条命令都只是「造一个 `core::update::Job` → 调 `check` / `apply` → 排版输出」,
//! 判定(版本比较、渠道准入、清单校验)与替换(双检 + 原子改名)全在 core。
//! `--dest` 钉替换目标,缺省是本进程的 exe(取证 / 自定义安装位置时才给)。
//!
//! ## 为什么单独一个文件
//!
//! `parse.rs` 在加进这两条之后是 406 行,撞仓库 400 行硬线(`guards.yml` 的 `file-size`
//! 作业按 `git ls-files '*.rs'` + `wc -l > 400` 判红)。拆法与 `parse/{accounts,agent,
//! instances}.rs` 一致:一个子命令族一个文件,主文件只留分发。
use std::path::PathBuf;
use super::{ensure_no_extra, ensure_no_positionals, parse_flags};
use crate::cli::model::{Command, UpdateApplyArgs, UpdateCheckArgs};
use crate::error::CliError;
/// 解析 `update ...`。
pub(super) fn parse_update(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("update 需要子命令 check|apply")
.with_hint("%prog% update check / %prog% update apply"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"check" => {
ensure_no_extra(rest, "update check")?;
Ok(Command::UpdateCheck(UpdateCheckArgs {}))
}
"apply" => {
let parsed = parse_flags(rest, &["dest"], &[])?;
ensure_no_positionals(&parsed, "update apply")?;
Ok(Command::UpdateApply(UpdateApplyArgs {
dest: parsed.value("dest").map(PathBuf::from),
}))
}
other => Err(CliError::usage(format!(
"update 的子命令只有 check|apply,收到 `{other}`"
))),
}
}
+269
View File
@@ -0,0 +1,269 @@
//! `instances launch-args <instance>`(M157):实例级启动参数的读/写。
//!
//! 五项的**含义与回落规则**都在 core([`unlucky_core::game::launch_overrides`]),本命令只做
//! 三件事:找实例、把命令行选项拼成 [`LaunchOverridesPatch`]、把生效值印出来。
//!
//! ```text
//! ull instances launch-args 生存 # 读:实例层 + 生效值 + 后端门槛
//! ull instances launch-args 生存 --jvm "-XX:+UseG1GC" --backend vulkan
//! ull instances launch-args 生存 --memory 8192 --game "--fullscreen"
//! ull instances launch-args 生存 --clear # 五项全部回「跟随全局」
//! ```
//!
//! 判定「版本够不够 Vulkan」用 core 的组合判据
//! [`unlucky_core::launch::overrides::backend_allowed`](继承解析后的库表 + 版本门槛),
//! 与 GUI 灰化、拼装链的告警是同一个函数——CLI 不另写一条。
//!
//! 全局层(`java.extra_args` / `java.default_memory_mb` / `java.selected_path`)在这里读一次
//! 设置文档填 [`GlobalLaunchDefaults`];presenter 的 host 侧有一份同形的读法(两个 crate 各自
//! 依赖 core,没法共享那三行 IO),字段口径以 core 的回落表为准。
use serde_json::json;
use unlucky_core::game::install::instance::{
read_instance_config, write_instance_config, InstanceConfig,
};
use unlucky_core::game::launch_overrides::{
resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverridesPatch, ResolvedLaunch,
};
use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::game_args::GRAPHICS_BACKEND_MIN_VERSION;
use unlucky_core::launch::game_version::GameVersion;
use unlucky_core::launch::overrides::backend_allowed;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::InstanceLaunchArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
use super::instances::read_profiles;
/// `ull instances launch-args`。
///
/// # Errors
///
/// 实例不存在、值非法(内存越界 / 未知后端)、实例文件读写失败时返回用户错误。
pub fn run(ctx: &Ctx, args: &InstanceLaunchArgs) -> Result<Report, CliError> {
let profiles = read_profiles(ctx)?;
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance)
.cloned()
.ok_or_else(|| {
CliError::user("not_found", format!("找不到实例 `{}`", args.instance))
.with_hint("用 `%prog% instances list` 看已登记的实例")
})?;
let id = profile.id.as_str().to_string();
let layout = ctx.layout();
let mut config = read_instance_config(&layout, &id)
.map_err(|error| CliError::user("instance_config", format!("读实例设置失败:{error}")))?
.unwrap_or_else(|| InstanceConfig::new(&id, &profile.version_id));
let mut updated = false;
if !args.is_read_only() {
config.launch = config
.launch
.patched(&patch_from(args))
.map_err(CliError::usage)?;
write_instance_config(&layout, &config).map_err(|error| {
CliError::user("instance_config", format!("写实例设置失败:{error}"))
})?;
updated = true;
}
let effective = resolve(Some(&config.launch), &global_defaults(ctx));
let (allowed, mc_version) = backend_gate(ctx, &profile.version_id);
let layer = &config.launch;
let data = json!({
"instance_id": id,
"name": profile.name,
"version_id": profile.version_id,
"updated": updated,
"launch": {
"jvm_args": layer.jvm_args.clone().unwrap_or_default(),
"game_args": layer.game_args.clone().unwrap_or_default(),
"memory_mb": layer.memory_mb,
"java_path": layer.java_path.as_ref().map(|path| path.display().to_string()),
"graphics_backend": layer.graphics_backend.unwrap_or_default().as_str(),
},
"effective": {
"jvm_args": effective.instance_jvm_args.clone().unwrap_or_default(),
"global_jvm_args": effective.global_jvm_args.clone().unwrap_or_default(),
"game_args": effective.game_args.clone().unwrap_or_default(),
"memory_mb": effective.memory_mb,
"java_path": effective.java_path.as_ref().map(|path| path.display().to_string()),
"graphics_backend": effective.graphics_backend.as_str(),
"graphics_backend_allowed": allowed,
"graphics_backend_min_version": GRAPHICS_BACKEND_MIN_VERSION,
"mc_version": mc_version,
},
"graphics_backend_options": GraphicsBackend::IDS,
});
let mut human = vec![format!(
"{}({} · {}){}",
profile.name,
profile.version_id,
id,
if updated { " ← 已保存" } else { "" }
)];
human.push(format!(
" JVM 参数(实例): {}",
layer
.jvm_args
.clone()
.unwrap_or_else(|| "(跟随全局)".to_string())
));
human.push(format!(
" 游戏参数(实例): {}",
layer
.game_args
.clone()
.unwrap_or_else(|| "(无)".to_string())
));
human.push(format!(
" 最大内存: {} MiB{}",
effective.memory_mb,
match layer.memory_mb {
Some(_) => "(实例覆盖)",
None => "(跟随全局 / 出厂默认)",
}
));
human.push(format!(
" Java: {}",
effective
.java_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "(自动挑:托管 > JAVA_HOME > PATH)".to_string())
));
human.push(format!(
" 图形后端: {}{}",
effective.graphics_backend.as_str(),
if effective.graphics_backend.is_default() {
"(不下发 --graphicsBackend)".to_string()
} else if allowed {
format!("({} 可下发)", mc_version)
} else {
format!("(版本 {mc_version} 不支持,不会下发;需 MC {GRAPHICS_BACKEND_MIN_VERSION}+)")
}
));
Ok(Report::new(data, human))
}
/// 命令行选项 → core 的文本补丁(`--clear` 先清五项,随后给出的项覆盖)。
fn patch_from(args: &InstanceLaunchArgs) -> LaunchOverridesPatch {
let mut patch = if args.clear {
LaunchOverridesPatch::clear_all()
} else {
LaunchOverridesPatch::default()
};
if let Some(value) = &args.jvm_args {
patch.jvm_args = Some(value.clone());
}
if let Some(value) = &args.game_args {
patch.game_args = Some(value.clone());
}
if let Some(value) = &args.memory_mb {
patch.memory_mb = Some(value.clone());
}
if let Some(value) = &args.java {
patch.java_path = Some(value.clone());
}
if let Some(value) = &args.backend {
patch.graphics_backend = Some(value.clone());
}
patch
}
/// 全局层(三个设置键)。
pub(crate) fn global_defaults(ctx: &Ctx) -> GlobalLaunchDefaults {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let text = |key: &str| -> Option<String> {
store
.doc()
.effective_str(key)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
GlobalLaunchDefaults {
jvm_args: text("java.extra_args"),
game_args: None,
memory_mb: store
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok()),
java_path: text("java.selected_path").map(std::path::PathBuf::from),
graphics_backend: None,
}
}
/// 三级回落后的生效值(启动装配用;`instance_id` 为空 = 没有实例档案,只用全局层)。
///
/// 与 presenter 的 `host::launch_args::resolved` 同义:那边给 GUI 用,这边给 `ull launch` 用,
/// 规则都在 core 的 [`resolve`],两处只剩「读哪几份文件」的差别。
pub(crate) fn resolved(ctx: &Ctx, instance_id: &str) -> ResolvedLaunch {
let overrides = if instance_id.is_empty() {
None
} else {
read_instance_config(&ctx.layout(), instance_id)
.ok()
.flatten()
.map(|config| config.launch)
};
resolve(overrides.as_ref(), &global_defaults(ctx))
}
/// 版本能不能用 `--graphicsBackend`,以及判定依据的游戏版本(继承链根)。
fn backend_gate(ctx: &Ctx, version_id: &str) -> (bool, String) {
let game_layout = ctx.game();
let Ok(text) = std::fs::read_to_string(game_layout.version_json(version_id)) else {
return (false, version_id.to_string());
};
let Ok(manifest) = VersionJson::from_json_str(&text) else {
return (false, version_id.to_string());
};
let mc_version = root_version_id(&game_layout, &manifest);
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(game_layout.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
let resolved = game::resolve(&source, &manifest).unwrap_or(manifest);
let allowed = backend_allowed(&GameVersion::parse(&mc_version), &resolved);
(allowed, mc_version)
}
/// 继承链根(原版层)的 id——能力位与版本门限都以它为准(loader 层的 id 带加载器后缀)。
///
/// `pub(crate)`:`snapshot::prepare` 的装配(`SnapshotInput.game_version`)也要这一份口径,
/// 门槛判定(本模块的 `backend_gate`)与拼装必须用**同一个**根版本,否则会出现
/// 「UI 说可用、命令行里没有这个参数」的静默偏差。
pub(crate) fn root_version_id(
layout: &unlucky_core::game::GameLayout,
manifest: &VersionJson,
) -> String {
let mut current = manifest.id.clone().unwrap_or_default();
let mut parent = manifest.inherits_from.clone().filter(|id| !id.is_empty());
for _ in 0..16 {
let Some(next) = parent else { break };
let Ok(text) = std::fs::read_to_string(layout.version_json(&next)) else {
return next;
};
let Ok(json) = VersionJson::from_json_str(&text) else {
return next;
};
current = json.id.clone().unwrap_or(next);
parent = json.inherits_from.clone().filter(|id| !id.is_empty());
}
current
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,212 @@
//! [`super`] 的单测:走真入口 [`crate::run`],断言用户/脚本拿到的那份输出。
//!
//! 口径与 `instances/tests.rs` 一致:临时 `--data-dir` + 真 `profiles.json`,只看 stdout 的
//! JSON 信封(`--json`)与退出码。回落规则的真值表在 core 的
//! `game::launch_overrides` / `launch::overrides` 测试里,这里只验 CLI 有没有如实搬运。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use unlucky_core::platform::layout::{LauncherLayout, LAUNCHER_HOME_DIR};
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-instance-launch-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn arg(&self) -> String {
self.0.display().to_string()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 写 `.ULL/config/profiles.json` 并返回第一个实例的 id。
fn write_profiles(data_root: &Path, name: &str, version_id: &str) -> String {
let dir = data_root.join(LAUNCHER_HOME_DIR).join("config");
std::fs::create_dir_all(&dir).expect("建配置目录");
let id = "11111111-2222-4333-8444-555555555555".to_string();
let document = json!({
"schema_version": 1,
"profiles": [{ "id": id, "name": name, "version_id": version_id }],
});
std::fs::write(
dir.join("profiles.json"),
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
)
.expect("写 profiles.json");
id
}
/// 写 `<data>/.minecraft/versions/<id>/<id>.json`(库表可带 vulkan)。
fn write_version(data_root: &Path, id: &str, vulkan: bool) {
let path = data_root
.join(".minecraft")
.join("versions")
.join(id)
.join(format!("{id}.json"));
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
let libs = if vulkan {
r#"[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]"#
} else {
"[]"
};
std::fs::write(&path, format!(r#"{{"id":"{id}","libraries":{libs}}}"#)).expect("写版本 JSON");
}
/// 跑一次 CLI,返回(退出码, stdout)。
fn run_cli(args: &[&str]) -> (i32, String) {
let owned: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
let code = crate::run(&owned, &mut out, &mut err);
(code, String::from_utf8_lossy(&out).into_owned())
}
/// 跑 `instances launch-args … --json`,返回 `data` 载荷。
fn launch_args(dir: &TempDir, rest: &[&str]) -> Value {
let root = dir.arg();
let mut args = vec!["instances", "launch-args", "--json", "--data-dir", &root];
args.extend_from_slice(rest);
let (code, stdout) = run_cli(&args);
assert_eq!(code, 0, "{stdout}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["ok"], true, "{stdout}");
value["data"].clone()
}
#[test]
fn read_reports_instance_layer_and_effective_values() {
let dir = TempDir::new("read");
let id = write_profiles(dir.path(), "生存", "1.20.4");
let data = launch_args(&dir, &[&id]);
assert_eq!(data["instance_id"], id.as_str());
assert_eq!(data["updated"], false);
assert_eq!(data["launch"]["graphics_backend"], "default");
// 没配内存 ⇒ 生效值是出厂默认 2048(三级回落的最后一级)。
assert_eq!(data["effective"]["memory_mb"], 2048);
assert_eq!(data["effective"]["graphics_backend_allowed"], false);
assert_eq!(
data["graphics_backend_options"],
json!(["default", "opengl", "vulkan"])
);
}
#[test]
fn write_from_flags_then_read_back() {
let dir = TempDir::new("write");
let id = write_profiles(dir.path(), "生存", "26.2-snapshot-2");
write_version(dir.path(), "26.2-snapshot-2", true);
let data = launch_args(
&dir,
&[
&id,
"--jvm=-XX:+UseG1GC",
// 值以 `--` 开头时必须用 `--key=value` 形式(`--game --fullscreen` 会被解析器
// 当成「缺少取值」——那是全项目 CLI 的既有口径,见 help 里的提示)。
"--game=--fullscreen",
"--memory",
"6144",
"--backend",
"vulkan",
],
);
assert_eq!(data["updated"], true);
assert_eq!(data["launch"]["jvm_args"], "-XX:+UseG1GC");
assert_eq!(data["launch"]["memory_mb"], 6144);
assert_eq!(data["launch"]["graphics_backend"], "vulkan");
// 26.2-snapshot-2 过了门槛(判定来自 core 的能力位 + 版本门限)。
assert_eq!(data["effective"]["graphics_backend_allowed"], true);
// 再读一次:落盘之后依然在(`--json` 是事实上的对外契约)。
let again = launch_args(&dir, &[&id]);
assert_eq!(again["updated"], false);
assert_eq!(again["launch"]["jvm_args"], "-XX:+UseG1GC");
assert_eq!(again["launch"]["game_args"], "--fullscreen");
}
#[test]
fn clear_restores_follow_the_global() {
let dir = TempDir::new("clear");
let id = write_profiles(dir.path(), "生存", "1.20.4");
launch_args(&dir, &[&id, "--memory", "4096", "--backend", "opengl"]);
let cleared = launch_args(&dir, &[&id, "--clear"]);
assert_eq!(cleared["launch"]["memory_mb"], Value::Null);
assert_eq!(cleared["launch"]["graphics_backend"], "default");
assert_eq!(cleared["effective"]["memory_mb"], 2048);
}
#[test]
fn invalid_values_fail_with_a_usage_error() {
let dir = TempDir::new("invalid");
let id = write_profiles(dir.path(), "生存", "1.20.4");
let root = dir.arg();
for (flag, value) in [
("--backend", "vulkan2"),
("--memory", "4g"),
("--memory", "1"),
] {
let (code, stdout) = run_cli(&[
"instances",
"launch-args",
"--json",
"--data-dir",
&root,
&id,
flag,
value,
]);
assert_ne!(code, 0, "{flag} {value} 应当失败:{stdout}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["error"]["code"], "usage", "{stdout}");
}
// 失败的写入不许留下半截设置文件。
let layout = LauncherLayout::new(dir.path().join(LAUNCHER_HOME_DIR));
assert!(
unlucky_core::game::install::instance::read_instance_config(&layout, &id)
.expect("读")
.is_none(),
"非法补丁不该落盘"
);
}
#[test]
fn unknown_instance_is_a_user_error() {
let dir = TempDir::new("missing");
let root = dir.arg();
let (code, stdout) = run_cli(&[
"instances",
"launch-args",
"--json",
"--data-dir",
&root,
"nope",
]);
assert_ne!(code, 0);
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["error"]["code"], "not_found", "{stdout}");
}
+10 -2
View File
@@ -8,7 +8,7 @@
use serde_json::{json, Value};
use unlucky_core::download::MirrorTable;
use unlucky_core::game::java::{vendor, JdkVendor};
use unlucky_core::game::java::{termux, vendor, JdkVendor};
use super::java::{region_profile, JavaSettings};
use crate::context::Ctx;
@@ -23,7 +23,11 @@ use crate::output::Report;
pub fn vendors(ctx: &Ctx) -> Result<Report, CliError> {
let settings = JavaSettings::load(ctx);
let domestic = region_profile(ctx).is_domestic();
let order = vendor::order(&settings.priority, domestic);
let mut order = vendor::order(&settings.priority, domestic);
// Termux 上系统包是一条真实可用的发行版:不进默认回退序,但展示时要让人看见。
if termux::available() && !order.contains(&JdkVendor::Termux) {
order.insert(0, JdkVendor::Termux);
}
let table = MirrorTable::builtin();
let rows: Vec<Value> = order
.iter()
@@ -114,6 +118,10 @@ fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value {
.zulu()
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
.unwrap_or_default(),
JdkVendor::Termux => termux::MIRRORS
.iter()
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
.collect(),
};
json!({
"rank": index + 1,
+16 -3
View File
@@ -5,6 +5,7 @@ pub mod agent;
pub mod config;
pub mod identity;
pub mod install;
pub mod instance_launch;
pub mod instances;
pub mod java;
pub mod java_launch;
@@ -14,6 +15,7 @@ pub mod meta;
pub mod pack;
pub mod sessions;
pub mod snapshot;
pub mod update;
use crate::cli::{Command, Globals, Invocation};
use crate::context::Ctx;
@@ -32,6 +34,9 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
Command::Version => Ok(Outcome::new(meta::version())),
Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))),
Command::InstancesList => with_ctx(&invocation.globals, instances::list),
Command::InstancesLaunchArgs(args) => {
with_ctx(&invocation.globals, |ctx| instance_launch::run(ctx, args))
}
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
Command::PackImport(args) => {
with_ctx_outcome(&invocation.globals, |ctx| pack::run(ctx, args))
@@ -53,6 +58,12 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
java::install(ctx, *major, *vendor)
}),
Command::JavaVendors => with_ctx(&invocation.globals, java::vendors),
Command::UpdateCheck(args) => {
with_ctx_outcome(&invocation.globals, |ctx| update::check(ctx, args))
}
Command::UpdateApply(args) => {
with_ctx_outcome(&invocation.globals, |ctx| update::apply(ctx, args))
}
Command::AccountsList => with_ctx(&invocation.globals, accounts::list),
Command::AccountsAdd(args) => with_ctx(&invocation.globals, |ctx| accounts::add(ctx, args)),
Command::AccountsRemove { name } => {
@@ -84,10 +95,12 @@ pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError {
AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()),
// M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路:
// 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。
// M149:官方发布构建已把 id 烘进二进制(真值走仓库 Secret,不进 git 文件),所以能走到这个
// hint 的只可能是自建且没注入 id 的二进制——文案与 `cli/help.rs` 对齐:官方构建已内置。
AuthError::Unsupported(_) => Some(format!(
"先配 Azure 公共客户端 id(id 不进仓库):\
`{env}=<id> %prog% accounts add msa`;\
想让二进制自带就构建时注入 `{env}=<id> cargo build`。环境变量优先于构建期注入。",
"官方构建已内置微软登录所需的客户端 id,装完即可用;自建构建得自己给:\
运行期 `{env}=<id> %prog% accounts add msa`(优先于内置值);\
或构建时注入 `{env}=<id> cargo build` 让二进制自带。",
env = unlucky_core::auth::microsoft::CLIENT_ID_ENV
)),
AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
+132 -6
View File
@@ -75,18 +75,38 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
let mut libraries: Vec<LibraryInput> = Vec::new();
for library in resolved.libraries.as_deref().unwrap_or_default() {
if !library.applies(&environment) || library.native_classifier(&environment).is_some() {
if !library.applies(&environment) {
continue;
}
let Ok(coordinate) = library.coordinate() else {
// 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath;
// ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。
// Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。
let arm64 = library.linux_arm64_native_name(&environment);
if arm64.is_none() && library.native_classifier(&environment).is_some() {
continue;
}
let (coordinate, name) = match arm64 {
Some(rewritten) => {
let Ok(coordinate) =
unlucky_core::game::library::MavenCoordinate::parse(&rewritten)
else {
continue;
};
(coordinate, rewritten)
}
None => {
let Ok(coordinate) = library.coordinate() else {
continue;
};
(coordinate, library.name.clone())
}
};
let path = if library.is_local() {
game.version_library(&version_id, &coordinate)
} else {
game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, library.name.clone()));
libraries.push(LibraryInput::new(path, name));
}
// natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。
@@ -111,11 +131,14 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
.clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
// 同 presenter:装配门限按**继承链的 MC 根版本**判(loader 实例的 version_id 带加载器后缀,
// 周快照的 6 字符形态判据在那儿失效,`--graphicsBackend` 会被静默跳过)。
let root_version = super::instance_launch::root_version_id(&game, &manifest);
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&input.instance_name),
version_type: None,
game_version: None,
game_version: Some(root_version.as_str()),
main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game,
@@ -131,11 +154,21 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
game_assets: assets_root,
};
// 实例级启动参数的三级回落(实例 → 全局 → 默认)。临时启动参数(`--max-memory` /
// `--java`)在 D20 的覆盖链里位于实例之上,所以这里先把它们叠上去再落位。
let mut effective = super::instance_launch::resolved(ctx, &input.instance_id);
if let Some(mb) = input.max_memory {
effective.memory_mb = mb;
}
if let Some(path) = &input.java {
effective.java_path = Some(path.clone());
}
let options = AdapterOptions {
java: super::java::selection(input.java.as_deref()),
java: super::java::selection(effective.java_path.as_deref()),
memory: MemoryOptions {
min_mb: None,
max_mb: input.max_memory,
max_mb: Some(effective.memory_mb),
},
auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders {
player_name: input
@@ -161,6 +194,8 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
request.instance.id = input.instance_id.clone();
}
request.instance.name = input.instance_name.clone();
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
unlucky_core::launch::overrides::apply(&mut request, &effective);
Ok(Plan {
version_id,
@@ -203,3 +238,94 @@ fn describe_warning(warning: &AdapterWarning) -> String {
}
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
/// 一次性临时数据根(`--data-dir` 的等价物:custom 模式 → `<root>/.ULL` + `<root>/.minecraft`)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-cli-snapshot-{tag}-{}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn ctx_at(dir: &TempDir) -> Ctx {
let globals = crate::cli::Globals {
data_dir: Some(dir.path().to_path_buf()),
..crate::cli::Globals::default()
};
Ctx::resolve(&globals).expect("解析数据根")
}
fn write_version(ctx: &Ctx, id: &str, json: &str) {
let path = ctx.game().version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
/// **周快照 + 加载器实例**(M157 r3 复审 P2-1):`prepare` 交给装配的游戏版本是
/// 继承链根的 `26w14a`,不是 `26w14a-fabric-0.16.5`。
///
/// 抓得住:`SnapshotInput.game_version` 回退成 `None`(装配用带后缀的 id 判门限,
/// `--graphicsBackend` 会被静默跳过,而 CLI 自己的门槛显示「可下发」)。
/// 抓不住:完整 argv(`crates/core/src/launch/overrides/tests.rs` 的周快照用例)。
#[test]
fn weekly_loader_instance_is_assembled_against_the_mc_root_version() {
let dir = TempDir::new("weekly");
let ctx = ctx_at(&dir);
write_version(
&ctx,
"26w14a",
r#"{"id":"26w14a","mainClass":"net.minecraft.client.main.Main",
"libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#,
);
write_version(
&ctx,
"26w14a-fabric-0.16.5",
r#"{"id":"26w14a-fabric-0.16.5","inheritsFrom":"26w14a",
"mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient"}"#,
);
let plan = prepare(
&ctx,
&PlanInput {
version_id: "26w14a-fabric-0.16.5".to_string(),
instance_name: "周快照 Fabric".to_string(),
..PlanInput::default()
},
)
.expect("装配应成功");
assert_eq!(
plan.request.instance.version.raw(),
"26w14a",
"装配的游戏版本必须是继承链的 MC 根版本(CLI 门槛与拼装必须同一口径)"
);
assert!(plan.request.instance.version.is_weekly_snapshot());
assert!(plan.request.manifest.has_vulkan_backend);
}
}
+218
View File
@@ -0,0 +1,218 @@
//! `ull update check` / `ull update apply`(M159):**复用 core 的同一内核**。
//!
//! 两条命令都只做「解析参数 → 造一个 [`Job`] → 调 `check` / `apply` → 排版输出」,
//! 版本比较、渠道准入、清单校验、长度/SHA256 双检、原子替换全在 `core::update`
//! (GUI 走的是同一个 `Job`,见 `crates/presenter/src/host/update.rs`)。
//!
//! 三条不许折叠的边界:
//!
//! | 情形 | 输出 |
//! |---|---|
//! | 已是最新 | `status=up_to_date`(退出码 0) |
//! | 有可装的更新 | `status=available`(退出码 0;`apply` 才动文件) |
//! | 检查失败 | **错误**(退出码 1,`code=update_check_failed`)——绝不说成「已是最新」 |
//!
//! 还有一条单独报的:`missing_artifact`(有更高版本但里面没有本平台的产物)。那是发布侧的漏项,
//! 同样不许说成「已是最新」。
//!
//! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use unlucky_core::update::{default_source, Applied, CheckOutcome, Job, UpdateError, Version};
use crate::cli::{UpdateApplyArgs, UpdateCheckArgs};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{Outcome, Report};
/// D50:先只发 stable。
const CHANNEL: unlucky_core::update::Channel = unlucky_core::update::Channel::Stable;
/// 本进程版本(与 `ull --version` 同源)。
fn current_version() -> Result<Version, CliError> {
let text = env!("CARGO_PKG_VERSION");
Version::parse(text)
.map_err(|error| CliError::internal("bad_version", format!("编译期版本号不合法:{error}")))
}
/// 造一个内核 job(源 = GitHub / `ULL_UPDATE_DIR`,暂存 = 数据根缓存,目标 = 本进程 exe)。
fn job(ctx: &Ctx, dest: Option<PathBuf>) -> Job {
let engine = super::install::build_engine(ctx);
let job = Job::for_process(default_source(Arc::new(engine)), &ctx.layout().cache());
match dest {
Some(dest) => job.with_dest(dest),
None => job,
}
}
/// 失败 → CLI 错误(`code` 说「哪条命令的哪一类失败」,`detail.kind` 说**到底为什么**)。
///
/// `--json` 信封示例(复审 P3-3 之后的样子):
///
/// ```json
/// {"ok":false,"error":{"code":"update_apply_failed","kind":"user","message":"应用更新失败(digest_mismatch):…",
/// "hint":"…","detail":{"kind":"digest_mismatch","retryable":true}}}
/// ```
///
/// 为什么要两层:`code` 是命令级的(脚本据此决定「要不要提示用户重跑」),`detail.kind` 是
/// **底层原因**(`digest_mismatch` / `network` / `invalid_manifest` / `io` …,据此决定「重试有没有意义」)。
/// 以前原因只写在人读 message 里,脚本不解析散文就分不出来。
fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
let hint = if error.is_retryable() {
"网络/占用类失败,过一会儿重试一次通常就好"
} else {
"这不是瞬时失败:清单或版本号本身有问题,等发布侧修"
};
CliError::user(
code,
format!("{action}失败({}):{error}", error.kind.as_str()),
)
.with_detail(json!({
"kind": error.kind.as_str(),
"retryable": error.is_retryable(),
}))
.with_hint(hint)
}
/// `ull update check`:只查、不动任何文件。
///
/// # Errors
///
/// 清单取不到 / 清单不合法 / 渠道不符 → 用户错误(退出码 1)。
pub fn check(ctx: &Ctx, _args: &UpdateCheckArgs) -> Result<Outcome, CliError> {
let current = current_version()?;
let job = job(ctx, None);
let outcome = job
.check(&current, CHANNEL)
.map_err(|error| failure("update_check_failed", "检查更新", &error))?;
Ok(Outcome::new(report_of(&current, job.source_id(), &outcome)))
}
/// `ull update apply`:复查 → 下载 → 双检 → 替换(失败自动清理/回滚)。
///
/// # Errors
///
/// 复查、取包、校验、替换任一步失败 → 用户错误(退出码 1)。
pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
let current = current_version()?;
let job = job(ctx, args.dest.clone());
let dest = job.dest().display().to_string();
match job
.apply(&current, CHANNEL)
.map_err(|error| failure("update_apply_failed", "应用更新", &error))?
{
Applied::Installed { receipt, version } => {
let backup = receipt
.backup
.as_ref()
.map(|path| path.display().to_string());
let mut human = vec![
format!("已下载并校验 v{version}({} 字节)", receipt.bytes),
format!("已替换:{dest}"),
];
human.push(match &backup {
Some(backup) => format!("旧版本备份:{backup}"),
None => "原本没有旧文件(首次落盘)".to_string(),
});
human.push("下次启动生效(正在运行的进程仍是旧版本)。".to_string());
Ok(Outcome::new(Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"status": "installed",
"version": version,
"path": dest,
"bytes": receipt.bytes,
"backup": backup,
}),
human,
)))
}
Applied::Nothing(outcome) => {
let report = report_of(&current, job.source_id(), &outcome);
Ok(Outcome::new(Report::new(
json!({ "status": "nothing_to_apply", "check": report.data }),
{
let mut human =
vec!["复查之后没有可应用的更新(已经不是最新版本冲突)".to_string()];
human.extend(report.human);
human
},
)))
}
}
}
/// 把检查结论排成「人读几行 + `--json` 一份」。
fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report {
let base = |status: &str| {
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"status": status,
})
};
match outcome {
CheckOutcome::UpToDate { latest, .. } => {
let latest = latest.as_ref().map(ToString::to_string);
let mut data = base("up_to_date");
data["latest"] = json!(latest);
Report::new(
data,
vec![match &latest {
Some(latest) => format!("已是最新:v{current}(渠道 {latest} 内最高)"),
None => format!("已是最新:v{current}(该渠道还没有任何发布)"),
}],
)
}
CheckOutcome::Available(candidate) => {
let version = candidate.release.version.to_string();
let forced = candidate.urgency == unlucky_core::update::Urgency::Forced;
let notes = candidate.release.notes.clone().unwrap_or_default();
let mut human = vec![format!(
"发现新版本 v{version}(当前 v{current},{} 字节{})",
candidate.artifact.size,
if forced { ",强制更新" } else { "" }
)];
if !notes.trim().is_empty() {
human.push(format!("说明:{}", notes.trim()));
}
human.push("用 `%prog% update apply` 下载并应用(下次启动生效)".to_string());
Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"status": "available",
"version": version,
"size": candidate.artifact.size,
"forced": forced,
"notes": notes,
"target": candidate.artifact.target.as_str(),
}),
human,
)
}
CheckOutcome::MissingArtifact { release } => {
let version = release.version.to_string();
Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"status": "missing_artifact",
"version": version,
"target": unlucky_core::update::Target::current().as_str(),
}),
vec![format!(
"v{version} 已发布,但没有为本平台({})提供产物——这是发布侧漏项,不是「已是最新」",
unlucky_core::update::Target::current().as_str()
)],
)
}
}
}
+33
View File
@@ -54,6 +54,17 @@ pub struct CliError {
code: &'static str,
message: String,
hint: Option<String>,
/// `--json` 信封里 `error.detail` 的结构化载荷(可选)。
///
/// 为什么要有它:`code` 只分到「哪条命令的哪一类失败」(`update_apply_failed`),
/// 而脚本真正要分流的是**底层原因**(`digest_mismatch` / `network` / `invalid_manifest`)。
/// 那些原因以前只出现在人读 `message` 里(`应用更新失败(digest_mismatch):…`),
/// 脚本不解析散文就分不出来。`detail` 把它们挪进机器可读字段。
///
/// **装箱**:`serde_json::Value` 有 32 字节,直接塞进 `CliError` 会把它顶到 144 字节,
/// 于是所有 `Result<_, CliError>` 都触发 `clippy::result_large_err`(阈值 128)。
/// `Option<Box<_>>` 只占 8 字节,错误对象保持轻量。
detail: Option<Box<Value>>,
}
/// 把模板里的程序名占位符渲染掉(M82 r3)。
@@ -73,6 +84,7 @@ impl CliError {
code: "usage",
message: render(message),
hint: None,
detail: None,
}
}
@@ -84,6 +96,7 @@ impl CliError {
code,
message: render(message),
hint: None,
detail: None,
}
}
@@ -95,6 +108,7 @@ impl CliError {
code,
message: render(message),
hint: None,
detail: None,
}
}
@@ -111,6 +125,22 @@ impl CliError {
self
}
/// 补一段**机器可读**的细节(`--json` 里落在 `error.detail`)。
///
/// 约定:`detail` 里只放脚本要分流的结构化字段(底层分类 id、可重试位、目标路径…),
/// 人话仍在 `message` / `hint` 里——两条出口各管一件事,别把散文塞进 `detail`。
#[must_use]
pub fn with_detail(mut self, detail: Value) -> Self {
self.detail = Some(Box::new(detail));
self
}
/// 机器可读细节(没有则为 `None`)。
#[must_use]
pub fn detail(&self) -> Option<&Value> {
self.detail.as_deref()
}
/// 错误大类。
#[must_use]
pub const fn kind(&self) -> ErrorKind {
@@ -152,6 +182,9 @@ impl CliError {
if let Some(hint) = &self.hint {
error["hint"] = Value::String(hint.clone());
}
if let Some(detail) = &self.detail {
error["detail"] = (**detail).clone();
}
json!({ "ok": false, "error": error })
}
}
+46
View File
@@ -257,6 +257,51 @@ fn java_subcommands() {
assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors);
}
#[test]
fn update_subcommands() {
use unlucky_cli::cli::{UpdateApplyArgs, UpdateCheckArgs};
assert_eq!(
invoke(&["update", "check"]).command,
Command::UpdateCheck(UpdateCheckArgs {})
);
assert_eq!(
invoke(&["update", "apply"]).command,
Command::UpdateApply(UpdateApplyArgs { dest: None })
);
// `--dest` 两种写法都要认(钉替换目标 = 取证 / 自定义安装位置)
for args in [
vec!["update", "apply", "--dest", "D:/tmp/ull.exe"],
vec!["update", "apply", "--dest=D:/tmp/ull.exe"],
] {
assert_eq!(
invoke(&args).command,
Command::UpdateApply(UpdateApplyArgs {
dest: Some(std::path::PathBuf::from("D:/tmp/ull.exe")),
})
);
}
}
/// `update` 的用法错误:没子命令 / 未知子命令 / 多余位置参数 / 未知选项——四条都是 `usage`
/// (退出码 1),不许静默退化成某个默认动作。
#[test]
fn update_usage_errors() {
for args in [
vec!["update"],
vec!["update", "install"],
vec!["update", "check", "extra"],
vec!["update", "apply", "extra"],
vec!["update", "apply", "--dest"],
vec!["update", "apply", "--nope"],
] {
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
let error = parse(&owned).expect_err(&format!("{args:?} 应当报用法错误"));
assert_eq!(error.kind(), ErrorKind::User, "{args:?}");
assert_eq!(error.code(), "usage", "{args:?}");
}
}
#[test]
fn usage_errors_are_user_errors() {
let cases: &[&[&str]] = &[
@@ -328,6 +373,7 @@ fn help_text_covers_every_command() {
"diag",
"config",
"java",
"update",
] {
assert!(
unlucky_cli::cli::help::topic(topic).is_some(),
+63 -8
View File
@@ -6,6 +6,21 @@
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md`(施工口径)。
//! 格式模型与安全闸是 M110 的 [`crate::addon::pack`]:本模块**只消费,不重写**。
//!
//! # 逐文件冲突决策(M160):预检 → 用户点头 → 导入
//!
//! | 步骤 | 入口 | 写盘吗 |
//! |------|------|--------|
//! | 预检:算出"哪几个文件会盖掉用户的" | [`scan_pack`] | **不写**(只读盘算摘要) |
//! | 用户点「全部保留 / 全部覆盖 / 逐项」 | [`ConflictDecisions`] | — |
//! | 导入 | [`import_pack`] | 写(按决策处置冲突) |
//!
//! 三条硬口径(改它们之前先看 [`scan`] 与 [`decision`] 的模块文档):
//!
//! 1. 进冲突清单的**只有**"目标里已存在同名普通文件且内容(SHA-1)不同"的路径;内容相同、
//! 目标不存在、以及用户删过的文件都不打扰用户;
//! 2. 决策按**路径**给,逐项优先于整批档,整批档缺省 = 保留;
//! 3. 缺省**绝不覆盖**:没收到决策的冲突一律保留用户那一侧([`ConflictChoice::default`])。
//!
//! # 三条硬要求落在哪
//!
//! | 要求 | 落点 |
@@ -18,7 +33,10 @@
//!
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
//! 优先级落地,不自创排序。
//! 优先级落地,不自创排序。**同一实例路径被本包多份声明命中时**(跨侧或同侧重复),
//! 逐条算清楚:胜者按上面这条规则定,被遮蔽的那条不读字节、只留一条
//! `pack.import.note.override_shadowed` 注记说明「谁赢了 / 两份内容是否逐字节相同」
//! (见 [`plan::Shadowed`])。一路径一行,不再出现"两条 Installed、前一条没落地"。
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
@@ -60,6 +78,7 @@
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
pub mod baseline;
pub mod decision;
pub mod error;
pub mod fetch;
pub mod merge;
@@ -67,9 +86,11 @@ pub mod phase;
pub mod pipeline;
pub mod plan;
pub mod report;
pub mod scan;
pub mod stage;
pub use baseline::{PackBaseline, BASELINE_FILE};
pub use decision::ConflictDecisions;
pub use error::{ImportError, ImportErrorKind};
pub use fetch::{
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
@@ -77,12 +98,13 @@ pub use fetch::{
};
pub use merge::{MergeAction, MergeDecision};
pub use pipeline::baseline_relative;
pub use plan::{ImportPlan, PlanOverride, PlanRemote, SuffixVariant};
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
pub use report::{
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
};
pub use scan::{scan_pack, ImportConflict, ImportScan};
pub use stage::{RollbackOutcome, Staging};
use std::path::Path;
@@ -90,7 +112,15 @@ use std::path::Path;
use crate::addon::pack::PackLimits;
use crate::platform::layout::LauncherLayout;
/// 冲突处置策略(默认保留用户文件)。
/// 整次导入的**缺省**冲突档(逐项决策见 [`ConflictDecisions`])。
///
/// 保留这个名字是因为 CLI 已经按它调用(`pack import --overwrite`);语义上它就是
/// [`ConflictDecisions::default_choice`] 的构造器:
///
/// - [`ConflictPolicy::KeepUser`](缺省)= 逐项决策的缺省档是"保留";
/// - [`ConflictPolicy::Overwrite`] = 逐项决策的缺省档是"覆盖"。
///
/// **逐项决策优先于它**,所以"整批覆盖 + 某几条保留"是可表达的。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConflictPolicy {
/// 保留用户那一侧(不覆盖、不补回被删的文件),冲突逐条进报告。
@@ -100,6 +130,17 @@ pub enum ConflictPolicy {
Overwrite,
}
impl ConflictPolicy {
/// 对应的逐项决策缺省档。
#[must_use]
const fn choice(self) -> ConflictChoice {
match self {
Self::KeepUser => ConflictChoice::KeepUser,
Self::Overwrite => ConflictChoice::Overwrite,
}
}
}
/// 一次导入请求。
pub struct ImportRequest<'a> {
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
@@ -112,8 +153,8 @@ pub struct ImportRequest<'a> {
pub register: Option<&'a LauncherLayout>,
/// 实例展示名(缺省用包名)。
pub instance_name: Option<&'a str>,
/// 冲突策略。
pub conflict_policy: ConflictPolicy,
/// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。
pub decisions: ConflictDecisions,
/// 包读取限额(条数 / 单条体积 / 解压总量)。
pub limits: PackLimits,
/// 进度回调。
@@ -130,7 +171,7 @@ impl<'a> ImportRequest<'a> {
fetcher: None,
register: None,
instance_name: None,
conflict_policy: ConflictPolicy::KeepUser,
decisions: ConflictDecisions::default(),
limits: PackLimits::default(),
progress: None,
}
@@ -151,10 +192,24 @@ impl<'a> ImportRequest<'a> {
self
}
/// 改冲突策略。
/// 整批缺省档(逐项决策仍然优先)。
#[must_use]
pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
self.conflict_policy = policy;
self.decisions = std::mem::take(&mut self.decisions).with_default(policy.choice());
self
}
/// 整表逐文件决策(「全部保留 / 全部覆盖 / 逐项」三条入口的产物)。
#[must_use]
pub fn with_decisions(mut self, decisions: ConflictDecisions) -> Self {
self.decisions = decisions;
self
}
/// 挂一条逐项决策(链式,便于测试与命令行逐条给)。
#[must_use]
pub fn with_decision(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
self.decisions.set(path, choice);
self
}
+182
View File
@@ -0,0 +1,182 @@
//! 逐文件冲突决策:`path → Keep / Overwrite`,以及"没收到决策就等于保留"这条硬线。
//!
//! # 三个入口,一个通道
//!
//! | 用户在界面上点的 | 落到这里的形状 |
//! |---|---|
//! | 全部保留 | [`ConflictDecisions::keep_all`](= [`ConflictDecisions::default`]) |
//! | 全部覆盖 | [`ConflictDecisions::overwrite_all`] |
//! | 逐项 | [`ConflictDecisions::with`] 一条一条挂上去,整批档仍是"保留" |
//!
//! 三者共用一个类型,是因为它们要回答的是同一个问题:「这个路径上的冲突该怎么处置」。
//! 两条独立的落盘规则迟早会打架(一处按整批档、一处按逐项表),所以合并成一处。
//!
//! # 缺省为什么必须保留
//!
//! [`ConflictChoice::default`] 是 `KeepUser`:**表里没有这个路径**、或者调用方根本没设过决策时,
//! 结论是"用户那份文件留着"。这不是保守,是正确性——导入的输入是别人做的整合包,用户的
//! 配置/存档/自己改过的 mod 不能因为"我们没问"就消失。要看"覆盖"只有一个来路:调用方
//! **显式**说了覆盖(整批档或逐项决策)。
//!
//! # 决策只作用于**冲突**
//!
//! 非冲突路径(磁盘上没有、或内容与包内一致)根本不看这张表:没有冲突就没有"保留/覆盖"这个
//! 问题,`merge::decide` 的结论照旧。于是"表里没有这条"永远不会把一份该装的文件漏掉。
use std::collections::BTreeMap;
use super::report::ConflictChoice;
/// 一次导入的逐文件决策表。
///
/// 顺序稳定(`BTreeMap`),所以同一份决策序列化出来的形状是确定的——诊断与测试都能直接比。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictDecisions {
/// 表里没点名的冲突按它处置。
default: ConflictChoice,
/// 逐项决策(实例内相对路径 → 处置)。
entries: BTreeMap<String, ConflictChoice>,
}
impl Default for ConflictDecisions {
fn default() -> Self {
Self {
// **不许改**:这是"没收到决策不覆盖"的落点。
default: ConflictChoice::KeepUser,
entries: BTreeMap::new(),
}
}
}
impl ConflictDecisions {
/// 全部保留(= 缺省)。
#[must_use]
pub fn keep_all() -> Self {
Self::default()
}
/// 全部覆盖(用户显式点了「整合包说了算」)。
#[must_use]
pub fn overwrite_all() -> Self {
Self {
default: ConflictChoice::Overwrite,
entries: BTreeMap::new(),
}
}
/// 改整批缺省档(逐项决策优先于它)。
#[must_use]
pub fn with_default(mut self, choice: ConflictChoice) -> Self {
self.default = choice;
self
}
/// 挂一条逐项决策(链式)。
#[must_use]
pub fn with(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
self.set(path, choice);
self
}
/// 挂一条逐项决策(就地)。
pub fn set(&mut self, path: impl Into<String>, choice: ConflictChoice) {
self.entries.insert(path.into(), choice);
}
/// 整批缺省档。
#[must_use]
pub const fn default_choice(&self) -> ConflictChoice {
self.default
}
/// 这个路径的处置:逐项决策优先,其次整批档(**整批档缺省是保留**)。
#[must_use]
pub fn choice_for(&self, path: &str) -> ConflictChoice {
self.entries.get(path).copied().unwrap_or(self.default)
}
/// 这个路径有没有被逐项点名(诊断用:区分"用户选的"与"缺省档")。
#[must_use]
pub fn explicit(&self, path: &str) -> Option<ConflictChoice> {
self.entries.get(path).copied()
}
/// 逐项决策的条数(不含整批缺省档)。
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
/// 有没有逐项决策。
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// 逐项决策(按路径排序;进报告/日志用)。
pub fn iter(&self) -> impl Iterator<Item = (&str, ConflictChoice)> {
self.entries
.iter()
.map(|(path, choice)| (path.as_str(), *choice))
}
/// 逐项决策里"覆盖"的条数。
#[must_use]
pub fn overwrite_count(&self) -> usize {
self.entries
.values()
.filter(|choice| **choice == ConflictChoice::Overwrite)
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_decision_never_overwrites() {
// 这张表是"没收到决策就覆盖"的唯一防线:默认档与空表的答案都必须是保留。
assert_eq!(ConflictChoice::default(), ConflictChoice::KeepUser);
let empty = ConflictDecisions::default();
assert_eq!(empty.choice_for("config/a.toml"), ConflictChoice::KeepUser);
assert_eq!(
ConflictDecisions::keep_all().default_choice(),
ConflictChoice::KeepUser
);
assert!(empty.is_empty());
}
#[test]
fn per_item_wins_over_the_batch_default() {
let decisions =
ConflictDecisions::overwrite_all().with("mods/pin.jar", ConflictChoice::KeepUser);
assert_eq!(
decisions.choice_for("mods/pin.jar"),
ConflictChoice::KeepUser
);
assert_eq!(
decisions.choice_for("mods/other.jar"),
ConflictChoice::Overwrite
);
assert_eq!(
decisions.explicit("mods/pin.jar"),
Some(ConflictChoice::KeepUser)
);
assert_eq!(decisions.explicit("mods/other.jar"), None);
assert_eq!(decisions.len(), 1);
assert_eq!(decisions.overwrite_count(), 0);
}
#[test]
fn set_is_idempotent_per_path() {
let mut decisions = ConflictDecisions::keep_all();
decisions.set("a.toml", ConflictChoice::Overwrite);
decisions.set("a.toml", ConflictChoice::KeepUser);
assert_eq!(decisions.len(), 1);
assert_eq!(decisions.choice_for("a.toml"), ConflictChoice::KeepUser);
assert_eq!(decisions.overwrite_count(), 0);
let paths: Vec<&str> = decisions.iter().map(|(path, _)| path).collect();
assert_eq!(paths, vec!["a.toml"]);
}
}
+9 -5
View File
@@ -15,10 +15,10 @@
//! | 有 | 有 | `D == B` | [`MergeAction::Install`] | 用户没动过 ⇒ 整合包升级,覆盖是对的 |
//! | 有 | 有 | `D != B` | [`MergeAction::KeepUser`] + `UserModified` | 用户改过:**不许**静默覆盖 |
//!
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:
//! `ImportRequest::conflict_policy = Overwrite` 时,`KeepUser` 才会被判据的调用方翻成
//! [`MergeAction::Install`],**判据本身不读策略**——这样"哪些情形算冲突"与"冲突怎么处置"
//! 是两个可独立验证的事实。
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:决策
//! (逐项 [`super::decision::ConflictDecisions`],或整批 [`super::ConflictPolicy`])为
//! `Overwrite` 时,`KeepUser` 才会被判据的调用方翻成 [`MergeAction::Install`],**判据本身
//! 不读决策**——这样"哪些情形算冲突"与"冲突怎么处置"是两个可独立验证的事实。
//!
//! # `.disabled` / `.old` 不参与判据
//!
@@ -76,7 +76,11 @@ pub fn decide(pack_sha1: &str, base_sha1: Option<&str>, disk_sha1: Option<&str>)
// 磁盘上没有这个文件。
(None, None) => MergeDecision::plain(MergeAction::Install),
(None, Some(_)) => MergeDecision::conflict(ConflictReason::UserDeleted),
// 磁盘上有。
// 磁盘上有、内容与包内**逐字节相同**(同一份 SHA-1)。
//
// 这是"内容相同"的**唯一判据**:预检(`super::scan::judge`)刻意不再自己写一遍
// `disk == pack` 的早退(复审 r1 P3-2),而是走这里的 `reason == None`。改这一行
// 会同时影响"导入怎么处置"与"预检列不列这条",所以两边的测试都会红。
(Some(disk), _) if disk == pack_sha1 => MergeDecision::plain(MergeAction::SkipIdentical),
(Some(_), None) => MergeDecision::conflict(ConflictReason::PreExisting),
(Some(disk), Some(base)) if disk == base => MergeDecision::plain(MergeAction::Install),
+26 -8
View File
@@ -22,6 +22,11 @@ use crate::download::verify::{self, digest_bytes};
use crate::download::HashAlgo;
/// 阶段一:覆盖条目落暂存 + 三方合并。
///
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
/// "两条 Installed、前一条的哈希其实没落地"。
pub(crate) async fn extract_overrides(
request: &ImportRequest<'_>,
archive: &PackArchive,
@@ -31,9 +36,14 @@ pub(crate) async fn extract_overrides(
report: &mut ImportReport,
next: &mut PackBaseline,
) -> 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();
for (index, entry) in plan.overrides.iter().enumerate() {
for (index, entry) in live.iter().enumerate() {
tell(
&request.progress,
ImportStage::Extract,
@@ -52,7 +62,9 @@ pub(crate) async fn extract_overrides(
next.insert(entry.path.clone(), pack_sha1.clone());
let disk = disk_sha1(&target.join(&entry.path))?;
let decision = merge::decide(&pack_sha1, baseline.sha1(&entry.path), disk.as_deref());
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
// 逐项决策优先,其次整批档(缺省是"保留"):见 `decision::ConflictDecisions`。
let picked = request.decisions.choice_for(&entry.path);
let (action, choice) = settle(decision, disk.is_some(), picked);
if let Some(reason) = decision.reason {
report.conflicts.push(conflict(
entry.path.clone(),
@@ -111,7 +123,8 @@ pub(crate) async fn fetch_remote(
// 有声明摘要就能先判合并:命中"保留用户 / 已一致"时**不必下载**(省一次网络)。
if let Some(declared) = resolved.sha1.clone() {
let decision = merge::decide(&declared, baseline.sha1(&resolved.path), disk.as_deref());
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
let picked = request.decisions.choice_for(&resolved.path);
let (action, choice) = settle(decision, disk.is_some(), picked);
if !action.wrote() {
next.insert(resolved.path.clone(), declared.clone());
if let Some(reason) = decision.reason {
@@ -175,7 +188,8 @@ pub(crate) async fn fetch_remote(
}
}
let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref());
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
let picked = request.decisions.choice_for(&resolved.path);
let (action, choice) = settle(decision, disk.is_some(), picked);
if let Some(reason) = decision.reason {
report.conflicts.push(conflict(
resolved.path.clone(),
@@ -266,13 +280,17 @@ async fn resolve(
}))
}
/// 合并判定 + 策略 → 实际动作。
/// 合并判定 + 决策 → 实际动作。
///
/// `picked` 是这一次的处置(逐项决策或整批档,见 [`super::decision::ConflictDecisions`]):
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 两个非冲突结论不受它影响,所以"用户点错
/// 一个保留"不会把一份全新文件漏掉。
fn settle(
decision: merge::MergeDecision,
disk_exists: bool,
policy: super::ConflictPolicy,
picked: ConflictChoice,
) -> (FileAction, ConflictChoice) {
let overwrite = policy == super::ConflictPolicy::Overwrite;
let overwrite = picked == ConflictChoice::Overwrite;
match decision.action {
MergeAction::Install => (
if disk_exists {
+22 -12
View File
@@ -49,7 +49,11 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
};
account_plan(&mut report, &plan);
let baseline = load_baseline(request.target, &plan, &mut report);
let baseline = {
let (baseline, warnings) = load_baseline(request.target, &plan);
report.warnings.extend(warnings);
baseline
};
let staging = match Staging::begin(request.target) {
Ok(staging) => staging,
Err(error) => return abandoned(report, error),
@@ -134,7 +138,12 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
}
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。
fn probe_and_plan(request: &ImportRequest<'_>) -> Result<(PackArchive, ImportPlan), ImportError> {
///
/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致,
/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。
pub(super) fn probe_and_plan(
request: &ImportRequest<'_>,
) -> Result<(PackArchive, ImportPlan), ImportError> {
// 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。
let archive = PackArchive::open(request.pack, request.limits)?;
let (format, manifest) = PackRegistry::builtin().read(&archive)?;
@@ -159,17 +168,18 @@ fn account_plan(report: &mut ImportReport, plan: &ImportPlan) {
///
/// "属于别的包"要单独说一句:同一目录先后导入两个整合包时,若拿别人的基线当 base,用户改过
/// 的文件会被判成"整合包升级"而**被静默覆盖**。所以身份不符时降级成"没有基线"并告警。
fn load_baseline(
///
/// 返回的告警由调用方写进报告(预检也是同一批文案——两侧对"有没有基线"必须有同一个答案)。
pub(super) fn load_baseline(
target: &std::path::Path,
plan: &ImportPlan,
report: &mut ImportReport,
) -> PackBaseline {
) -> (PackBaseline, Vec<ImportIssue>) {
match PackBaseline::load(target) {
Ok(baseline) => {
if baseline.matches_pack(&plan.format, &plan.manifest.name) {
return baseline;
return (baseline, Vec::new());
}
report.warnings.push(ImportIssue::new(
let warning = ImportIssue::new(
keys::NOTE_BASELINE_PACK_MISMATCH,
format!(
"基线属于另一个整合包({} / {:?}),本次按「没有基线」处理:磁盘上同名文件一律算用户已有内容",
@@ -180,17 +190,17 @@ fn load_baseline(
},
baseline.pack_name
),
));
PackBaseline::default()
);
(PackBaseline::default(), vec![warning])
}
Err(reason) => {
report.warnings.push(ImportIssue::new(
let warning = ImportIssue::new(
keys::NOTE_BASELINE_UNREADABLE,
format!(
"基线读不出来,本次按「没有基线」处理(磁盘上同名文件一律算用户已有内容):{reason}"
),
));
PackBaseline::default()
);
(PackBaseline::default(), vec![warning])
}
}
}
+11 -13
View File
@@ -17,19 +17,24 @@
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
//! **同一实例路径被多份声明命中时逐条算清楚**([`PlanOverride::shadowed`]):胜者按上面
//! 这条规则定,被遮蔽的那条不再读字节、只留一条 `NOTE_OVERRIDE_SHADOWED` 注记说明
//! "谁赢了"和"两份内容是否逐字节相同"。这是"整包一个策略"到"逐文件事实"的那一步。
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
//! [`paths`]。
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
mod layer;
pub mod paths;
pub use layer::Shadowed;
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant};
use super::super::pack::{
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
PackOverrides, PackSide,
PackSide,
};
use super::error::ImportError;
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
@@ -51,6 +56,8 @@ pub struct PlanOverride {
pub disabled: bool,
/// 可选注记(`.disabled` / `.old`)。
pub note: Option<ImportIssue>,
/// 被同包另一条声明遮蔽时的事实(`None` = 本条目就是这条路径的最终胜者)。
pub shadowed: Option<Shadowed>,
}
/// 一条要补全/下载的远端条目。
@@ -136,7 +143,7 @@ impl ImportPlan {
let mut overrides: Vec<PlanOverride> = Vec::new();
let mut skipped_server_files = 0_usize;
for group in ordered_overrides(&manifest) {
for group in layer::ordered(&manifest) {
if group.side == PackSide::Server {
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
@@ -153,9 +160,11 @@ impl ImportPlan {
size: file.size,
disabled: variant.is_disabled(),
note: suffix_note(variant, &file.path),
shadowed: None,
});
}
}
layer::resolve(&mut overrides, &mut notes);
if skipped_server_files > 0 {
notes.push(ImportIssue::new(
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`]。
fn select_loader(
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);
}
}
+8 -1
View File
@@ -79,7 +79,7 @@ impl ConflictReason {
}
}
/// 冲突实际采用的处置(由 [`super::super::ConflictPolicy`] 决定)。
/// 冲突实际采用的处置(由 [`super::super::ConflictPolicy`] / [`super::super::ConflictDecisions`] 决定)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictChoice {
@@ -89,6 +89,13 @@ pub enum ConflictChoice {
Overwrite,
}
impl Default for ConflictChoice {
/// **缺省是保留**:没有决策时绝不覆盖用户已有文件(M160 的硬线,见 `import::decision`)。
fn default() -> Self {
Self::KeepUser
}
}
/// 一条冲突("无法判定时给可选择的冲突清单"就是这张表)。
#[derive(Debug, Clone, Serialize)]
pub struct ConflictFile {
@@ -51,6 +51,10 @@ 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";
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
/// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。
pub const NOTE_REMOTE_PATH_UNKNOWN: &str = "pack.import.note.remote_path_unknown";
/// 缺失:调用方没给 fetcher(离线导入)。
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
@@ -94,6 +98,8 @@ pub const ALL: &[&str] = &[
NOTE_BASELINE_PACK_MISMATCH,
NOTE_ENGINE_ARTIFACTS_DROPPED,
NOTE_ROLLBACK_INCOMPLETE,
NOTE_OVERRIDE_SHADOWED,
NOTE_REMOTE_PATH_UNKNOWN,
MISSING_OFFLINE,
MISSING_NO_SOURCE,
MISSING_FETCH_FAILED,
+306
View File
@@ -0,0 +1,306 @@
//! 导入**预检**(M160):只读地算出「冲突清单」——**只列真正需要用户点头的文件**。
//!
//! # 什么进清单,什么不进(口径只此一处)
//!
//! 一个路径进清单**当且仅当**:目标实例里同名路径是一个普通文件,且它的内容与包内**不同**。
//! 等值判据是 **SHA-1**(包内那条来自 M110 读包时现算的摘要,磁盘那条现算);体积只作展示,
//! 不参与判定——同一份内容不可能有两个哈希,而"体积相同"并不说明内容相同。
//!
//! 不进清单的四种情形,每一种都有理由:
//!
//! | 情形 | 为什么不必打扰用户 |
//! |------|--------------------|
//! | 目标里没有这个文件 | 全新文件,装就是了 |
//! | 内容与包内一致 | 已经是最新,写盘是浪费 |
//! | 用户删过(基线有、磁盘没有) | 删是用户的决定,**不偷偷装回来**(`merge::decide` 的 `UserDeleted`) |
//! | 包内被同包另一条声明遮蔽(`shadowed`) | 它不落地,用户看不到它,问了也无从选 |
//!
//! # 叠层优先级不在这层重判
//!
//! `client-overrides/` 胜 `overrides/`、`server-overrides/` 客户端不解压——这些**已由**
//! [`super::plan::ImportPlan::build`] 与 `plan::layer::resolve` 算定(一路径一行,胜者才在
//! `overrides` 表里且 `shadowed.is_none()`)。本模块只按胜者取 `source`,从不自己再排一次。
//!
//! # 远端条目的两种命运
//!
//! mrpack 的 `files[]` 自带 `path` + `hashes.sha1`,所以能在这里判;判的是"**声明的**摘要 vs
//! 磁盘",与导入时"下载下来再校验"是同一份期望值。CurseForge 的 `files[]` 只有
//! `projectID/fileID`,补全前**没有路径**,无从判起 ⇒ 不猜、不进清单,只把条数记进
//! [`ImportScan::unknown_remote`](界面据此说"还有 N 条补全后才能知道会不会撞车")。计数与
//! 文案同一口径:**判不了**的两种来路(补全前没有路径 / 有路径但缺 `hashes.sha1`)都算。
//!
//! # 便宜在哪、贵在哪
//!
//! 预检不读包内字节(摘要来自 M110 的读包结果),但要给每条冲突填"目标现有体积与哈希",
//! 所以**磁盘上那些同名文件要真读一遍**。于是 `scan` + `import` 会把它们读两次——这是本轮
//! 的已知代价(要省就得让导入直接吃预检的判定结果,那是另一件事,报告里登记)。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::baseline::PackBaseline;
use super::decision::ConflictDecisions;
use super::error::ImportError;
use super::merge;
use super::pipeline::{load_baseline, probe_and_plan};
use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget};
use super::ImportRequest;
use crate::download::verify;
use crate::download::HashAlgo;
/// 一条**要用户点头**的冲突(目标里已有同名文件且内容不同)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportConflict {
/// 实例内相对路径。
pub path: String,
/// 来源(包内 `overrides/` / `client-overrides/` / mrpack 或 CF 的 `files[]`)。
pub source: FileSource,
/// 成因(`PreExisting` = 没有基线可判;`UserModified` = 基线里记过、用户改过)。
pub reason: ConflictReason,
/// 包内那条是不是禁用态(`.disabled` 后缀原样保留)。
pub disabled: bool,
/// 包内声明的 SHA-1。
pub pack_sha1: String,
/// 包内声明的字节数。
pub pack_size: u64,
/// 目标现有文件的 SHA-1。
pub disk_sha1: String,
/// 目标现有文件的字节数。
pub disk_size: u64,
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
pub base_sha1: Option<String>,
}
impl ImportConflict {
/// 覆盖它会丢掉多少字节(界面上"覆盖会丢掉 12.4 MB"就是它)。
#[must_use]
pub const fn lost_bytes(&self) -> u64 {
self.disk_size
}
}
/// 一次预检的结果。
///
/// `error` 为 `Some` 时其余字段是"没算出来":探测/开包/组计划失败与导入管线同一条口径
/// (同一个 [`PackError`] → [`ImportError`] 的映射),不去新造一套。
#[derive(Debug, Clone)]
pub struct ImportScan {
/// 目标实例目录。
pub target: PathBuf,
/// 命中的格式 id。
pub format: Option<String>,
/// 包名。
pub pack_name: Option<String>,
/// 包版本。
pub pack_version: Option<String>,
/// 游戏版本。
pub game_version: Option<String>,
/// 加载器计划输入(`None` = 纯原版)。
pub loader: Option<LoaderTarget>,
/// 冲突清单(按路径排序)。
pub conflicts: Vec<ImportConflict>,
/// 预检阶段**判不了**的远端条目数。
///
/// 判不了有两种来路,计数把两者都算上(口径与注记文案一致,复审 r1 P3-4):
/// ①CurseForge `files[]` 补全前**没有路径**(常态);②极少数 mrpack 条目**有路径但缺
/// `hashes.sha1`**,没有声明摘要就没法与磁盘现状比对。
pub unknown_remote: usize,
/// 注记(`.disabled` / 可选条目 / 基线口径 / 被遮蔽的条目……与导入报告同源)。
pub notes: Vec<ImportIssue>,
/// 开包或组计划失败(`Failed` 口径,目标目录一个字节都没动)。
pub error: Option<ImportError>,
}
impl ImportScan {
fn empty(target: PathBuf) -> Self {
Self {
target,
format: None,
pack_name: None,
pack_version: None,
game_version: None,
loader: None,
conflicts: Vec::new(),
unknown_remote: 0,
notes: Vec::new(),
error: None,
}
}
/// 预检成功了吗(`false` = 看 [`Self::error`])。
#[must_use]
pub const fn is_ok(&self) -> bool {
self.error.is_none()
}
/// 有没有需要用户点头的项(`false` = 可以直接导入,不必打扰用户)。
#[must_use]
pub fn needs_decision(&self) -> bool {
!self.conflicts.is_empty()
}
/// 「全部保留」的决策(= 缺省档,不覆盖任何用户文件)。
#[must_use]
pub fn keep_all(&self) -> ConflictDecisions {
ConflictDecisions::keep_all()
}
/// 「全部覆盖」的决策(用户显式要求"整合包说了算")。
#[must_use]
pub fn overwrite_all(&self) -> ConflictDecisions {
ConflictDecisions::overwrite_all()
}
/// 覆盖会丢掉的字节总量(界面上的计数摘要)。
#[must_use]
pub fn lost_bytes(&self) -> u64 {
self.conflicts
.iter()
.map(ImportConflict::lost_bytes)
.fold(0, u64::saturating_add)
}
/// 包内那些条目声明了多大(= 覆盖后会占用的字节数)。
#[must_use]
pub fn incoming_bytes(&self) -> u64 {
self.conflicts
.iter()
.map(|conflict| conflict.pack_size)
.fold(0, u64::saturating_add)
}
}
/// 跑一次预检:**只读**(不建目录、不写暂存、不改基线)。
#[must_use]
pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
let mut scan = ImportScan::empty(request.target.to_path_buf());
let plan = match probe_and_plan(request) {
Ok((_, plan)) => plan,
Err(error) => {
scan.error = Some(error);
return scan;
}
};
let (baseline, warnings) = load_baseline(request.target, &plan);
scan.notes = warnings;
scan.format = Some(plan.format.clone());
scan.pack_name = Some(plan.manifest.name.clone());
scan.pack_version = plan.manifest.version.clone();
scan.game_version = Some(plan.mc_version.clone());
scan.loader = plan.loader.clone();
// 一路径一行:同一路径被覆盖条目与远端条目同时声明时,只留一条(决策是按路径给的)。
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
for entry in plan
.overrides
.iter()
.filter(|entry| entry.shadowed.is_none())
{
let Some(conflict) = judge(
&entry.path,
entry.source,
&entry.sha1,
entry.size,
entry.disabled,
&baseline,
request.target,
) else {
continue;
};
seen.insert(entry.path.clone(), ());
scan.conflicts.push(conflict);
}
for entry in &plan.remote {
let (Some(path), Some(declared)) = (entry.path.as_deref(), entry.file.sha1()) else {
// 两种来路都算「判不了」:没有路径(CF 待补全)或没有声明摘要(缺 `hashes.sha1`)。
scan.unknown_remote += 1;
continue;
};
if seen.contains_key(path) {
continue;
}
let Some(conflict) = judge(
path,
entry.source,
declared,
entry.file.size.unwrap_or(0),
entry.disabled,
&baseline,
request.target,
) else {
continue;
};
seen.insert(path.to_string(), ());
scan.conflicts.push(conflict);
}
if scan.unknown_remote > 0 {
scan.notes.push(ImportIssue::new(
keys::NOTE_REMOTE_PATH_UNKNOWN,
format!(
"有 {} 条远端条目预检判不了:CurseForge files[] 补全前没有路径,个别条目有路径但没有 sha1 声明;这些条目会不会撞上已有文件只能等补全/下载后才知道,导入时仍按「缺省保留」处置",
scan.unknown_remote
),
));
}
scan.conflicts
.sort_by(|left, right| left.path.cmp(&right.path));
scan
}
/// 判一个路径:`Some` = 要用户点头的冲突。
///
/// # 「内容相同」的判据**只此一处**:[`merge::decide`]
///
/// 这里**故意不再写** `if disk.sha1 == pack_sha1 { return None }`(复审 r1 P3-2 抓到的重复判据):
/// 那种早退与 `merge::decide` 的 `SkipIdentical` 分支是同一件事的两次实现,改一处漏一处的后果
/// 就是"预检说没冲突、导入却判 SkipIdentical"或者反过来。
///
/// 现在只有一条链路:`decide` 给出 `MergeDecision`,**没有 `reason` 就不是冲突**
/// (`SkipIdentical` 与 `Install` 都是 `reason == None`),`?` 把它一步交代掉。
/// 于是"内容相同 ⇒ 不进清单"这条结论的源头是 `merge.rs` 的那张表——与导入用的是同一张。
fn judge(
path: &str,
source: FileSource,
pack_sha1: &str,
pack_size: u64,
disabled: bool,
baseline: &PackBaseline,
target: &Path,
) -> Option<ImportConflict> {
let disk = disk_state(&target.join(path))?;
// 包内那条的摘要来自 M110 读包(`override_files` 边读边算),与导入时现算的是同一份字节。
let base_sha1 = baseline.sha1(path).map(str::to_string);
// 唯一判据:`reason` 为 `None` 就是"不是冲突"(内容相同 → `SkipIdentical`;
// 磁盘上没有 → `Install`,而那一条 `disk_state` 已经先返回 `None` 了)。
let decision = merge::decide(pack_sha1, base_sha1.as_deref(), Some(&disk.sha1));
let reason = decision.reason?;
Some(ImportConflict {
path: path.to_string(),
source,
reason,
disabled,
pack_sha1: pack_sha1.to_string(),
pack_size,
disk_sha1: disk.sha1,
disk_size: disk.size,
base_sha1,
})
}
/// 磁盘现状:`None` = 那里没有普通文件(不存在或是目录)。
struct DiskState {
size: u64,
sha1: String,
}
fn disk_state(path: &Path) -> Option<DiskState> {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() {
return None;
}
let sha1 = verify::digest_file(path, HashAlgo::Sha1).ok()?;
Some(DiskState {
size: metadata.len(),
sha1,
})
}
+6 -3
View File
@@ -31,9 +31,10 @@ pub use export::{
RemoteHit,
};
pub use import::{
import_pack, ConflictFile, ConflictPolicy, ImportError, ImportErrorKind, ImportProgress,
ImportProgressSink, ImportReport, ImportRequest, ImportStage, ImportStatus, ImportedFile,
MissingFile, PackBaseline, PackFetcher, RejectedComponent,
import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictFile, ConflictPolicy,
ImportConflict, ImportError, ImportErrorKind, ImportProgress, ImportProgressSink, ImportReport,
ImportRequest, ImportScan, ImportStage, ImportStatus, ImportedFile, MissingFile, PackBaseline,
PackFetcher, RejectedComponent,
};
pub use loader::{
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
@@ -44,5 +45,7 @@ pub use pack::{
PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide,
};
#[cfg(test)]
pub(crate) mod temp;
#[cfg(test)]
mod tests;
+4 -1
View File
@@ -60,7 +60,10 @@ pub mod path;
#[cfg(test)]
mod tests;
pub use archive::{PackArchive, PackEntryInfo, PackLimits, ReadBudget};
pub use archive::{
PackArchive, PackEntryInfo, PackLimits, ReadBudget, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
};
pub use curseforge::CurseForge;
pub use error::{PackError, PackErrorKind, PackResult};
pub use format::{PackFormat, PackMatch, PackPayload, PackRegistry};
+2 -1
View File
@@ -50,7 +50,8 @@ mod open;
pub use entry::PackEntryInfo;
pub use limits::{
PackLimits, ReadBudget, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES,
DEFAULT_MAX_UNCOMPRESSED_BYTES,
DEFAULT_MAX_UNCOMPRESSED_BYTES, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
};
use limits::read_capped;
+35 -6
View File
@@ -1,5 +1,22 @@
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
//!
//! # 只此一份口径(导入读取 **与** 导出写入共用)
//!
//! [`PACK_MAX_ENTRIES`] / [`PACK_MAX_UNCOMPRESSED_BYTES`] / [`PACK_MAX_ENTRY_BYTES`] 是**唯一**
//! 的体积口径:读侧 [`PackLimits::default`] 从这里取,写侧 `pack::format::write` 的预检也从
//! 这里取(以前写侧直接拿 `zip::ZIP64_BYTES_THR`,于是"导得出来、导不回去"——导出允许 ~4 GiB,
//! 导入只允许 512 MiB)。现在的关系是硬约束:
//!
//! ```text
//! PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR ≤ u32::MAX
//! ```
//!
//! 所以「我们能写出的包一定能被自己读回来」,而 ZIP64 阈值退化成一条**不会再被触碰的**
//! 物理上限(`tests/limits.rs` 钉住这条不等式,也钉住两侧用的是同一批常数)。
//!
//! 为什么不让写侧跟着 ZIP64 放宽:读侧把单条目整条读进内存([`read_capped`]),64 MiB 是**内存**
//! 护栏;放宽它等于用一个看不见的内存峰值换一个几乎用不到的大包能力。
//!
//! # 两阶段限额(打开 vs 读取)
//!
//! 打开阶段([`check_limits`])只能拿**中央目录声明的大小**做判断——它会撒谎。
@@ -13,12 +30,24 @@ use std::io::Read;
use super::super::error::{PackError, PackResult};
/// 默认条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
pub const DEFAULT_MAX_ENTRIES: usize = 20_000;
/// 默认解压总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
/// 默认单条目上限(64 MiB;最大的 mod 也不过十几 MiB)。
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
/// 条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
pub const PACK_MAX_ENTRIES: usize = 20_000;
/// 解压/打包总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
pub const PACK_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
/// 单条目上限(64 MiB;同时是读侧的**内存**护栏与写侧的单文件护栏)。
pub const PACK_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
/// ZIP64 的物理阈值(`zip` crate 的口径,≈4 GiB)。
///
/// 生产路径**不再**用它当判据:PACK_MAX_UNCOMPRESSED_BYTES 远低于它(0.5 GiB ≪ 4 GiB),
/// 所以 `tests/limits.rs` 只需钉住"统一上限 < 本阈值",ZIP64 就永远不会被需要。
pub const PACK_ZIP64_BYTES_THR: u64 = zip::ZIP64_BYTES_THR;
/// 默认条目数上限(= [`PACK_MAX_ENTRIES`],保留旧名以免破坏既有调用点)。
pub const DEFAULT_MAX_ENTRIES: usize = PACK_MAX_ENTRIES;
/// 默认解压总体积上限(= [`PACK_MAX_UNCOMPRESSED_BYTES`])。
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = PACK_MAX_UNCOMPRESSED_BYTES;
/// 默认单条目上限(= [`PACK_MAX_ENTRY_BYTES`])。
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = PACK_MAX_ENTRY_BYTES;
/// 包读取限额。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+15 -82
View File
@@ -51,9 +51,7 @@
//! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。
use std::fmt::Debug;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::path::Path;
use super::archive::PackArchive;
use super::curseforge;
@@ -61,9 +59,9 @@ use super::error::{PackError, PackResult};
use super::manifest::PackManifest;
use super::modrinth;
use super::multimc;
use super::path::{normalize_dir, normalize_file};
mod payload;
mod write;
/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行):
/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按
@@ -116,7 +114,8 @@ pub trait PackFormat: Debug + Send + Sync {
/// 导出一个整包(清单 + 覆盖内容)。
///
/// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出;
/// ③`render` 出清单;④写进**临时文件**再 `rename` 到 `target`(失败不留半个包)。
/// ③`render` 出清单;④**先按声明尺寸预检 ZIP64 阈值**,再写进**临时文件**并 `rename` 到
/// `target`(失败不留半个包)。第④步的细节见 `format::write` 模块文档。
///
/// # 只保证同格式往返(写给 M111/M112)
///
@@ -148,8 +147,17 @@ pub trait PackFormat: Debug + Send + Sync {
manifest.check_paths()?;
let json = self.render(manifest)?;
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
let temp = temporary_path(target);
match write_container(self.id(), entry, &json, manifest, payload, &temp) {
let temp = write::temporary_path(target);
match write::container(
self.id(),
entry,
&json,
manifest,
payload,
&temp,
// 生产口径只有一处来源:导入读取用的也是这两个数(见 `archive::limits`)。
write::WriteLimits::production(),
) {
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
let _ = std::fs::remove_file(&temp);
PackError::io(format!(
@@ -166,81 +174,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)]
pub struct PackMatch {
+285
View File
@@ -0,0 +1,285 @@
//! 把模型写成 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 + 每个覆盖文件的字节数)**与单条目体积**,两个数
//! 都来自 `pack::archive::limits`([`WriteLimits::production`]):以前写侧直接拿
//! `zip::ZIP64_BYTES_THR`(≈4 GiB),导入读取只允许 512 MiB ⇒「导得出来、导不回去」。现在
//! 只此一份口径,关系是
//! `PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR`,
//! 所以 ZIP64 阈值永远不会被触碰(`pack/tests/limits.rs` 钉住这条不等式)。
//! 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,
limits: WriteLimits,
) -> PackResult<()> {
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份几百 MiB 的内容。
// **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸
// 再累加一遍 ⇒ 过拒窗口(P2-1)。
let _ = preflight(json.len() as u64, manifest, payload, limits)?;
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, limits.total);
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()`,不读内容。
///
/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器
/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半
/// 阈值就被误拒(P2-1,已有端到端用例钉住这个窗口)。
///
/// 判据顺序是**先总量、后单条**:总量超限时给出的那句"包太大"更贴用户的实际处境
/// ("把这个文件移出实例"只对单条超限才有意义)。
fn preflight(
json_len: u64,
manifest: &PackManifest,
payload: PackPayload<'_>,
limits: WriteLimits,
) -> PackResult<u64> {
// **条目数闸**(读侧同一条:`PACK_MAX_ENTRIES`)。放在读盘之前:超 2 万条时连一次
// `metadata()` 都不必做,也顺手证明"闸在碰内容之前"。清单条目自己也占一条。
let declared_entries = 1 + manifest
.overrides
.iter()
.map(|group| group.files.len())
.fold(0_usize, usize::saturating_add);
guard_entries(declared_entries, limits.entries)?;
let mut total = json_len;
let mut entries: Vec<(String, u64)> = Vec::new();
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;
entries.push((format!("{dir}/{path}"), length));
}
}
guard_with(total, limits.total)?;
for (name, length) in entries {
guard_entry(&name, length, limits.entry)?;
}
Ok(total)
}
/// 写侧的限额口径(**默认值直接取读侧那三个常量**,见 `archive::limits` 的模块文档)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct WriteLimits {
/// 未压缩总量上限。
pub(super) total: u64,
/// 单条目上限。
pub(super) entry: u64,
/// 条目数上限(**与读侧同一个数**:清单条目 + 每个覆盖文件)。
pub(super) entries: usize,
}
impl WriteLimits {
/// 生产口径:与 [`PackLimits::default`](crate::addon::pack::PackLimits) 同一个数。
pub(super) const fn production() -> Self {
Self {
total: super::super::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES,
entry: super::super::archive::limits::PACK_MAX_ENTRY_BYTES,
entries: super::super::archive::limits::PACK_MAX_ENTRIES,
}
}
/// 显式给值(测试用:把边界缩小到可测尺寸)。
#[cfg(test)]
pub(super) const fn new(total: u64, entry: u64, entries: usize) -> Self {
Self {
total,
entry,
entries,
}
}
}
/// 条目数闸:与读侧的 `PACK_MAX_ENTRIES` **同一份口径**。
///
/// 读侧在 `archive::open` 里对"中央目录条数"判 `>= limit` 就拒;写侧产出的是"清单 + N 个覆盖
/// 文件",比读侧少掉目录条目,所以这里按同一侧的口径判(`count > limit` 拒,`== limit` 许可,
/// 与读侧能读回的最大值一致)。没有这道闸时,一个内容目录超过 2 万条的实例**导得出来、
/// 自己的导入器拒读**——正是"M152 遗留的限额不衔接"那条。
fn guard_entries(count: usize, entries_limit: usize) -> PackResult<()> {
if count > entries_limit {
return Err(PackError::limits(format!(
"包内条目数 {count} 超过统一上限 {entries_limit}(导入读取用的也是这个数)"
))
.with_hint("把内容目录里多余的文件移出实例后再导出;单文件与总量上限也是同一份口径"));
}
Ok(())
}
/// 单条目闸:与读侧的 `PACK_MAX_ENTRY_BYTES` **同一份口径**。
///
/// 没有这道闸时,"导出 100 MiB 的覆盖文件 → 自己的导入器拒读"是能真实发生的(读侧把一条
/// 内容整块读进内存,所以有单条上限)。写侧照着读侧的上限拒,才不会产出自己读不回来的包。
fn guard_entry(name: &str, length: u64, entry_limit: u64) -> PackResult<()> {
if length > entry_limit {
return Err(PackError::limits(format!(
"覆盖条目 {name} 有 {length} 字节,超过单条目统一上限 {entry_limit}(导入读取用的也是这个数)"
))
.with_hint("把该文件移出实例(或换一个不需要内联它的内容目录)后再导出"));
}
Ok(())
}
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<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)
}
}
/// 总量闸(`pack_limits`):`total` **达到**上限就拒(`>=` 不是 `>`,撞上就不写)。
///
/// 生产路径的 `limit` 来自 [`WriteLimits::production`](= 读侧 `PACK_MAX_UNCOMPRESSED_BYTES`);
/// 把它做成参数是为了能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事。
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
if total >= limit {
return Err(PackError::limits(format!(
"包体未压缩总量超过统一上限 {limit} 字节(ZIP64 阈值 {} 远在其上,所以永远不需要写 ZIP64)",
super::super::archive::limits::PACK_ZIP64_BYTES_THR
))
.with_hint("把超大的覆盖文件移出实例后再导出;导入读取用的是同一个上限"));
}
Ok(())
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,150 @@
//! `format/write.rs` 的单测:**从 `write.rs` 拆出来守 400 行硬线**。
//!
//! 拆分口径与仓库其它拆分一致:**纯搬移**,断言与注释一行不改(只在外面加一层 `mod tests;`)。
//! 这些用例钉的是「闸在碰内容之前」「边界在 `>=` 上」「写循环不许重复累计」三件事。
use super::*;
use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide};
use crate::addon::temp::TempRoot;
#[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 的窗口:真实总量在 [上限一半, 上限) 之间的包**不许**被拒。这一条不真写
// 几百 MiB(那要几十秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
let limit = WriteLimits::production().total;
let inside = limit / 2 + limit / 4;
let mut written = Written::new(28, limit);
written.spend(inside).expect("没到统一上限,第二道闸不许拒");
assert!(inside > limit / 2, "样本要落在过拒窗口里");
// 对照:撞上上限必须拒,且正好在上限处(不是超一点点才拒)。
let mut written = Written::new(28, limit);
let error = written.spend(limit).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]` 用例)。
// 临时根用 `TempRoot`(drop 即删、panic 也删):这两个用例以前自己拼 `%TEMP%` 路径、
// 只在**正常收尾**时删,断言一红就留下 `ull-double-count-*`(M160 r2 的泄漏口径)。
let root = TempRoot::new("double-count");
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,
WriteLimits::new(limit, 1024, 16),
)
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
// 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。
let refused = root.join("refused.zip");
let error = container(
"curseforge",
"manifest.json",
json,
&manifest,
PackPayload::from_root(&root),
&refused,
WriteLimits::new(declared, 1024, 16),
)
.expect_err("声明总量达到 limit 必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
assert!(!refused.exists(), "预检失败时目标路径不许出现");
}
#[test]
fn the_preflight_measures_declared_sizes_not_content() {
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住,
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
let root = TempRoot::new("preflight");
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),
// 本条只关心"预检不读内容",所以三道闸都放到最宽(生产口径见 `tests/limits.rs`)。
WriteLimits::new(
crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
crate::addon::pack::archive::limits::PACK_MAX_ENTRIES,
),
)
.expect("3 GiB 总量仍在最宽闸之下,预检不许误伤");
assert_eq!(
total,
7 + 10 + 3 * 1024 * 1024 * 1024,
"清单 + 每个覆盖文件的声明尺寸"
);
}
+20 -11
View File
@@ -5,7 +5,7 @@
//! 2. 同一份包命中多格式时选谁(含 `also_matched` 记录的其它命中);
//! 3. **换一下注册顺序,赢家跟着换**——这才证明优先级来自注册表,而不是代码里散落的 if。
use super::{fixtures, temp_root, write_zip};
use super::{fixtures, write_zip, TempRoot};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
@@ -23,8 +23,9 @@ const MMC_PACK: &[u8] =
br#"{"formatVersion":1,"components":[{"uid":"net.minecraft","version":"1.20.1"}]}"#;
/// 同时带三种身份标记的"不标准包"。
fn hybrid_pack(tag: &str) -> std::path::PathBuf {
let target = temp_root(tag).join("hybrid.zip");
fn hybrid_pack(tag: &str) -> TempRoot {
// 返回的是**守卫**(Deref 到 `hybrid.zip`):包文件活到调用点用完为止。
let target = TempRoot::at(tag, "hybrid.zip");
write_zip(
&target,
&[
@@ -48,7 +49,8 @@ fn builtin_priority_is_pinned() {
#[test]
fn curseforge_wins_when_a_pack_carries_every_marker() {
let pack = PackArchive::open_default(hybrid_pack("detect-hybrid")).expect("打开混合包");
let hybrid = hybrid_pack("detect-hybrid");
let pack = PackArchive::open_default(&hybrid).expect("打开混合包");
let matched = PackRegistry::builtin().detect(&pack).expect("要能选出格式");
assert_eq!(matched.format().id(), "curseforge");
assert_eq!(
@@ -62,7 +64,8 @@ fn curseforge_wins_when_a_pack_carries_every_marker() {
#[test]
fn a_plain_manifest_json_does_not_claim_curseforge() {
// `manifest.json` 这名字太泛:形状不符就不能认领,否则 mrpack 会被抢走。
let target = temp_root("detect-plain").join("plain.zip");
let root = TempRoot::new("detect-plain");
let target = root.join("plain.zip");
write_zip(
&target,
&[
@@ -79,7 +82,8 @@ fn a_plain_manifest_json_does_not_claim_curseforge() {
#[test]
fn registration_order_decides_the_winner() {
// 同一份包同时有 CF 与 mrpack 标记:只换注册顺序,赢家就该换。
let target = temp_root("detect-order").join("both.zip");
let root = TempRoot::new("detect-order");
let target = root.join("both.zip");
write_zip(
&target,
&[
@@ -107,7 +111,8 @@ fn registration_order_decides_the_winner() {
#[test]
fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
let target = temp_root("detect-unknown").join("unknown.zip");
let root = TempRoot::new("detect-unknown");
let target = root.join("unknown.zip");
write_zip(&target, &[("readme.txt", &b"nothing to see"[..])]);
let pack = PackArchive::open_default(&target).expect("打开包");
assert!(PackRegistry::builtin().detect_all(&pack).is_empty());
@@ -126,7 +131,8 @@ fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
#[test]
fn multimc_needs_a_non_empty_component_graph() {
let target = temp_root("detect-mmc-empty").join("empty-mmc.zip");
let root = TempRoot::new("detect-mmc-empty");
let target = root.join("empty-mmc.zip");
write_zip(
&target,
&[(
@@ -165,7 +171,8 @@ fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
// r1 p3-2:清单在、但读不出来 = 包坏了(`pack_metadata`)。之前三个 detect 都用
// `.ok().flatten()` 吞掉解析错误,结果被报成「没有格式认领」——M111 的失败分级会据此
// 把"可以重下文件"的包判成"不支持"。
let target = temp_root("corrupt-manifest").join("bad.zip");
let root = TempRoot::new("corrupt-manifest");
let target = root.join("bad.zip");
write_zip(&target, &[("manifest.json", &br#"{"name": "#[..])]);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
@@ -183,7 +190,8 @@ fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
#[test]
fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
// 命中优先于失败:包里躺着一份别人的坏 manifest.json,不该让真 mrpack 也变得不可用。
let target = temp_root("corrupt-foreign").join("mixed.zip");
let root = TempRoot::new("corrupt-foreign");
let target = root.join("mixed.zip");
write_zip(
&target,
&[
@@ -202,7 +210,8 @@ fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
#[test]
fn a_missing_manifest_is_not_an_error() {
// 反向对照:**没有** `manifest.json` 是"不是 CF",不是"包坏了"。
let target = temp_root("no-manifest").join("mmc.zip");
let root = TempRoot::new("no-manifest");
let target = root.join("mmc.zip");
write_zip(
&target,
&[(
+6 -4
View File
@@ -2,7 +2,7 @@
//!
//! 这一组不测"映射是否做对"(那是 M112),只测"表达不了的时候会不会静默产出空包"。
use super::{fixtures, temp_root};
use super::{fixtures, TempRoot};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
@@ -67,7 +67,8 @@ fn curseforge_refuses_more_than_one_both_side_group() {
.overrides
.push(PackOverrides::new("extra-overrides", PackSide::Both));
let target = temp_root("export-cf-two").join("out.zip");
let root = TempRoot::new("export-cf-two");
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect_err("多出来的 Both 组会被清单漏掉,必须拒");
@@ -90,7 +91,7 @@ fn a_failed_export_leaves_neither_a_half_pack_nor_a_temp_file() {
}],
});
let root = temp_root("export-half");
let root = TempRoot::new("export-half");
std::fs::create_dir_all(&root).expect("建目录");
let target = root.join("out.zip");
let error = CURSEFORGE
@@ -142,7 +143,8 @@ fn mrpack_refuses_two_override_groups_for_the_same_side() {
.overrides
.push(PackOverrides::new("overrides", PackSide::Both));
let target = temp_root("export-mr-same-side").join("out.mrpack");
let root = TempRoot::new("export-mr-same-side");
let target = root.join("out.mrpack");
let error = MODRINTH
.write(&manifest, PackPayload::metadata_only(), &target)
.expect_err("同一侧两个覆盖目录必须拒");
@@ -5,9 +5,9 @@
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::path::Path;
use super::{temp_root, write_file};
use super::{write_file, TempRoot};
use crate::addon::export::catalog::{RemoteCatalog, RemoteHit};
use crate::addon::export::scan::{scan_instance, DEFAULT_CONTENT_ROOTS};
use crate::addon::pack::curseforge::fingerprint;
@@ -46,8 +46,9 @@ pub(super) fn write_layer(game: &Path, id: &str, json: &str) {
}
/// 一个 Fabric 实例目录:两个 mod(其中一个禁用)+ 一份 config + 一份白名单外的日志。
pub(super) fn fabric_instance(tag: &str) -> PathBuf {
let root = temp_root(tag).join("game");
pub(super) fn fabric_instance(tag: &str) -> TempRoot {
// 返回守卫本身(`Deref` 到 `<root>/game`):实例目录活到调用点用完为止,drop 即删。
let root = TempRoot::under(tag, "game");
write_layer(&root, "1.20.1", VANILLA_LAYER);
write_layer(&root, "1.20.1-fabric-0.15.11", FABRIC_LAYER);
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
@@ -58,8 +59,8 @@ pub(super) fn fabric_instance(tag: &str) -> PathBuf {
}
/// 一个 NeoForge 1.20.1 实例目录(CF `modLoaders[].id` 的 MC 前缀特例用它)。
pub(super) fn neoforge_instance(tag: &str) -> PathBuf {
let root = temp_root(tag).join("game");
pub(super) fn neoforge_instance(tag: &str) -> TempRoot {
let root = TempRoot::under(tag, "game");
write_layer(&root, "1.20.1", VANILLA_LAYER);
write_layer(&root, "1.20.1-neoforge-47.1.106", NEOFORGE_LAYER);
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
@@ -10,7 +10,7 @@ use std::path::Path;
use super::export_harness::{
defaults, digests, fabric_instance, modrinth_hit, neoforge_instance, StubCatalog,
};
use super::{temp_root, write_file};
use super::{write_file, TempRoot};
use crate::addon::export::classify::classify_with;
use crate::addon::export::scan::{is_disabled_path, scan_instance};
use crate::addon::export::{export_instance, ExportRequest, FileClass, FileReason, RemoteHit};
@@ -300,7 +300,7 @@ fn a_disabled_file_is_still_disabled_after_a_round_trip_through_the_package() {
let target = root.with_file_name("disabled.mrpack");
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("导出");
let again = temp_root("export-disabled-reimport");
let again = TempRoot::new("export-disabled-reimport");
let file = std::fs::File::open(&target).expect("打开包");
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
for index in 0..archive.len() {
@@ -17,12 +17,12 @@
//! | mrpack → mrpack | `files[]` 的条目要靠"文件真的在磁盘上"才会再写回去 | 读出来的模型里那两条是远程条目、本地没有字节;再导出时以磁盘为准 |
//! | MultiMC → mrpack | 非加载器组件(`org.lwjgl3`)不进 `dependencies` | 它不是加载器;mrpack 的 `dependencies` 只表达 loader |
use std::path::{Path, PathBuf};
use std::path::Path;
use super::export_harness::{
defaults, modrinth_hit, write_layer, StubCatalog, FORGE_LAYER, VANILLA_LAYER,
};
use super::{fixtures, temp_root, write_file};
use super::{fixtures, write_file, TempRoot};
use crate::addon::export::snapshot::{InstanceSnapshot, SnapshotLoader};
use crate::addon::export::{export_instance, ExportRequest};
use crate::addon::pack::archive::PackArchive;
@@ -34,8 +34,8 @@ use crate::addon::pack::multimc::MULTIMC;
use crate::addon::LoaderKind;
/// 把夹具里的覆盖内容铺成一个实例目录(`<game>/<path>`),并写一份版本 JSON。
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> PathBuf {
let game = temp_root(tag).join("game");
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> TempRoot {
let game = TempRoot::under(tag, "game");
for (_, file) in manifest.override_files() {
let group = manifest
.overrides
@@ -226,7 +226,7 @@ fn multimc_fixture_exports_as_mrpack_from_its_game_directory() {
assert!(MULTIMC.metadata_entry().is_none(), "MultiMC 导出不做");
// 重新打成 MultiMC 形状的目录以确认没有丢内容(把 overrides 解出来再比)。
let unpacked = temp_root("rt-mmc-unpacked");
let unpacked = TempRoot::new("rt-mmc-unpacked");
for entry in pack.entries() {
let name = entry.name();
let Some(relative) = name.strip_prefix("overrides/") else {
+4 -4
View File
@@ -6,7 +6,7 @@
use serde_json::{json, Value};
use super::{temp_root, write_file, write_zip};
use super::{write_file, write_zip, TempRoot};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::{PackError, PackErrorKind, PackResult};
@@ -87,8 +87,8 @@ impl PackFormat for ToyFormat {
}
/// 造一个 toy 包(源目录形式,顺带能当导出 payload)。
fn toy_pack(tag: &str) -> std::path::PathBuf {
let dir = temp_root(tag).join("toy");
fn toy_pack(tag: &str) -> TempRoot {
let dir = TempRoot::under(tag, "toy");
write_file(
&dir.join("toy.json"),
br#"{"name":"Toy Pack","minecraft":"1.21","mods":["mods/a.jar","mods/b.jar"]}"#,
@@ -140,7 +140,7 @@ fn a_new_format_needs_one_registration_line() {
#[test]
fn appending_a_format_never_steals_existing_packs() {
// 同一份包同时有 CF 清单与 toy 清单。
let root = temp_root("extend-priority");
let root = TempRoot::new("extend-priority");
let target = root.join("both.zip");
write_zip(
&target,
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more