Compare commits

25 Commits
Author SHA1 Message Date
AskaEth 0bfe22aa6f Merge branch 'feat/p3-release-windows-dumpbin-locator-fix-d'
Release / build (macos-x64) (push) Canceled after 0s
Release / build (macos-arm64) (push) Canceled after 0s
Release / build (linux-x64) (push) Canceled after 0s
Release / build (windows-x64) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
2026-11-01 13:06:12 +08:00
AskaEth a6013c7d22 fix(ci/release): M50 —— Windows 槽位 dumpbin 定位链改三级(vswhere → find → PATH)
v0.1.0 首跑(run 41273841717)windows-x64 在「Windows 单文件自检」挂
`找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)`。两个独立根因:

- `command -v dumpbin` 在 windows-latest 上不成立:VS 环境未预置进 PATH。
- 兜底 glob 深度错了一级:写的是 `<VS 根>/<年>/VC/Tools/...`,真实布局是
  `<VS 根>/<年>/<版次>/VC/Tools/...`(2022/Enterprise、2022/BuildTools),
  所以即便 PATH 失守、glob 也不可能命中。
- 且只扫了 64 位 `Program Files` 根,Build Tools 落在 `Program Files (x86)` 根。

改成三级定位链,全不命中才 fail(门禁语义一字未动:工具缺失仍 exit 1、
导入表出现 VCRUNTIME/MSVCP/MSVCR/ucrtbase 仍 exit 1,文案不变):

1. vswhere —— VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/布局无关:
   `.../Installer/vswhere.exe -latest -products '*' -find <pattern>`。
   模式先试反斜杠(官方文档形式)、再试正斜杠,输出统一折成正斜杠后再执行。
2. find 兜底 —— 两个 VS 根(64 位 + x86)按路径模式扫,`-maxdepth 11`
   留一级余量,不再依赖固定层数的 glob。
3. PATH —— 最后才试,PATH 最不可控。

另加一行 `dumpbin: <路径>(定位自 <层级>)`,下次真机挂掉能直接看出命中哪一级。
同文件其余步骤不依赖 VS 环境(已逐条核对),无需收敛。

验证(本机无 VS):
- 抽出 run 块 `bash -n` 通过;13 条 stub 用例全绿:无工具→fail 文案不变且不写
  dependents.txt、vswhere 反斜杠/正斜杠两个模式、两处 VS 根 find 兜底、PATH 兜底、
  动态 CRT 仍 fail-closed、vswhere 优先于 PATH、vswhere 输出的反斜杠路径归一化后可执行。
- 未改写的原始 run 块在本机实跑:干净 exit 1 + 原文案,不误命中、不崩。
- yaml 解析 + 23 条结构断言全过(矩阵四槽位、触发条件、门禁 grep/文案/exit 原样);
  文件 276 行 ≤ 400。
- git-bash 行为实证:`.exe` 的 `-x` 为真、非可执行文件为假、`find` 接受含空格的
  `C:/Program Files` 根。
真机验证由 merge 后 retag 重跑流水线完成。
2026-11-01 12:58:40 +08:00
AskaEth 37ccaaf23f Merge branch 'feat/p3-fix-game-java-discover-cfg-gating-jav'
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / build (macos-x64) (push) Canceled after 0s
Release / build (macos-arm64) (push) Canceled after 0s
Release / build (linux-x64) (push) Canceled after 0s
Release / build (windows-x64) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
2026-11-01 11:00:32 +08:00
AskaEth dc4fac8391 fix(core/game): M49 —— Java 去重键平台无关归一化(Linux CI 三红)
`game::java::discover::path_key` 只在 `cfg!(windows)` 分支折分隔符,POSIX 上
把 `C:\a\b\java.exe` 原样留成带反斜杠的键,与 `C:/a/b/java.exe` 不同键 →
`dedupe` 不去重。三条用例(Windows 语义无 cfg 门)因此在
`test (ubuntu-latest)` 挂:`path_key_normalises_separators`、
`dedupe_keeps_the_higher_priority_source`、
`java_discover::dedupe_collapses_one_install_found_twice`。

- `path_key`:`\?\` 前缀剥离与分隔符折成 `/` 改为**与平台无关**(同一个
  `bin/java[.exe]` 用哪种拼法都是同一个文件,键必须相同);大小写折叠仍只有
  Windows 做——POSIX 上 `Java` 与 `java` 是两个目录,折了会把两份真实安装误并。
  已知取舍写进文档:POSIX 上文件名里的字面反斜杠会一并被当分隔符。
- 三条用例:不靠 `#[cfg(windows)]` 跳过。`path_key` 单测改成平台无关断言 +
  大小写折叠的双侧断言(Windows 折 / POSIX 不折,两侧都钉住);集成用例的
  「同一路径第二种写法」按平台给(Windows 用大小写+分隔符,POSIX 用分隔符)。
- 回归钉子:新增 `one_install_normalises_to_one_key_whatever_the_spelling`
  (普通写法 / 反斜杠写法 / `\?\` 前缀写法 / 混合写法 四种拼法同键)与
  `path_key_folds_case_only_where_the_filesystem_does`。

验证:Linux(rust:1-bookworm 容器,ull-linux-target 热缓存)
`cargo test -p unlucky-core --lib --test game` → 224 + 120 passed / 0 failed
(基线是 lib 220/2 failed、game 119/1 failed);
`cargo test --workspace --all-targets --no-fail-fast` → 34 个测试块
1137 passed / 0 failed(该次跑在 M48 launch 修复叠加的临时副本上,因为 M48
尚未合入 main,见报告)。Windows 本机 `cargo fmt --check`、
`cargo clippy -p unlucky-core --all-targets -D warnings`、
`cargo test -p unlucky-core` 全绿(17 个测试块 0 failed)。
2026-11-01 11:00:25 +08:00
AskaEth 38832df095 Merge branch 'feat/p3-fix-posix-tree-kill-ci-red-posixkill' 2026-11-01 10:48:15 +08:00
AskaEth 6ce825db8d fix(launch): M48 —— POSIX 组杀假成功导致 stop() 空等超时
CI test (ubuntu-latest) 自 M43 起红的根因不是「组杀打到自身进程组」,
而是 procps-ng 的 kill 把裸 `-<pgid>` 当成又一个信号选项吞掉:退出码 0、
什么都没发。旧实现据此提前 return,单 pid 兜底永远不执行,于是
process_smoke::stop_kills_process_tree 与
pipeline_cases::pipeline_stop_records_killed_exit 只能干等
graceful+5s 预算后报「强制结束后进程仍在运行」。

- process.rs:Unix 分支用 CommandExt::process_group(0)(std 1.64+ 安全
  API,无需 pre_exec/unsafe)让子进程自成一个进程组,与 Windows 的
  CREATE_NEW_PROCESS_GROUP 对称;这也让「树」回收真正覆盖后代。
- tree_kill.rs:组杀改用 `kill -TERM -- -<pgid>`(`--` 不可省),并且
  先过 group_kill_target 三重验证——目标必须是自己的进程组组长、必须
  读得到我们自己的组、目标组不能就是我们所在的组;单 pid 兜底改为
  「永远执行」,kill 缺失(NotFound)返回明确错误而不是静默失败。
  pid==0 守卫从 kill_tree 提到 terminate/force_kill 共用入口。
- 测试钉子(Linux 门住):spawn 后子进程 pgid == 自己的 pid 且 !=
  测试进程 pgid;组杀只接受验证过的独立组;组杀覆盖孙进程且不碰我们
  自己的组;同组子进程靠单 pid 兜底仍能被干掉;pid 0 一律拒绝。

验证:rust:1-bookworm 容器(ull-linux-target 热缓存)cargo fmt --check、
cargo clippy --workspace --all-targets -D warnings、cargo test -p
unlucky-core --test launch(82 passed,两条原红灯转绿)、cargo test
--workspace --all-targets --no-fail-fast 不再被 SIGTERM 打断;Windows
本机 fmt/clippy/cargo test -p unlucky-core 全绿。
2026-11-01 10:32:18 +08:00
AskaEth eaa1414847 Merge branch 'feat/p3-packaging-chore-six-p3-findings-packc' 2026-11-01 09:40:59 +08:00
AskaEth 62019a29cd fix(packaging): M47 r1 P2 —— 重跑不再断言 pre-release 标记
reviewer 指出:edit 分支此前每次默认重跑都重传 --prerelease,会把用户按
README 建议手动提升为 stable 的 Release 静默翻回 pre-release,文档承诺"重跑
不翻转标记"不成立。按首选修法让代码匹配文档:

- release.yml:create/edit 两套标记分开算。create 保持老契约(空输入 → 首建
  pre-release);edit 只在 inputs.prerelease 显式给出时断言
  (true → --prerelease,false → --prerelease=false),空值(push 或其 Re-run)
  不传任何标记。非法取值直接 ::error + exit 1。
- README §3:重跑语义与实现逐字一致,并把"改标记"的两条等价路径
  (gh workflow run -f prerelease=false / 手动 gh release edit)与 dispatch
  语义写顺;yml 头注释同步一句。
2026-11-01 09:37:55 +08:00
AskaEth ea6016716e chore(packaging): 收 M46 r1 六条 P3(dumpbin 门禁/ref 注入面/重跑幂等/文档口径)
- O1 release.yml 找不到 dumpbin 从 ::warning+exit 0 改为 ::error+exit 1(门禁不降级),
  README §7.4 同步为"两侧都 fail、没有降级分支"
- O2 打包 step 改用 env REF_TYPE/TAG 传参,bash 里不再出现 ${{ }} 直插;
  release job 的 TAG/PRERELEASE 已是 env 写法,全文件复核无同类风险点
- O3 gh release create 改为 view 命中则 upload --clobber + edit,同 tag 重跑幂等;
  README §3 补重跑语义与 pre-release 标记以首次创建为准
- O4 yml 头注释与 README §3 点明 inputs.prerelease 只在 tag ref 上 dispatch 可达
  (UI 只能选分支,需 gh workflow run --ref v<tag>),不与"dispatch 只构建不发布"冲突
- O5 release-notes 模板补 Termux 试验级行,README §9 同口径,标题改为"逐行一致"
- O6 README §6 补 ci.yml 每次 push/PR 的 release build 吃 fat LTO 链接时间;
  订正"fat 是全局单线程 LTO"为 codegen-units=1 导致单线程(§6/§7.1 两处);
  §7.3 写入 reviewer 补测的 tar 可复现证据 a99d6e9e…92f8
2026-11-01 09:30:27 +08:00
AskaEth 92d8a705e8 Merge branch 'feat/p3-forge-neoforge-gui-install-via-proces' 2026-11-01 09:14:07 +08:00
AskaEth d95c9664c6 fix(app): M45 r1 两条 P2(向导拒装文案 + 处理器段进度满格)
P2-1:wizard.slint 的 Forge/NeoForge 提示还写着「需要 installer processors:本轮请用 ull install」,
改成「需要 installer processors(首装会外调 java,约 1–3 分钟)」;全仓再扫一遍
「ull install / 显式拒装 / 终端跑」等措辞,user-facing 文案已无残留(余下命中全是
OptiFine/LiteLoader 的正确说明与测试注释)。

P2-2:进度条在处理器依赖库下完那一刻就满格(36/36),随后加载器库的 29 个文件才被计入
→ 100% 回落再涨回,且 bytes_total 用 .max() 与文件数对不上。
- Meter 新增 expect(files, bytes):文件数与字节都 saturating_add;
- install_loader 在处理器段之前就把加载器库的文件/字节一次预记账(两段之和),
  加载器库阶段只剩切阶段文案,不再记账;
- Reporter::expect 改为委托 Meter::expect,处理器依赖库沿用同一条曲线;
- 回归测试钉住:预记账后 files_total=95、bytes_total=17MB、ratio 不再满格,
  处理器库下完仍是中间态,加载器库下完才到 100%。

真机重截 + 识图:D:/tmp/ull-m45-shots/forge-mid-fixed-25s.png —— 同一时刻面板
36/65 文件、3.4 MB/15.3 MB、进度条只填了约一半(旧图 36/36 + 满格)。
2026-11-01 09:14:02 +08:00
AskaEth 3d9372029e refactor(app): 处理器测试夹具用 StepView 别名,过 clippy::type_complexity
clippy --all-targets -D warnings 报 very complex type(RefCell<Vec<(usize, String, Vec<String>, usize)>>),
抽 pub(super) type StepView 别名,语义不变。
2026-11-01 09:14:01 +08:00
AskaEth b0f747074f feat(app): GUI 接上 Forge/NeoForge installer processors(M45)
- host/install_processors:GUI 与 CLI 同序跑 processors(计划 → 依赖库 → 选 Java → 串行 java -cp),
  进度经 HostTask::Install 回流(阶段文案 + 每步处理器名 + 文件/字节/速度/ETA),取消在阶段边界生效;
- host/install_journal:安装过程追加到 .ULL/logs/install.log,失败文案带日志路径,不再静默失败;
- host/install.rs:supported() 扩到原版 + Fabric/Quilt/Forge/NeoForge,删除 Forge/NeoForge 拒装路径
  (仅剩 OptiFine/LiteLoader 两家 core 侧无管线者),spawn_install 统一持 Job 上下文;
- 失败诊断:缺 Java / processor 非零退出(保留 processor_* 大类码与脱敏 stderr 尾部)/ 依赖库下漏
  / 元数据缺失,均为结构化文案 + Toast + 装日志;
- dto::InstallStage::Installing 文案改「生成版本」(同时覆盖写版本 JSON 与跑 installer 两段);
- screenshot:新增 --screenshot-loader,Forge/NeoForge 中间态可离屏取图;
- 测试 +14(138 全绿):supported() 语义、处理器阶段注入 fake 后端覆盖跳过/缺 Java/非零退出/
  依赖库失败/命令行构造/进度文案,另修旧用例(Forge 不再被门禁拒装)。
2026-11-01 09:14:01 +08:00
AskaEth 858c227900 Merge branch 'feat/p3-packaging-release-artifacts-matrix-pa'
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
2026-11-01 08:33:18 +08:00
AskaEth 76d83148ff feat(packaging): 单文件产物矩阵 + tag 触发的 Release 流水线(M46)
- packaging/make_artifacts.py:本地与 CI 共用的打包脚本 —— 版本取自 cargo metadata
  (tag 交叉校验,不一致即失败)、按宿主判定槽位、Windows 裸 exe / UNIX tar.gz
  (内含 LICENSE/NOTICE/README.md)、SHA256SUMS.txt;打包前跑 `ull --version` 断言版本;
  tar 内 mtime/uid 归零 + gzip header mtime=0 → 同源构建 sha256 可复现
- packaging/README.md:产物矩阵与命名约定、版本唯一真相源、各平台运行时依赖、
  软渲染兜底、release profile 取舍、**本机实测**体积与冒烟证据、合规与支持矩阵
- packaging/release-notes.md:GitHub Release 说明模板({{VERSION}}/{{SUMS}},
  写清 Win10+ / macOS 12+ / Linux x64 支持矩阵)
- .github/workflows/release.yml:tag v* 触发四槽位矩阵 → 产物 + 重算 SHA256SUMS →
  gh release create(pre-release 默认开,workflow_dispatch 只构建);Windows 用
  +crt-static 并以 dumpbin 导入表把"单文件"做成硬门禁;归档内容自检 LICENSE/NOTICE/README
- Cargo.toml:release profile lto thin → fat(实测 GUI 16,366,080 → 14,595,584 B
  = −10.8%,ull 7,572,992 → 6,157,824 B = −18.7%)

验证:cargo build --release 通过;GUI `--software-renderer --screenshot` 出 1180×760 PNG、
`ull --version` = 0.1.0;PE 导入表 33 个 DLL 全为系统库(无 VCRUNTIME/libwinpthread);
cargo deny licenses/bans/sources ok;workflow 用 python yaml 解析自检。
2026-11-01 08:33:17 +08:00
AskaEth f9079f8ab0 Merge branch 'feat/p3-msa-authorization-code-flow-browser-l' 2026-11-01 08:33:13 +08:00
AskaEth 07fd6113a7 fix(core): 回调先校验 state 再处理 error,堵掉伪造取消失效登录
reviewer P2(crates/core/src/auth/microsoft/callback.rs):handle() 先处理 error 参数、
之后才校验 state,于是任意一次 `GET http://localhost:{port}/auth?error=access_denied`
(state 随便给甚至不给)都会让 wait_for_code 直接返回 Canceled,登录被静默中止、账户不入库
—— 不需要知道随机 state、不需要 code、不需要任何凭据。违反 RFC 6749 §4.1.2.1 / RFC 9207
(错误响应同样要校验 state),也与本模块「与本次登录无关的请求一律继续等待」的声明矛盾。

- state 校验提到 error 之前:state 不匹配的请求(无论带不带 error)一律 400 后继续等;
  只有带**本机 state** 的 access_denied 才判 Canceled。模块文档第 4 条同步改写。
- 顺带采纳 reviewer 的两条非计数观察:
  * 读头预算改为 accept 之后按 deadline 重算(原来复用 accept 前的 remaining,
    总时长可超发 ≤5s);预算归零直接超时。
  * state 由 64 位升到 128 位(两个独立随机种子的 SipHash 输出拼接),对齐
    RFC 6749 §10.10 的熵建议;新增单测断言 32 位 hex 且每次不同。
- 新增回归用例(crates/core/tests/auth/microsoft_authcode.rs):
  `foreign_state_cannot_cancel_the_login` —— 先发 `?error=access_denied`(无 state)
  再发 `?error=access_denied&state=not-our-state`,两次都必须 400 且服务器继续等待,
  随后真回调拿到 code;`access_denied_with_our_state_is_still_canceled` 钉住另一半语义。
  既有的「陈旧 state 回 400 后继续等」用例保持通过。

门禁:cargo fmt --all --check OK;cargo clippy --workspace --all-targets -- -D warnings
exit 0 无告警;cargo test -p unlucky-core -p unlucky-app 18 个 suite 全绿(auth 59 例)。

AskaEth <i@yeij.top>
2026-11-01 08:33:11 +08:00
AskaEth 8194e38bcf feat(core): 加 MSA 授权码流(本地回调服务器)并接到账户页
research/08 方案 A:GUI 的微软登录改走授权码流(HMCL 主流做法),设备码流保留给
CLI/无浏览器场景。两条流只差「怎么拿到 MS access_token」,拿到之后共用
chain::complete_chain 与 MicrosoftSession,落盘/刷新语义没有第二套。

core(crates/core/src/auth/microsoft/)
- endpoints:新增 authorize(…/consumers/oauth2/v2.0/authorize),与 token 同租户。
- authcode:RedirectUri 是 http://localhost:{port}/auth 的唯一来源(授权 URL、
  token 请求体、实际监听三处共用,坑 5);AuthCodePolicy(回调超时 300s / 绑定重试 3 次 /
  固定端口 / 固定 state);authorization_url(response_type=code + prompt=select_account +
  scope=XboxLive.signin%20offline_access);exchange_code 复用 flow 的 oauth_error/tokens_from。
- callback:本地 HTTP/1.1 回调服务器。内核分配端口(占用按次数退避重试);非 /auth 路径
  (favicon、预连)404 后继续等;state 不匹配 400 后继续等;error=access_denied 归 Canceled;
  单条连接读头超时 5s,半开连接拖不垮时间预算。
- MicrosoftAuth::authcode_login(present):绑端口 → present(宿主开浏览器)→ 等回调 →
  换 token → 整链;失败与设备码流一样落 M38 失败日志。

app(crates/app)
- AppCommand::StartBrowserLogin + host::accounts::start_browser_login;设备码/授权码共用
  LoginJob 取消槽位、收尾入库(store_session)与面板 DTO。
- host::opener 用系统默认浏览器打开授权 URL(Windows rundll32 url.dll,FileProtocolHandler)。
- 账户页:主按钮「微软登录」走授权码流,「设备码登录」作备用;DeviceLoginView::browser_waiting
  在面板里给出可复制的授权链接(user_code 留空)。

测试
- crates/core/tests/auth/microsoft_authcode.rs:授权 URL 向量(租户/编码/prompt)、
  URI 一致性(授权 URL 与 token 表单同字面量)、mock 全链、favicon+陈旧 state 后仍拿到 code、
  端口占用重试文案、超时、access_denied→Canceled、login_with_xbox 403 的 M38 诊断
  (detail_code=login_with_xbox_403,真机白名单未批时验收止步于此)。
- 单测:authcode 编码/向量、callback query 解码与错误分流、DTO browser_waiting、conv 面板。

未做/未验证:真机完整登录需 Azure 公共客户端 id(ULL_MSA_CLIENT_ID)与 Minecraft API 许可,
本机只能到「授权页 302 到 login.live.com 且 redirect_uri/scope/prompt 原样保留」这一步。

AskaEth <i@yeij.top>
2026-11-01 08:33:11 +08:00
AskaEth f62bb00494 docs(readme): add official GitHub project home line
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
2026-10-31 22:19:47 +08:00
AskaEth daadc9170f Merge branch 'feat/p3-gui-full-wiring-all-pages-consume-cor'
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
2026-10-31 22:16:35 +08:00
AskaEth 50b321ec64 fix(app): LaunchEnd 也清 pending 并记失败原因,补早失败回归测试(M43 r3 两条 P2)
P2-1(本轮新引入的回归):`pending` 只在 LaunchStarted 清除,但 `launch::start`
的两条早失败出口(build_request 失败 / spawn 失败——没装 Java、版本 JSON 坏就是
这两条)只发 LaunchEnd → pending 永久残留 → `in_flight` 恒真 → 该实例本会话再也
启动不了,磁贴锁在「启动中」。修:LaunchEnd 分支先 `pending.remove`,早失败与
正常收尾都释放。

P2-2(failed 零 insert):上一轮只在 note 里声称「失败原因进 failed 表」,代码没
落地(唯一 insert 缺失,refresh 的 Failed 分支是死代码,test 6 还靠手动塞表绕过)。
修:LaunchEnd 非 Ok 时 `failed.insert(id, summary)`、Ok 时 remove;refresh_instances
的 Failed 分支从此由真实路径喂数据。

测试(123 → 124,全部走真实入口):
- 新增 `launch_end_before_launch_started_still_releases_the_instance`:mark_pending
  → handle(LaunchEnd Crashed) → pending 空、in_flight 假、refresh 后磁贴是
  Failed{reason}(同时钉住 P2-1 与 P2-2)。
- test 6 重写:Busy 断言改走 `mark_pending`(start_launch 用的同一入口),Failed
  断言改走 `handle(LaunchEnd Crashed)`,不再手动塞 failed 表。
- 所有测试的数据根加 `TempRoot` Drop 守卫,跑完不留临时目录。
2026-10-31 22:14:24 +08:00
AskaEth 7b2ff62ddc fix(app): 运行时状态回归真实来源 + 启动状态机补回归测试(M43 r2 两条 P2)
P2-1(修复零测试):新增 host/launch_state_tests.rs,6 条单测钉住启动状态机:
- running_pid 按实例 id 取 pid(并发 A/B 后停 A 不会拿到 B 的 pid,A 收尾不动 B);
- 重复启动守卫:launches 已落盘时 start_launch不发 Busy、只给 warn(不起第二个进程);
- pending 窗口守卫:后台线程还没回报 LaunchStarted 时同样挡住重复启动;
- LaunchStarted 清 pending 并登记 pid;LaunchEnd 清启动表与失败记录;
- refresh 后在跑实例仍是 Running{pid};pending 是 Busy;失败记录是 Failed(保住
  磁贴的停止键/运行条/重试外观)。

P2-2(运行时状态未派生):refresh_instances 重建快照后按 host 的启动表覆写运行时
(Running{pid} / Busy / Failed),不再被 game::instances 的硬编码 Idle 打回原形——
装版本成功、加删账户、别的实例收尾等任意刷新都不再让「在跑的实例」丢掉停止入口。

顺带加固(reviewer 建议的 pending 窗口):单槽守卫改为 pending ∪ launches 并集,
launch::start 到 LaunchStarted 之间的重复 dispatch 不会起第二个进程;失败原因也
记进 failed 表,避免收尾后的刷新把「失败/重试」覆盖成 Idle。
2026-10-31 22:09:47 +08:00
AskaEth ecded9bef0 fix(app): 停止实例按实例 id 找 pid,并发启动不再杀错进程树
reviewer-guiwire r1 的 P2-1:`running_pid` 只记「最近一次启动」的单槽 pid,
并发启动 A、B 后点 A 的停止键会 tree_kill 掉 B;且实时尾随在 B 启动时被抢占,
A 的日志再也没人跟。兜底分支也永远不命中(`game::instances` 每次重建都把
runtime 置 Idle)。

改法:
- 单槽 `launch_pid` → `launches: Vec<LiveLaunch{instance, session, pid}>`,
  停止/收尾/尾随判定都按实例 id 或会话 id 精确匹配;
- 同一实例重复点启动直接拒绝(「已经在启动/运行中」),不再起第二个进程;
- 收尾只拆自己那场的尾随,并发时另一实例的实时日志不被掐断;
- 实时尾随仍是单窗口(一次只跟一场,另一场收尾后可从 JSONL 回放)——这是
  有意的 UI 取舍,不是漏判。
2026-10-31 21:55:59 +08:00
AskaEth 359a5a4325 chore: gitignore 截图验收产物目录(.screens/.tmp-screens) 2026-10-31 21:55:59 +08:00
AskaEth ddc0577f83 feat(app): GUI 全量接线,五页改吃 core 真实数据(M43)
app 从「mock 骨架」切到「真实 core host」:新增 src/host 装配层(env/job/
game/launch/identity/versions/install/meter/java/accounts/logs/settings/engine/
dispatch),是 app 里除 settings_bridge 外唯一 use unlucky_core 的地方,出口只有
crate::dto 的 DTO/事件(三通道纪律,AGENTS §6)。

页面接线:
- 实例页:profiles+版本 JSON 继承链+会话索引 → 磁贴(真实版本/加载器徽标/最后启动/
  隔离档位/载荷缺失);启动按钮走 LaunchRequest 装配 → Launcher::spawn → JSONL
  尾随 → 运行/失败回写;M30 ModeChangeWarning 进横幅通知条。
- 新建实例向导:版本 → 加载器 → 名字 → 确认 → 真实安装进度;Fabric/Quilt 可装,
  Forge/NeoForge 因需要 installer processors(仅 CLI 有)显式拒装并给出 CLI 命令。
- 下载页:远端 manifest(12h 缓存)+ 内嵌 unlisted → 分类(正式/预览/远古四子类);
  安装进度(文件数/字节/速度/ETA,速度由相邻采样差分)+ 阶段边界可取消;
  Java 分组:四家发行版 + 回退链可视化(计划链/真实 ChainLog)+ 本地运行时。
- 账户页:三轨账户列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软设备码登录
  (用户码/链接/轮询状态;取消后丢弃结果)。凭据只在 host/identity 与 host/accounts
  出现,DTO 无 token 字段(单测钉住)。
- 设置页:新增 download/java/data/ai/mcp 五组,行由 core::setting schema 键生成,
  写回走 SettingStore::set(与 ull config set / AI set_config 同源 launcher.json)。
- 日志页:会话索引 → 列表;事件 JSONL → 日志行(等级高亮)+ D8 规则引擎诊断卡片;
  启动中实时尾随(600ms/拍,只读新增字节);崩溃日志导入分析(hs_err/crash/latest)。

架构与守线:
- 新页面状态走 ui/page_state.slint 的 PageState global(main.slint 再导出),
  Rust 侧 page_sync.rs 灌入、callbacks_pages.rs 接线;避免属性逐层转发顶破 400 行。
- DTO 全部 Serialize+Deserialize(IPC 契约),新增 accounts/java/settings/install/
  notices 五个子模块;事件扩展 LogAppended/LaunchFinished/AccountsChanged/
  DeviceLoginChanged/JavaChanged/InstallProgress/SettingsChanged/NoticesChanged。
- 截图通道扩展:--data-dir(钉数据根且 settings 同源)/--mock/--screenshot-page/
  --screenshot-wizard/--screenshot-session/--screenshot-install/--screenshot-launch
  (真实中间态,取图后自动 stop 防孤儿进程)。

已知边界:GUI 账户「当前启动账户」仅内存态(core schema 无 auth.* 键,app 不自建键);
Forge/NeoForge GUI 安装待 processors 执行器上移。
2026-10-31 21:55:58 +08:00
95 changed files with 12082 additions and 458 deletions

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
name: Release
# 发布流水线:tag `v*` → 三平台(四槽位)矩阵构建 → 单文件产物 + SHA256SUMS
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
#
# 契约唯一真相源:packaging/README.md(命名/目录/运行时依赖)。
# 打包本体在 packaging/make_artifacts.py(本地与 CI 同一份脚本,逐字节同布局)。
# 手动触发(workflow_dispatch)默认只跑构建不建 Release —— 用来验证矩阵仍然编得过。
# 注意 `inputs.prerelease` 只在 **tag ref** 上 dispatch 才可达:Actions 页面的
# "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。
# 分支 ref 的 dispatch 与 push 事件一样被 release job 的 `if: github.ref_type == 'tag'`
# 挡在发布之外,所以「dispatch 只构建不发布」与「dispatch 可关 pre-release」并不矛盾。
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
prerelease:
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: build (${{ matrix.slot }})
strategy:
fail-fast: false
matrix:
include:
# Windows:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
- slot: windows-x64
os: windows-latest
rustflags: -C target-feature=+crt-static
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 = 2.39)。
- slot: linux-x64
os: ubuntu-22.04
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
- slot: macos-arm64
os: macos-latest
- slot: macos-x64
os: macos-15-intel
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: cargo build --release --locked
env:
RUSTFLAGS: ${{ matrix.rustflags }}
run: cargo build --release --locked --bin unlucky-launcher --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
run: |
set -euo pipefail
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
# 可再发行包,那就不是"单文件"了,直接 fail。
#
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位):
# ① vswhere:VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/安装布局无关;
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# -find 的模式优先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
for pattern in 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' \
'VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe'; do
hit=$("$VSWHERE" -latest -products '*' -find "$pattern" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=${hit//\\//}
VIA="vswhere"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
# 镜像里的 VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),
# 固定层数的 glob 会在 <年>/<版次> 这两级踩空,所以按路径模式扫。
for root in "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio"; do
[ -d "$root" ] || continue
hit=$(find "$root" -maxdepth 11 -type f \
-path '*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe' 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="find: $root"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="PATH"
fi
fi
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
exit 1
fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
"$DUMPBIN" -nologo -dependents target/release/unlucky-launcher.exe | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
exit 1
fi
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
args=()
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
ls -l dist
- name: 归档内容自检(二进制定位 + 合规文本)
shell: bash
run: |
set -euo pipefail
for archive in dist/*.tar.gz; do
[ -e "$archive" ] || continue
listing=$(tar -tzf "$archive")
for required in unlucky-launcher LICENSE NOTICE README.md; do
if ! grep -q "/$required$" <<<"$listing"; then
echo "::error file=$archive::$archive 缺少 $required"
exit 1
fi
done
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
done
if ls dist/*.exe >/dev/null 2>&1; then
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
fi
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.slot }}
path: dist
if-no-files-found: error
retention-days: 14
release:
name: GitHub Release
needs: build
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: 矩阵完整性 + 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
for slot in windows-x64 linux-x64 macos-arm64 macos-x64; do
if ! compgen -G "dist/*-$slot*" >/dev/null; then
echo "::error::矩阵缺产物:$slot"
exit 1
fi
done
# 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher-* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib
version = os.environ["TAG"].lstrip("v")
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
print(body[:400])
PY
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 标记只在**显式要求**时才断言:
# PRERELEASE=true / false —— 来自 tag ref 上的 workflow_dispatch 输入;
# PRERELEASE 空 —— push 触发的运行或它的 Re-run,此时**不碰**该标记,
# 否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release。
# 首次创建(create)仍保持「pre-release 默认开」的老契约:空值按 true 处理。
create_mark=()
edit_mark=()
case "${PRERELEASE:-}" in
true)
create_mark=(--prerelease)
edit_mark=(--prerelease)
;;
false)
edit_mark=(--prerelease=false)
;;
"")
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上,
# 保证每个下载平台的许可条款都随包分发。
assets=(dist/unlucky-launcher-* 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
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
else
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
fi
+4
View File
@@ -5,6 +5,10 @@
# Local development docs
/DevDocs/
# Local acceptance screenshots (screenshot channel output, never committed)
/.screens/
/.tmp-screens/
# Agent workspace (local-only)
/.tower/
Generated
+2
View File
@@ -6032,6 +6032,8 @@ dependencies = [
name = "unlucky-app"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"slint",
"slint-build",
"unlucky-agent",
+1 -1
View File
@@ -82,7 +82,7 @@ all = { level = "deny", priority = -1 }
[profile.release]
opt-level = "z"
lto = "thin"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"
+2
View File
@@ -2,6 +2,8 @@
**English** · [简体中文](README.zh-CN.md)
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher
Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust
(Apache-2.0, non-commercial), with a Slint + Metro UI. It embeds an AI agent client (chat /
configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP.
+2
View File
@@ -2,6 +2,8 @@
[English](README.md) · **简体中文**
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
+7
View File
@@ -15,6 +15,11 @@ path = "src/main.rs"
[dependencies]
slint.workspace = true
# 跨界 DTO 必须可序列化(AGENTS §6 / 01c §5):事件与快照是 IPC 契约,
# 序列化能力由 derive 钉住,测试里做 round-trip 断言。
serde.workspace = true
# host 层要读 core 设置/事件里的 JSON 值(serde_json 是 workspace 既有依赖)
serde_json.workspace = true
# 边界(01c §5 / D3):app 只经 DTO 消费 core/agent,**不 import 内部类型**。
# P0 的 host 是 src/mock.rs 的假后端;这两个依赖在这里登记,是为了后续把真实 host
@@ -25,5 +30,7 @@ unlucky-agent = { path = "../agent", version = "0.1" }
[build-dependencies]
slint-build.workspace = true
[dev-dependencies]
[lints]
workspace = true
+36
View File
@@ -0,0 +1,36 @@
//! 后端选择与启动选项:真实 core host 优先,解析不出数据根时退回 mock。
use std::path::{Path, PathBuf};
use crate::dto::Backend;
use crate::host::CoreHost;
use crate::host::HostEnv;
use crate::mock::MockBackend;
/// 启动选项(截图/演示通道用;正常启动全用默认值)。
#[derive(Debug, Clone, Default)]
pub struct AppOptions {
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析
pub data_root: Option<PathBuf>,
/// 强制 mock 后端(UI 回归/无数据根演示)
pub mock: bool,
}
/// `<root>/.ULL/config/launcher.json`(与 `SettingsBridge::open` 同落点)。
pub(crate) fn config_path(root: &Path) -> PathBuf {
root.join(".ULL").join("config").join("launcher.json")
}
/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。
pub(crate) fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
if options.mock {
return Box::new(MockBackend::new());
}
match &options.data_root {
Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))),
None => match CoreHost::open() {
Some(host) => Box::new(host),
None => Box::new(MockBackend::new()),
},
}
}
+2 -4
View File
@@ -180,10 +180,8 @@ fn wire_instances(ui: &crate::MainWindow, weak: &Weak<Inner>) {
let weak = weak.clone();
ui.on_new_instance(move || {
let Some(this) = weak.upgrade() else { return };
this.state
.borrow_mut()
.toasts
.push(ToastKind::Info, "新建实例向导(G5)在 P1 落地");
// G5:打开向导(版本 → 加载器 → 名字 → 确认 → 安装进度)
this.state.borrow_mut().wizard = crate::wizard::WizardState::open();
this.sync();
});
}
+264
View File
@@ -0,0 +1,264 @@
//! `PageState` global 的回调接线:账户 / Java / 设置 / 通知 / 向导 / 崩溃导入 / 日志刷新。
//!
//! 与 `callbacks.rs` 同一纪律:每个回调 = 「改状态 + 发 AppCommand + 同步 UI」,一处看全。
//! 用 global 而不是逐层转发属性(见 `ui/page_state.slint` 顶部的取舍说明)。
use std::rc::Weak;
use slint::ComponentHandle;
use crate::conv;
use crate::dto::{AppCommand, CreateRequest, LoaderKind};
use crate::ui_bind::Inner;
/// 接全部页面状态回调。
pub(crate) fn wire_pages(weak: Weak<Inner>) {
let Some(inner) = weak.upgrade() else { return };
let page = inner.ui.global::<crate::PageState<'_>>();
// ── 账户 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_account_add_offline(move |name| {
let name = name.to_string();
if name.trim().is_empty() {
return;
}
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::AddOfflineAccount(name));
this.ui
.global::<crate::PageState<'_>>()
.set_offline_name("".into());
this.sync();
});
}
{
let weak = weak.clone();
page.on_account_remove(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::RemoveAccount(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
page.on_account_switch(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::SwitchAccount(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
page.on_msa_begin(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::StartDeviceLogin);
this.sync();
});
}
{
let weak = weak.clone();
page.on_msa_browser_begin(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::StartBrowserLogin);
this.sync();
});
}
{
let weak = weak.clone();
page.on_msa_cancel(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::CancelDeviceLogin);
this.sync();
});
}
// ── Java ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_java_install(move |vendor| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::InstallJava(vendor.to_string()));
this.sync();
});
}
// ── 设置 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_setting_changed(move |key, value| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::SetSetting {
key: key.to_string(),
value: value.to_string(),
});
this.sync();
});
}
{
let weak = weak.clone();
page.on_setting_action(move |action| {
let Some(this) = weak.upgrade() else { return };
let action = action.to_string();
match action.as_str() {
// 清 24h 地区缓存 → 下次下载重新判定
"detect_region" => this.dispatch(AppCommand::SetSetting {
key: "download.action.detect_region".to_string(),
value: String::new(),
}),
// Java 分组的「安装/管理」→ 按回退链装一个托管 JDK
"install_java" => this.dispatch(AppCommand::InstallJava(String::new())),
_ => {}
}
this.sync();
});
}
{
let weak = weak.clone();
page.on_settings_tab_changed(move |tab| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().settings_tab = tab.to_string();
this.sync();
});
}
// ── 通知 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_notice_dismiss(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::DismissNotice(id.to_string()));
this.sync();
});
}
// ── 向导 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_wizard_open(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard = crate::wizard::WizardState::open();
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_close(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard.open = false;
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_select_version(move |version| {
let Some(this) = weak.upgrade() else { return };
this.state
.borrow_mut()
.wizard
.select_version(version.as_ref());
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_select_loader(move |index| {
let Some(this) = weak.upgrade() else { return };
let loader = match index {
1 => LoaderKind::Fabric,
2 => LoaderKind::Quilt,
3 => LoaderKind::Forge,
4 => LoaderKind::NeoForge,
_ => LoaderKind::Vanilla,
};
this.state.borrow_mut().wizard.select_loader(loader);
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_name_changed(move |name| {
let Some(this) = weak.upgrade() else { return };
let mut state = this.state.borrow_mut();
state.wizard.name = name.to_string();
state.wizard.error.clear();
drop(state);
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_next(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard.advance();
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_back(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard.step_back();
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_confirm(move || {
let Some(this) = weak.upgrade() else { return };
let request = {
let state = this.state.borrow();
let wizard = &state.wizard;
wizard.ready_to_submit().map(|()| CreateRequest {
version: wizard.version.trim().to_string(),
loader: wizard.loader,
loader_version: wizard.loader_version.trim().to_string(),
name: wizard.name.trim().to_string(),
})
};
match request {
Ok(request) => {
this.dispatch(AppCommand::CreateInstance(request));
this.state.borrow_mut().wizard.submitted();
}
Err(error) => {
this.state.borrow_mut().wizard.error = error;
}
}
this.sync();
});
}
{
let weak = weak.clone();
page.on_install_cancel(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::CancelInstall);
this.sync();
});
}
// ── 日志页 ────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_crash_import(move |path| {
let Some(this) = weak.upgrade() else { return };
let path = path.to_string();
if path.trim().is_empty() {
return;
}
this.dispatch(AppCommand::ImportCrashLog(path));
this.sync();
});
}
{
let weak = weak.clone();
page.on_logs_refresh(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::RefreshLogSessions);
this.sync();
});
}
// 设置页分组切换同时刷新一遍分组快照(外部改配置后手点分组即最新)
let _ = conv::setting_groups;
}
+1
View File
@@ -33,6 +33,7 @@ pub fn phase(runtime: &InstanceRuntime) -> (RuntimePhase, f32, String) {
dto::LaunchStage::Download => RuntimePhase::Downloading,
dto::LaunchStage::Verify => RuntimePhase::Verifying,
dto::LaunchStage::Launch => RuntimePhase::Launching,
dto::LaunchStage::Window => RuntimePhase::Running,
};
(phase, progress.ratio(), stage.label().to_string())
}
+5
View File
@@ -8,12 +8,17 @@
mod agent;
mod instances;
mod logs;
mod pages;
mod ui;
mod versions;
pub use agent::{chat, dialog, toasts};
pub use instances::{running_tiles, tiles};
pub use logs::{diagnosis, log_lines, log_sessions};
pub use pages::{
accounts, active_account_name, device, install, java_chain, java_runtimes, java_vendors,
notices, setting_groups, wizard, wizard_versions,
};
pub use ui::{
accent_color, bg_fill, bg_fill_from, category_from, instance_view, instance_view_from, route,
route_from, theme_mode, theme_mode_from, tile_size, tile_size_from, version_category,
+300
View File
@@ -0,0 +1,300 @@
//! 状态 → `page_state.slint` 的视图模型(账户 / Java / 安装 / 设置分组 / 通知 / 向导)。
//!
//! 与 `conv/{instances,versions,logs,agent}.rs` 同一套路:只做「领域值 → 展示值」的搬运,
//! 不含业务判断。`ui_bind::sync` 把这些灌进 `PageState` global。
use slint::{ModelRc, SharedString, VecModel};
use crate::dto::{
AccountKind as DtoAccountKind, DeviceLoginPhase, InstallStage as DtoInstallStage,
JavaChainOutcome, NoticeLevel, SettingControl, VersionKind as DtoVersionKind,
};
use crate::state::AppState;
use crate::wizard::WizardStep;
use crate::{
AccountKind, InstallStage, JavaStepOutcome, NoticeKind, SettingControl as UiControl, UiAccount,
UiDeviceCode, UiInstallProgress, UiJavaRuntime, UiJavaStep, UiJavaVendor, UiNotice,
UiSettingGroup, UiSettingRow, UiVersion, UiWizard,
};
fn strings(items: &[String]) -> ModelRc<SharedString> {
ModelRc::new(VecModel::from(
items
.iter()
.map(|item| SharedString::from(item.as_str()))
.collect::<Vec<_>>(),
))
}
/// 账户行。
pub fn accounts(state: &AppState) -> Vec<UiAccount> {
state
.accounts
.iter()
.map(|account| UiAccount {
id: account.id.as_str().into(),
name: account.name.as_str().into(),
kind: match account.kind {
DtoAccountKind::Offline => AccountKind::Offline,
DtoAccountKind::Microsoft => AccountKind::Microsoft,
DtoAccountKind::Authlib => AccountKind::Authlib,
},
kind_label: account.kind.label().into(),
profile_id: account.profile_id.as_str().into(),
login_name: account.login_name.as_str().into(),
created: account.created.as_str().into(),
active: account.active,
})
.collect()
}
/// 当前启动账户的展示名(导航底部头像)。
pub fn active_account_name(state: &AppState) -> String {
state
.accounts
.iter()
.find(|account| account.active)
.map(|account| account.name.clone())
.unwrap_or_else(|| "未登录".to_string())
}
/// 微软登录面板。
pub fn device(state: &AppState) -> UiDeviceCode {
let view = &state.device_login;
UiDeviceCode {
visible: view.phase.visible(),
busy: view.phase.busy(),
user_code: view.user_code.as_str().into(),
verification_uri: view.verification_uri.as_str().into(),
qr_link: view.qr_link.as_str().into(),
expires: i32::try_from(view.expires_in).unwrap_or(i32::MAX),
message: view.message.as_str().into(),
phase_label: match view.phase {
DeviceLoginPhase::Idle => "空闲",
DeviceLoginPhase::Requesting => "申请中",
DeviceLoginPhase::Waiting => "等待确认",
DeviceLoginPhase::Succeeded => "成功",
DeviceLoginPhase::Failed => "失败",
}
.into(),
}
}
/// Java 发行版行。
pub fn java_vendors(state: &AppState) -> Vec<UiJavaVendor> {
state
.java
.vendors
.iter()
.map(|vendor| UiJavaVendor {
id: vendor.id.as_str().into(),
display: vendor.display.as_str().into(),
rank: i32::try_from(vendor.rank).unwrap_or(0),
majors: vendor.majors.as_str().into(),
sources: vendor.sources.join(" · ").into(),
installed: vendor.installed,
})
.collect()
}
/// Java 回退链。
pub fn java_chain(state: &AppState) -> Vec<UiJavaStep> {
state
.java
.chain
.iter()
.map(|step| UiJavaStep {
vendor: step.vendor.as_str().into(),
outcome: match step.outcome {
JavaChainOutcome::Pending => JavaStepOutcome::Pending,
JavaChainOutcome::Skip => JavaStepOutcome::Skip,
JavaChainOutcome::Failed => JavaStepOutcome::Failed,
JavaChainOutcome::Hit => JavaStepOutcome::Hit,
},
outcome_label: step.outcome.label().into(),
detail: step.detail.as_str().into(),
})
.collect()
}
/// Java 本地运行时。
pub fn java_runtimes(state: &AppState) -> Vec<UiJavaRuntime> {
state
.java
.runtimes
.iter()
.map(|runtime| UiJavaRuntime {
binary: runtime.binary.as_str().into(),
version: runtime.version.as_str().into(),
vendor: runtime.vendor.as_str().into(),
origin: runtime.origin.as_str().into(),
})
.collect()
}
/// 安装进度卡片。
pub fn install(state: &AppState) -> UiInstallProgress {
let Some(progress) = &state.install else {
return UiInstallProgress {
visible: false,
stage: InstallStage::Planning,
..UiInstallProgress::default()
};
};
UiInstallProgress {
visible: true,
version: progress.version.as_str().into(),
instance: progress.instance.as_str().into(),
loader: progress.loader.as_str().into(),
stage: match progress.stage {
DtoInstallStage::Planning => InstallStage::Planning,
DtoInstallStage::Downloading => InstallStage::Downloading,
DtoInstallStage::Installing => InstallStage::Installing,
DtoInstallStage::Registering => InstallStage::Registering,
DtoInstallStage::Done => InstallStage::Done,
DtoInstallStage::Failed => InstallStage::Failed,
DtoInstallStage::Cancelled => InstallStage::Cancelled,
},
stage_label: progress.stage.label().into(),
ratio: progress.ratio(),
known: progress.is_known(),
files_label: progress.files_label().into(),
bytes_label: if progress.bytes_total > 0 {
format!(
"{}/{}",
crate::dto::human_bytes(progress.bytes_done),
crate::dto::human_bytes(progress.bytes_total)
)
} else {
String::new()
}
.into(),
speed: progress.speed_label().into(),
eta: progress.eta_label().into(),
message: progress.message.as_str().into(),
terminal: progress.stage.is_terminal(),
failed: progress.stage == DtoInstallStage::Failed,
}
}
/// 设置分组(行 + 控件)。
pub fn setting_groups(state: &AppState) -> Vec<UiSettingGroup> {
state
.settings_groups
.iter()
.map(|group| UiSettingGroup {
id: group.id.as_str().into(),
title: group.title.as_str().into(),
rows: ModelRc::new(VecModel::from(
group
.rows
.iter()
.map(|row| UiSettingRow {
key: row.key.as_str().into(),
label: row.label.as_str().into(),
description: row.description.as_str().into(),
control: match row.control {
SettingControl::Toggle => UiControl::Toggle,
SettingControl::Choice => UiControl::Choice,
SettingControl::Text => UiControl::Text,
SettingControl::ReadOnly => UiControl::Readonly,
SettingControl::Action => UiControl::Action,
},
flag: row.flag,
selected: row.selected,
options: strings(&row.options),
value_text: row.value_text.as_str().into(),
action: row.action.as_str().into(),
})
.collect::<Vec<_>>(),
)),
})
.collect()
}
/// 横幅通知。
pub fn notices(state: &AppState) -> Vec<UiNotice> {
state
.notices
.iter()
.map(|notice| UiNotice {
id: notice.id.as_str().into(),
kind: match notice.level {
NoticeLevel::Info => NoticeKind::Info,
NoticeLevel::Warn => NoticeKind::Warn,
NoticeLevel::Error => NoticeKind::Error,
},
title: notice.title.as_str().into(),
body: notice.body.as_str().into(),
})
.collect()
}
/// 向导状态。
pub fn wizard(state: &AppState) -> UiWizard {
let wizard = &state.wizard;
let step = wizard.step;
let can_next = match step {
WizardStep::Version => !wizard.version.trim().is_empty(),
WizardStep::Name => crate::wizard::validate_name(&wizard.name).is_ok(),
WizardStep::Loader | WizardStep::Confirm => true,
WizardStep::Progress => false,
};
UiWizard {
open: wizard.open,
step: i32::try_from(step.index()).unwrap_or(0),
step_label: step.title().into(),
step_index: i32::try_from(step.index()).unwrap_or(0),
step_total: 5,
version: wizard.version.as_str().into(),
loader: loader_index(wizard.loader),
loader_label: if wizard.loader == crate::dto::LoaderKind::Vanilla {
"原版".to_string()
} else {
format!("{} {}", wizard.loader.label(), wizard.loader_version.trim())
.trim()
.to_string()
}
.into(),
loader_version: wizard.loader_version.as_str().into(),
name: wizard.name.as_str().into(),
error: wizard.error.as_str().into(),
busy: wizard.submitting,
can_next,
}
}
/// 加载器在向导里的单选序号(与 `wizard.slint` 的 LoaderChoice 顺序一致)。
pub fn loader_index(loader: crate::dto::LoaderKind) -> i32 {
match loader {
crate::dto::LoaderKind::Vanilla => 0,
crate::dto::LoaderKind::Fabric => 1,
crate::dto::LoaderKind::Quilt => 2,
crate::dto::LoaderKind::Forge => 3,
crate::dto::LoaderKind::NeoForge => 4,
crate::dto::LoaderKind::OptiFine | crate::dto::LoaderKind::LiteLoader => 0,
}
}
/// 向导里的版本列表(正式版 + 快照;远古版走下载页的显式入口)。
pub fn wizard_versions(state: &AppState) -> Vec<UiVersion> {
state
.versions
.iter()
.filter(|version| !version.kind.is_ancient())
.map(|version| UiVersion {
id: version.id.as_str().into(),
kind: match version.kind {
DtoVersionKind::Release => crate::VersionKind::Release,
_ => crate::VersionKind::Snapshot,
},
released: version.released.as_str().into(),
installed: version.installed,
ancient: false,
})
.collect()
}
#[cfg(test)]
#[path = "pages_tests.rs"]
mod tests;
+172
View File
@@ -0,0 +1,172 @@
//! conv/pages.rs 的单测(拆文件守 400 行上限)。
use super::*;
use crate::dto::{
AccountView, DeviceLoginView, InstallProgressView, JavaPageView, JavaVendorView, NoticeView,
SettingGroupView, SettingRowView, VersionSummary,
};
use crate::wizard::WizardState;
use slint::Model;
#[test]
fn accounts_and_device_login_render() {
let mut state = AppState::new();
state.accounts = vec![AccountView {
id: "account:1".to_string(),
name: "Steve".to_string(),
kind: DtoAccountKind::Offline,
profile_id: "0f8f".to_string(),
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active: true,
}];
state.device_login =
DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
let rows = accounts(&state);
assert_eq!(rows.len(), 1);
assert!(rows[0].active);
assert_eq!(rows[0].kind_label, "离线");
assert_eq!(active_account_name(&state), "Steve");
let device = device(&state);
assert!(device.visible && device.busy);
assert_eq!(device.user_code, "ABCD-EFGH");
assert_eq!(device.phase_label, "等待确认");
}
#[test]
fn browser_login_panel_renders_the_authorization_link() {
let mut state = AppState::new();
state.device_login = DeviceLoginView::browser_waiting(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
300,
None,
);
let device = device(&state);
assert!(device.visible && device.busy);
assert_eq!(device.user_code, "", "授权码流没有用户码");
assert!(device.verification_uri.contains("/authorize?client_id=x"));
assert_eq!(device.qr_link, device.verification_uri);
assert_eq!(device.expires, 300);
}
#[test]
fn install_progress_renders_labels_and_terminal_flags() {
let mut state = AppState::new();
state.install = Some(InstallProgressView {
version: "1.20.4".to_string(),
instance: "生存".to_string(),
loader: "Fabric 0.16.5".to_string(),
stage: DtoInstallStage::Downloading,
files_done: 3,
files_total: 6,
bytes_done: 1024,
bytes_total: 4096,
speed_bps: 2048,
eta_secs: 2,
message: "下载中".to_string(),
});
let ui = install(&state);
assert!(ui.visible && !ui.terminal && !ui.failed);
assert!((ui.ratio - 0.5).abs() < f32::EPSILON);
assert_eq!(ui.files_label, "3/6 文件");
assert_eq!(ui.speed, "2.0 KB/s");
assert_eq!(ui.eta, "剩 2s");
state.install = None;
assert!(!install(&state).visible);
}
#[test]
fn java_chain_and_vendors_map_outcomes() {
let mut state = AppState::new();
state.java = JavaPageView {
vendors: vec![JavaVendorView {
id: "temurin".to_string(),
display: "Eclipse Temurin".to_string(),
rank: 1,
majors: "17/21".to_string(),
sources: vec!["BMCLAPI".to_string(), "GitHub".to_string()],
installed: true,
}],
chain: vec![crate::dto::JavaChainStep {
vendor: "temurin".to_string(),
outcome: JavaChainOutcome::Hit,
detail: "21".to_string(),
}],
..JavaPageView::new()
};
let vendors = java_vendors(&state);
assert_eq!(vendors.len(), 1);
assert_eq!(vendors[0].sources, "BMCLAPI · GitHub");
let chain = java_chain(&state);
assert!(matches!(chain[0].outcome, JavaStepOutcome::Hit));
assert_eq!(chain[0].outcome_label, "命中");
}
#[test]
fn settings_groups_map_controls() {
let mut state = AppState::new();
state.settings_groups = vec![SettingGroupView::new("download", "下载")
.with(SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true))
.with(SettingRowView::choice(
"download.region",
"地区",
"",
&["auto", "cn"],
1,
))];
let groups = setting_groups(&state);
assert_eq!(groups.len(), 1);
let rows = groups[0].rows.clone();
assert_eq!(rows.row_count(), 2);
let first = rows.row_data(0).expect("第一行");
let second = rows.row_data(1).expect("第二行");
assert!(first.flag);
assert!(matches!(second.control, UiControl::Choice));
assert_eq!(second.selected, 1);
}
#[test]
fn notices_and_wizard_render() {
let mut state = AppState::new();
state.notices = vec![NoticeView::new(
"isolation:x",
NoticeLevel::Warn,
"隔离档位已切换",
"两侧都有内容",
)];
let notices = notices(&state);
assert!(matches!(notices[0].kind, NoticeKind::Warn));
state.wizard = WizardState::open();
state.wizard.select_version("1.20.4");
let wizard = wizard(&state);
assert!(wizard.open);
assert_eq!(wizard.step_label, "选择版本");
assert!(wizard.can_next);
assert_eq!(wizard.loader, 0);
}
#[test]
fn wizard_versions_skip_ancient_ones() {
let mut state = AppState::new();
state.versions = vec![
VersionSummary {
id: "1.20.4".to_string(),
kind: DtoVersionKind::Release,
released: "2023-12-07".to_string(),
installed: false,
},
VersionSummary {
id: "rd-132211".to_string(),
kind: DtoVersionKind::Ancient(crate::dto::AncientKind::Classic),
released: "2009-05-13".to_string(),
installed: false,
},
];
let rows = wizard_versions(&state);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "1.20.4");
}
+30 -8
View File
@@ -1,17 +1,25 @@
//! 版本列表与下载分类映射(下载页)。
use crate::dto::{AncientKind, VersionKind as DtoVersionKind};
use crate::dto::{AncientKind, VersionCategory, VersionKind as DtoVersionKind, VersionSummary};
use crate::state::AppState;
use crate::{UiVersion, VersionKind};
/// 下载页版本行(已按「仅已装」+ 版本号筛选词过滤)。
pub fn versions(state: &AppState) -> Vec<UiVersion> {
let query = state.download_query.trim().to_lowercase();
state
.versions
/// 分类侧栏 → 该分类下的版本(远古版按子类细分)。
pub fn in_category(category: VersionCategory, kind: DtoVersionKind) -> bool {
match (category, kind) {
(VersionCategory::Release, DtoVersionKind::Release) => true,
(VersionCategory::Snapshot, DtoVersionKind::Snapshot) => true,
(VersionCategory::Ancient(expected), DtoVersionKind::Ancient(actual)) => expected == actual,
// 未接线的分类(整合包 / Mod)不显示任何版本,而不是把全部版本倒进去
(VersionCategory::Modpack | VersionCategory::Mod, _) => false,
_ => false,
}
}
/// 版本行(下载页与新建向导共用)。
pub fn version_rows(versions: &[VersionSummary]) -> Vec<UiVersion> {
versions
.iter()
.filter(|version| !state.installed_only || version.installed)
.filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query))
.map(|version| UiVersion {
id: version.id.clone().into(),
kind: version_kind(version.kind),
@@ -22,6 +30,20 @@ pub fn versions(state: &AppState) -> Vec<UiVersion> {
.collect()
}
/// 下载页版本行(按当前分类 + 「仅已装」+ 版本号筛选词过滤)。
pub fn versions(state: &AppState) -> Vec<UiVersion> {
let query = state.download_query.trim().to_lowercase();
let filtered: Vec<VersionSummary> = state
.versions
.iter()
.filter(|version| in_category(state.prefs.download_category, version.kind))
.filter(|version| !state.installed_only || version.installed)
.filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query))
.cloned()
.collect();
version_rows(&filtered)
}
fn version_kind(kind: DtoVersionKind) -> VersionKind {
match kind {
DtoVersionKind::Release => VersionKind::Release,
+5 -3
View File
@@ -4,11 +4,13 @@
//! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。
//! 非阻塞提示走 [`crate::toast`],不占模态队列。
use serde::{Deserialize, Serialize};
/// 对话框标识。
pub type DialogId = u64;
/// 对话框来源:决定插队优先级。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum DialogSource {
/// 提示类(教程、首次使用说明)
Hint = 0,
@@ -26,7 +28,7 @@ impl DialogSource {
}
/// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DialogKind {
/// 确认:主按钮 + 次按钮
Confirm {
@@ -87,7 +89,7 @@ impl DialogKind {
}
/// 一条对话框请求。
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DialogRequest {
/// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号)
pub id: DialogId,
+270
View File
@@ -0,0 +1,270 @@
//! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。
//!
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
//! host 侧在拿到账户快照的那一刻就丢掉凭据,只留「能显示什么」。
use serde::{Deserialize, Serialize};
/// 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AccountKind {
/// 离线账户(HMCL 兼容 UUID)
Offline,
/// 微软正版
Microsoft,
/// authlib-injector(第三方皮肤站)
Authlib,
}
impl AccountKind {
/// 徽标文字。
pub fn label(self) -> &'static str {
match self {
Self::Offline => "离线",
Self::Microsoft => "微软",
Self::Authlib => "第三方",
}
}
/// 稳定 id(事件/持久化用;与 core 的 `kind.as_str()` 同词表)。
pub fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
Self::Microsoft => "microsoft",
Self::Authlib => "authlib_injector",
}
}
/// 从 core 的稳定 id 解析(未知轨道按离线处理,不让一个坏条目毁掉整个列表)。
pub fn parse(raw: &str) -> Self {
match raw {
"microsoft" => Self::Microsoft,
"authlib_injector" => Self::Authlib,
_ => Self::Offline,
}
}
}
/// 账户列表行(**只含可公开字段**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountView {
/// 稳定 id(`account:...`)
pub id: String,
/// 展示名
pub name: String,
/// 轨道
pub kind: AccountKind,
/// 档案 UUID(公开)
pub profile_id: String,
/// 登录名(微软轨才有;可空)
pub login_name: String,
/// 创建时间展示串(`2026-10-31 20:12`)
pub created: String,
/// 是否是当前启动使用的账户
pub active: bool,
}
/// 微软登录阶段(UI 据此切换面板;两条流共用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeviceLoginPhase {
/// 没有登录在进行
Idle,
/// 正在向微软申请设备码(短暂)
Requesting,
/// 已拿到用户码,等待用户在浏览器完成
Waiting,
/// 成功(账户已入库)
Succeeded,
/// 失败(原因在 `message`,已脱敏)
Failed,
}
impl DeviceLoginPhase {
/// 面板是否要显示(非 Idle 都显示)。
pub fn visible(self) -> bool {
!matches!(self, Self::Idle)
}
/// 是否还在跑(Requesting / Waiting)。
pub fn busy(self) -> bool {
matches!(self, Self::Requesting | Self::Waiting)
}
}
/// 设备码登录的可见状态。
///
/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`——
/// 它们本来就是给用户看的,不是凭据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceLoginView {
/// 阶段
pub phase: DeviceLoginPhase,
/// 用户码(`ABCD-EFGH`)
pub user_code: String,
/// 验证地址(`https://www.microsoft.com/link`)
pub verification_uri: String,
/// 二维码内容(= 带码的链接;GUI 用文本展示,终端可直接打开)
pub qr_link: String,
/// 有效期(秒;0 = 未知)
pub expires_in: u64,
/// 状态/错误文案
pub message: String,
/// 成功后入库的账户展示名
pub account_name: String,
}
impl Default for DeviceLoginView {
fn default() -> Self {
Self::new()
}
}
impl DeviceLoginView {
/// 空闲态。
pub fn new() -> Self {
Self {
phase: DeviceLoginPhase::Idle,
user_code: String::new(),
verification_uri: String::new(),
qr_link: String::new(),
expires_in: 0,
message: String::new(),
account_name: String::new(),
}
}
/// 已拿到用户码(`present` 回调时刻)。
pub fn waiting(
user_code: &str,
verification_uri: &str,
expires_in: u64,
qr_hint: &str,
) -> Self {
let qr_link = if qr_hint.is_empty() {
format!("{verification_uri}?code={user_code}")
} else {
qr_hint.to_string()
};
Self {
phase: DeviceLoginPhase::Waiting,
user_code: user_code.to_string(),
verification_uri: verification_uri.to_string(),
qr_link,
expires_in,
message: format!(
"在浏览器打开 {verification_uri} 并输入用户码 {user_code}({} 秒内有效)",
expires_in
),
account_name: String::new(),
}
}
/// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。
///
/// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/
/// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。
pub fn browser_waiting(
authorization_url: &str,
expires_in: u64,
warning: Option<&str>,
) -> Self {
let mut message = format!(
"已在系统浏览器打开微软登录页,请完成授权({} 秒内有效);没弹出就把下面的链接粘到浏览器里",
expires_in.max(1)
);
if let Some(warning) = warning {
message.push_str(&format!("({warning})"));
}
Self {
phase: DeviceLoginPhase::Waiting,
user_code: String::new(),
verification_uri: authorization_url.to_string(),
qr_link: authorization_url.to_string(),
expires_in,
message,
account_name: String::new(),
}
}
/// 失败态。
pub fn failed(message: impl Into<String>) -> Self {
Self {
phase: DeviceLoginPhase::Failed,
message: message.into(),
..Self::new()
}
}
/// 成功态。
pub fn succeeded(account_name: impl Into<String>) -> Self {
let account_name = account_name.into();
Self {
phase: DeviceLoginPhase::Succeeded,
message: format!("已登录:{account_name}"),
account_name,
..Self::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_round_trips_through_stable_ids() {
for kind in [
AccountKind::Offline,
AccountKind::Microsoft,
AccountKind::Authlib,
] {
assert_eq!(AccountKind::parse(kind.as_str()), kind);
assert!(!kind.label().is_empty());
}
}
#[test]
fn device_login_view_never_carries_tokens() {
let view = DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert!(view.phase.busy());
assert!(view.qr_link.contains("ABCD-EFGH"));
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["token", "secret", "password", "refresh"] {
assert!(
!json.to_lowercase().contains(forbidden),
"视图里不该出现 {forbidden}"
);
}
}
#[test]
fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() {
let view = DeviceLoginView::browser_waiting(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
300,
Some("拉起浏览器失败"),
);
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert!(view.phase.busy());
assert!(view.user_code.is_empty(), "授权码流没有用户码");
assert_eq!(view.qr_link, view.verification_uri);
assert!(view.message.contains("浏览器"));
assert!(view.message.contains("拉起浏览器失败"), "{}", view.message);
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["refresh", "mc-access"] {
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
}
}
#[test]
fn succeeded_and_failed_clear_the_code() {
let ok = DeviceLoginView::succeeded("Steve");
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
assert!(ok.user_code.is_empty());
let bad = DeviceLoginView::failed("网络超时");
assert_eq!(bad.phase, DeviceLoginPhase::Failed);
assert_eq!(bad.message, "网络超时");
}
}
+6 -4
View File
@@ -1,7 +1,9 @@
//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。
use serde::{Deserialize, Serialize};
/// 会话消息角色。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChatRole {
/// 用户
User,
@@ -14,7 +16,7 @@ pub enum ChatRole {
}
/// 工具调用可视化条目。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCallView {
/// 工具名(与 ToolSpec 注册表同名)
pub name: String,
@@ -25,7 +27,7 @@ pub struct ToolCallView {
}
/// 一条会话消息。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage {
/// 角色
pub role: ChatRole,
@@ -60,7 +62,7 @@ impl ChatMessage {
}
/// agent 侧事件。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentEvent {
/// 追加一条完整消息
MessageAppended(ChatMessage),
+162 -11
View File
@@ -1,12 +1,46 @@
//! 事件与指令:core/agent → app 的事件队列,app → core/agent 的用户意图。
//!
//! 三条通道(01c §5):①不可变快照发布(`*Changed` 整体替换)②事件流(`LogAppended` /
//! `InstallProgress` / `DeviceLoginChanged`)③注入的显示服务(先落成事件,见 `DialogRequested`)。
//! 全部类型 `Serialize + Deserialize`:这是 IPC 契约(AGENTS §6)。
use serde::{Deserialize, Serialize};
use crate::dialogs::{DialogId, DialogRequest};
use crate::dto::{AgentEvent, DiagnosisReport, InstanceId, InstanceRuntime, InstanceSummary};
use crate::dto::{LogLine, LogSession, SessionId, VersionCategory, VersionSummary};
use crate::dto::CreateRequest;
use crate::dto::{
AccountView, AgentEvent, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, InstanceSummary, JavaPageView, LogLine, LogOutcome, LogSession, NoticeView,
SessionId, SettingGroupView, VersionCategory, VersionSummary,
};
use crate::toast::Toast;
/// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LiveLogBatch {
/// 会话 id
pub session: SessionId,
/// 新增行
pub lines: Vec<LogLine>,
/// 会话是否还在跑(false = 收尾,可以触发诊断/刷新列表)
pub running: bool,
}
/// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFinished {
/// 实例 id
pub id: InstanceId,
/// 会话 id
pub session: SessionId,
/// 结局
pub outcome: LogOutcome,
/// 一句人读结论(`正常退出` / `崩溃(exit 1)` / `启动失败:…`)
pub summary: String,
}
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CoreEvent {
/// 实例列表整体刷新
InstancesChanged(Vec<InstanceSummary>),
@@ -21,15 +55,31 @@ pub enum CoreEvent {
VersionListChanged(Vec<VersionSummary>),
/// 日志会话列表刷新
LogSessionsChanged(Vec<LogSession>),
/// 当前会话日志行刷新(P0 整批替换;P1 改增量追加)
/// 当前会话日志行刷新(整批替换)
LogLinesChanged(Vec<LogLine>),
/// 实时日志增量(启动中尾随 JSONL;`running=false` 表示会话收尾)
LogAppended(LiveLogBatch),
/// 启动收尾(磁贴状态回写 + toast)
LaunchFinished(LaunchFinished),
/// 诊断结论产出
DiagnosisReady(DiagnosisReport),
/// 账户列表刷新
AccountsChanged(Vec<AccountView>),
/// 微软登录状态刷新
DeviceLoginChanged(DeviceLoginView),
/// Java 页快照(发行版/回退链/运行时)
JavaChanged(JavaPageView),
/// 安装进度(`None` = 清掉进度面板)
InstallProgress(Option<InstallProgressView>),
/// 设置分组刷新(设置页右栏)
SettingsChanged(Vec<SettingGroupView>),
/// 横幅通知刷新(隔离切换提示/启动失败等)
NoticesChanged(Vec<NoticeView>),
}
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
/// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AppEvent {
/// 来自 core
Core(CoreEvent),
@@ -44,22 +94,58 @@ pub enum AppEvent {
}
/// app → core/agent 的用户意图。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AppCommand {
/// 重新扫描实例目录
RefreshInstances,
/// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
RefreshAll,
/// 启动实例
LaunchInstance(InstanceId),
/// 停止实例(含取消启动中的管线)
StopInstance(InstanceId),
/// 拉取某分类的版本列表
ListVersions(VersionCategory),
/// 安装指定版本
/// 安装指定版本(下载页行内「安装」按钮;原版)
InstallVersion(String),
/// 取消安装(阶段边界生效:当前阶段跑完就停,不再进下一阶段)
CancelInstall,
/// 新建实例(向导提交:版本 + 加载器 + 名字)
CreateInstance(CreateRequest),
/// 修改实例隔离档位(写实例设置文件)
SetIsolation {
/// 实例 id
id: InstanceId,
/// `isolated` / `shared`
mode: String,
},
/// 刷新日志会话列表
RefreshLogSessions,
/// 打开某个会话(拉取日志行 + 触发诊断)
OpenLogSession(SessionId),
/// 导入崩溃日志文件做规则分析(`hs_err_pid*.log` / `latest.log`)
ImportCrashLog(String),
/// 加一个离线账户
AddOfflineAccount(String),
/// 删除账户(按 id 或名字)
RemoveAccount(String),
/// 切换当前启动账户
SwitchAccount(String),
/// 开始微软设备码登录(CLI/无浏览器场景、备用通道)
StartDeviceLogin,
/// 开始微软授权码登录(拉起系统默认浏览器 + 本地回调,GUI 的默认入口)
StartBrowserLogin,
/// 取消/关掉微软登录面板(已发出的请求会在收尾时丢弃结果)
CancelDeviceLogin,
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
InstallJava(String),
/// 写一条设置(`key` 必须是 core schema 里的键)
SetSetting {
/// schema 键
key: String,
/// 新值(字符串形态;host 按 schema 类型转换)
value: String,
},
/// 关掉一条横幅通知
DismissNotice(String),
/// 向 AI 提问(会话状态在 app 侧,agent 只回事件)
AskAgent(String),
/// 中断当前 AI 流
@@ -68,7 +154,17 @@ pub enum AppCommand {
SetYolo(bool),
}
/// 后端通道:core/agent host 实现它;P0 用 [`crate::mock::MockBackend`] 验证通道语义。
impl AppCommand {
/// 构造「切换隔离档位」指令(磁贴/详情页共用)。
pub fn set_isolation(id: &str, mode: &str) -> Self {
Self::SetIsolation {
id: id.to_string(),
mode: mode.to_string(),
}
}
}
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
///
/// 轮询模型(而非回调)是为了让 Slint 的定时器成为唯一调度者,
/// 避免跨线程回调里碰 Slint 模型。
@@ -79,8 +175,63 @@ pub trait Backend {
/// 下发一条用户意图。
fn dispatch(&mut self, command: AppCommand);
/// 时间推进(mock 用脚本推进进度;真实后端可以是空实现)。
/// 时间推进(真实后端在这里收后台线程的结果并尾随日志)。
fn tick(&mut self, elapsed: std::time::Duration) {
let _ = elapsed;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage};
#[test]
fn events_are_serializable_ipc_contracts() {
let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView {
stage: InstallStage::Downloading,
files_done: 3,
files_total: 9,
..InstallProgressView::new()
})));
let text = serde_json::to_string(&event).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, event);
let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView {
id: "account:1".to_string(),
name: "Steve".to_string(),
kind: AccountKind::Offline,
profile_id: "0f8f".to_string(),
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active: true,
}]));
let text = serde_json::to_string(&account).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, account);
// 凭据永不跨界:序列化文本里没有 token 类字段
assert!(!text.to_lowercase().contains("token"));
}
#[test]
fn commands_round_trip() {
let command = AppCommand::CreateInstance(CreateRequest {
version: "1.20.4".to_string(),
loader: crate::dto::LoaderKind::Fabric,
loader_version: "0.16.5".to_string(),
name: "生存".to_string(),
});
let text = serde_json::to_string(&command).expect("序列化");
let back: AppCommand = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, command);
let login = AppCommand::StartDeviceLogin;
assert_eq!(
serde_json::from_str::<AppCommand>(&serde_json::to_string(&login).expect("序列化"))
.expect("反序列化"),
login
);
assert!(DeviceLoginPhase::Waiting.busy());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! 安装进度 DTO:下载页与新建向导共用的「一个安装任务」视图。
//!
//! 只放聚合量(文件计数 / 字节 / 速度 / 剩余时间)+ 一行状态文案:
//! 逐文件的明细留在 core 的 `InstallReport`,UI 不需要也不该持有。
//! 速度与 ETA 由 host 依据相邻两次字节数差分算(core 的事件只给绝对值)。
use serde::{Deserialize, Serialize};
/// 安装阶段。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InstallStage {
/// 解析版本清单 / 规划文件(联网取 metadata)
Planning,
/// 下载并校验文件
Downloading,
/// 落版本 JSON / 跑安装器
Installing,
/// 登记实例
Registering,
/// 完成
Done,
/// 失败
Failed,
/// 用户取消
Cancelled,
}
impl InstallStage {
/// 阶段文案。
pub fn label(self) -> &'static str {
match self {
Self::Planning => "解析版本",
Self::Downloading => "下载文件",
Self::Installing => "生成版本",
Self::Registering => "登记实例",
Self::Done => "已完成",
Self::Failed => "失败",
Self::Cancelled => "已取消",
}
}
/// 是否已经收尾(Done/Failed/Cancelled)。
pub fn is_terminal(self) -> bool {
matches!(self, Self::Done | Self::Failed | Self::Cancelled)
}
}
/// 一次安装的进度快照。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallProgressView {
/// 目标版本 id
pub version: String,
/// 实例名(默认 = 版本 id)
pub instance: String,
/// 加载器徽标文字(`原版` / `Fabric 0.16.5`)
pub loader: String,
/// 阶段
pub stage: InstallStage,
/// 已完成文件数
pub files_done: u64,
/// 文件总数
pub files_total: u64,
/// 已下载字节
pub bytes_done: u64,
/// 总字节(0 = 未知)
pub bytes_total: u64,
/// 当前速度(字节/秒;0 = 未知)
pub speed_bps: u64,
/// 剩余秒数(0 = 未知)
pub eta_secs: u64,
/// 状态/错误文案
pub message: String,
}
impl Default for InstallProgressView {
fn default() -> Self {
Self::new()
}
}
impl InstallProgressView {
/// 规划中的空快照。
pub fn new() -> Self {
Self {
version: String::new(),
instance: String::new(),
loader: String::new(),
stage: InstallStage::Planning,
files_done: 0,
files_total: 0,
bytes_done: 0,
bytes_total: 0,
speed_bps: 0,
eta_secs: 0,
message: String::new(),
}
}
/// 起一个任务的快照。
pub fn start(version: &str, instance: &str, loader: &str) -> Self {
Self {
version: version.to_string(),
instance: instance.to_string(),
loader: loader.to_string(),
stage: InstallStage::Planning,
message: "正在解析版本清单…".to_string(),
..Self::new()
}
}
/// 整体进度 0.0~1.0(优先文件计数,其次字节;都没有时 0)。
pub fn ratio(&self) -> f32 {
if self.files_total > 0 {
(self.files_done as f32 / self.files_total as f32).clamp(0.0, 1.0)
} else if self.bytes_total > 0 {
(self.bytes_done as f32 / self.bytes_total as f32).clamp(0.0, 1.0)
} else {
0.0
}
}
/// 进度是否已知(未知时进度条转不确定态)。
pub fn is_known(&self) -> bool {
self.files_total > 0 || self.bytes_total > 0
}
/// 速度文案(`1.2 MB/s`;未知给空串)。
pub fn speed_label(&self) -> String {
if self.speed_bps == 0 {
return String::new();
}
format!("{}/s", human_bytes(self.speed_bps))
}
/// 剩余时间文案(`剩 12s`;未知给空串)。
pub fn eta_label(&self) -> String {
if self.eta_secs == 0 {
return String::new();
}
if self.eta_secs >= 60 {
format!("剩 {}m{:02}s", self.eta_secs / 60, self.eta_secs % 60)
} else {
format!("剩 {}s", self.eta_secs)
}
}
/// 文件计数文案(`128/512 文件`)。
pub fn files_label(&self) -> String {
if self.files_total == 0 {
return String::new();
}
format!("{}/{} 文件", self.files_done, self.files_total)
}
}
/// 字节数 → 人读(`1.2 MB`)。
pub fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
/// 用相邻两次采样算速度与 ETA(`None` = 本次无法计算,保持上次值)。
pub fn derive_speed(
previous: (u64, std::time::Instant),
current: (u64, std::time::Instant),
bytes_total: u64,
) -> Option<(u64, u64)> {
let elapsed = current.1.saturating_duration_since(previous.1);
if elapsed.as_millis() < 50 {
return None;
}
let delta = current.0.saturating_sub(previous.0);
if delta == 0 {
return None;
}
let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
let speed = delta.saturating_mul(1000) / elapsed_ms.max(1);
let eta = if bytes_total > current.0 && speed > 0 {
(bytes_total - current.0) / speed
} else {
0
};
Some((speed, eta))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn ratio_prefers_file_counts() {
let mut view = InstallProgressView::start("1.20.4", "1.20.4", "原版");
assert_eq!(view.ratio(), 0.0);
assert!(!view.is_known());
view.files_total = 4;
view.files_done = 1;
view.bytes_total = 1000;
view.bytes_done = 900;
assert!((view.ratio() - 0.25).abs() < f32::EPSILON);
assert!(view.is_known());
assert_eq!(view.files_label(), "1/4 文件");
}
#[test]
fn speed_and_eta_labels_are_human_readable() {
let view = InstallProgressView {
speed_bps: 1_258_291,
eta_secs: 75,
..InstallProgressView::new()
};
assert_eq!(view.speed_label(), "1.2 MB/s");
assert_eq!(view.eta_label(), "剩 1m15s");
assert_eq!(InstallProgressView::new().speed_label(), "");
}
#[test]
fn derive_speed_needs_a_measurable_delta() {
let start = Instant::now();
let previous = (0, start);
// 间隔太短 → 不算
assert!(derive_speed(previous, (500, start + Duration::from_millis(10)), 1000).is_none());
// 正常差分:1s 下 500 字节 → 500 B/s,剩 500 字节 → 1s
let (speed, eta) =
derive_speed(previous, (500, start + Duration::from_secs(1)), 1000).expect("应能算");
assert_eq!(speed, 500);
assert_eq!(eta, 1);
}
#[test]
fn terminal_stages() {
assert!(InstallStage::Done.is_terminal());
assert!(InstallStage::Failed.is_terminal());
assert!(!InstallStage::Downloading.is_terminal());
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(2048), "2.0 KB");
}
}
+152 -7
View File
@@ -1,12 +1,14 @@
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
pub type InstanceId = String;
/// 加载器种类(磁贴徽标 + 详情页版本链)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoaderKind {
/// 原版(无加载器)
Vanilla,
@@ -37,10 +39,48 @@ impl LoaderKind {
Self::LiteLoader => "LiteLoader",
}
}
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 CLI 的 `--loader` 同词表)。
pub fn as_str(self) -> &'static str {
match self {
Self::Vanilla => "vanilla",
Self::Fabric => "fabric",
Self::Quilt => "quilt",
Self::Forge => "forge",
Self::NeoForge => "neoforge",
Self::OptiFine => "optifine",
Self::LiteLoader => "liteloader",
}
}
/// 从稳定 id 解析(未知给 `None`,由调用方决定报错文案)。
pub fn parse(raw: &str) -> Option<Self> {
Some(match raw.trim().to_ascii_lowercase().as_str() {
"vanilla" | "none" | "" => Self::Vanilla,
"fabric" => Self::Fabric,
"quilt" => Self::Quilt,
"forge" => Self::Forge,
"neoforge" => Self::NeoForge,
"optifine" => Self::OptiFine,
"liteloader" => Self::LiteLoader,
_ => return None,
})
}
/// 向导里的可选加载器(顺序即展示顺序)。
pub fn wizard_choices() -> [Self; 5] {
[
Self::Vanilla,
Self::Fabric,
Self::Quilt,
Self::Forge,
Self::NeoForge,
]
}
}
/// 加载器 + 版本(例如 Fabric 0.16.5)。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoaderInfo {
/// 加载器种类
pub kind: LoaderKind,
@@ -48,6 +88,15 @@ pub struct LoaderInfo {
pub version: String,
}
impl Default for LoaderInfo {
fn default() -> Self {
Self {
kind: LoaderKind::Vanilla,
version: String::new(),
}
}
}
impl LoaderInfo {
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
pub fn badge(&self) -> String {
@@ -60,7 +109,7 @@ impl LoaderInfo {
}
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Progress {
/// 已完成量
pub done: u64,
@@ -85,7 +134,7 @@ impl Progress {
}
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LaunchStage {
/// 下载资源
Download,
@@ -93,6 +142,8 @@ pub enum LaunchStage {
Verify,
/// 组装命令行并拉起进程
Launch,
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
Window,
}
impl LaunchStage {
@@ -101,13 +152,14 @@ impl LaunchStage {
match self {
Self::Download => "下载资源",
Self::Verify => "校验文件",
Self::Launch => "启动中",
Self::Launch => "组装命令行",
Self::Window => "等待窗口",
}
}
}
/// 实例运行时状态(磁贴的四种外观)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum InstanceRuntime {
/// 空闲
#[default]
@@ -132,7 +184,7 @@ pub enum InstanceRuntime {
}
/// 实例摘要:磁贴/列表视图唯一数据源。
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InstanceSummary {
/// 稳定 id
pub id: InstanceId,
@@ -148,4 +200,97 @@ pub struct InstanceSummary {
pub runtime: InstanceRuntime,
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
pub icon: Option<PathBuf>,
/// 隔离档位(`isolated` / `shared` / `custom`;未知为空)
pub isolation: String,
/// 该实例绑定的版本 id(详情页/启动用)
pub version_id: String,
/// 版本本体是否在盘上(缺载荷时磁贴亮黄条)
pub version_present: bool,
}
/// 新建实例向导提交给 host 的请求(已通过向导校验)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateRequest {
/// 目标原版版本 id(`1.20.4`)
pub version: String,
/// 加载器
pub loader: LoaderKind,
/// 加载器版本(空 = 让 core 取最新)
pub loader_version: String,
/// 实例名(空 = 用版本 id / 加载器版本 id)
pub name: String,
}
impl CreateRequest {
/// 实际使用的实例名。
pub fn instance_name(&self) -> String {
if self.name.trim().is_empty() {
self.version.clone()
} else {
self.name.trim().to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loader_badges_and_ids() {
let vanilla = LoaderInfo::default();
assert_eq!(vanilla.badge(), "原版");
let fabric = LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
};
assert_eq!(fabric.badge(), "Fabric 0.16.5");
for kind in LoaderKind::wizard_choices() {
assert_eq!(LoaderKind::parse(kind.as_str()), Some(kind));
}
assert_eq!(LoaderKind::parse("fabric"), Some(LoaderKind::Fabric));
assert_eq!(LoaderKind::parse("unknown"), None);
}
#[test]
fn progress_ratio_handles_unknown_total() {
assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0);
assert!((Progress { done: 1, total: 4 }.ratio() - 0.25).abs() < f32::EPSILON);
}
#[test]
fn create_request_defaults_name_to_version() {
let request = CreateRequest {
version: "1.20.4".to_string(),
loader: LoaderKind::Vanilla,
loader_version: String::new(),
name: " ".to_string(),
};
assert_eq!(request.instance_name(), "1.20.4");
}
#[test]
fn instance_summary_is_serde_round_trip_safe() {
let instance = InstanceSummary {
id: "inst-1".to_string(),
name: "生存".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: Some("2026-10-31T12:00:00.000Z".to_string()),
runtime: InstanceRuntime::Busy {
stage: LaunchStage::Window,
progress: Progress { done: 1, total: 2 },
},
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
};
let text = serde_json::to_string(&instance).expect("序列化");
let back: InstanceSummary = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, instance);
}
}
+234
View File
@@ -0,0 +1,234 @@
//! Java 页 DTO:四家发行版、回退链可视化与本地运行时清单(D23/D13)。
//!
//! 回退链是「可观测性」的载体:安装失败时用户要能看见「Temurin 跳过 → Oracle 失败 →
//! Microsoft 命中」这条链,而不是只拿到一句「下载失败」。链的每一步由 host 从
//! `core::game::java::source::ChainLog` 翻译过来。
use serde::{Deserialize, Serialize};
/// 一家发行版的展示行。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaVendorView {
/// 稳定 id(`temurin` / `oracle` / `microsoft` / `zulu`)
pub id: String,
/// 展示名
pub display: String,
/// 优先级序号(1 起)
pub rank: u32,
/// 支持的 Java 大版本摘要(`21/17/11`)
pub majors: String,
/// 可用源(镜像 + 吞吐备注)
pub sources: Vec<String>,
/// 是否已装过这家(托管目录里有现成的)
pub installed: bool,
}
/// 回退链一步的结局。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum JavaChainOutcome {
/// 还没尝试(安装前的计划链)
Pending,
/// 跳过(不支持目标大版本)
Skip,
/// 尝试过但失败
Failed,
/// 命中(下载/安装成功)
Hit,
}
impl JavaChainOutcome {
/// 徽标文字。
pub fn label(self) -> &'static str {
match self {
Self::Pending => "待尝试",
Self::Skip => "跳过",
Self::Failed => "失败",
Self::Hit => "命中",
}
}
/// 从 core 的 `VendorAttempt` 结局字符串解析。
pub fn parse(raw: &str) -> Self {
match raw {
"hit" => Self::Hit,
"failed" => Self::Failed,
"skip" | "skipped" => Self::Skip,
_ => Self::Pending,
}
}
}
/// 回退链一步。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaChainStep {
/// 发行版 id
pub vendor: String,
/// 结局
pub outcome: JavaChainOutcome,
/// 细节(跳过原因 / 失败摘要 / 命中的下载源)
pub detail: String,
}
/// 本地已发现的 Java 运行时。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaRuntimeView {
/// 可执行文件路径
pub binary: String,
/// 版本串(`17.0.9`)
pub version: String,
/// 大版本
pub major: u32,
/// 发行方
pub vendor: String,
/// 来源分类(system / managed / instance)
pub origin: String,
}
/// Java 页快照(一次事件整体替换)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaPageView {
/// 地区判定(`domestic` / `global`)
pub region: String,
/// 优先级的来源(`region_default` / `java.vendorPriority`)
pub priority_source: String,
/// 生效顺序(`temurin → oracle → microsoft → zulu`)
pub order_text: String,
/// 四家展示行(按生效顺序)
pub vendors: Vec<JavaVendorView>,
/// 回退链(计划链或上一次安装的真实链)
pub chain: Vec<JavaChainStep>,
/// 本地运行时
pub runtimes: Vec<JavaRuntimeView>,
/// 托管目录
pub managed_roots: Vec<String>,
/// 是否有 Java 安装在跑
pub busy: bool,
/// 状态/错误文案
pub message: String,
}
impl Default for JavaPageView {
fn default() -> Self {
Self::new()
}
}
impl JavaPageView {
/// 空态(托管目录还没扫到东西)。
pub fn new() -> Self {
Self {
region: "global".to_string(),
priority_source: "region_default".to_string(),
order_text: String::new(),
vendors: Vec::new(),
chain: Vec::new(),
runtimes: Vec::new(),
managed_roots: Vec::new(),
busy: false,
message: String::new(),
}
}
/// 计划链:按生效顺序铺一排「待尝试」(安装前的可视化)。
pub fn planned_chain(vendors: &[JavaVendorView]) -> Vec<JavaChainStep> {
vendors
.iter()
.map(|vendor| JavaChainStep {
vendor: vendor.id.clone(),
outcome: JavaChainOutcome::Pending,
detail: String::new(),
})
.collect()
}
/// 命中的那一家的展示名(`None` = 链上还没命中)。
pub fn hit_vendor(&self) -> Option<&str> {
self.chain
.iter()
.find(|step| step.outcome == JavaChainOutcome::Hit)
.map(|step| step.vendor.as_str())
}
/// 已安装的大版本列表(去重升序)。
pub fn majors(&self) -> Vec<u32> {
let mut majors: Vec<u32> = self.runtimes.iter().map(|runtime| runtime.major).collect();
majors.sort_unstable();
majors.dedup();
majors
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vendor(id: &str, rank: u32) -> JavaVendorView {
JavaVendorView {
id: id.to_string(),
display: id.to_uppercase(),
rank,
majors: "21/17".to_string(),
sources: Vec::new(),
installed: false,
}
}
#[test]
fn planned_chain_is_all_pending_in_rank_order() {
let vendors = vec![vendor("temurin", 1), vendor("oracle", 2)];
let chain = JavaPageView::planned_chain(&vendors);
assert_eq!(chain.len(), 2);
assert!(chain
.iter()
.all(|step| step.outcome == JavaChainOutcome::Pending));
assert_eq!(chain[0].vendor, "temurin");
}
#[test]
fn hit_vendor_finds_the_hit_step() {
let mut page = JavaPageView::new();
page.chain = vec![
JavaChainStep {
vendor: "temurin".to_string(),
outcome: JavaChainOutcome::Skip,
detail: "不支持 8".to_string(),
},
JavaChainStep {
vendor: "oracle".to_string(),
outcome: JavaChainOutcome::Hit,
detail: "BMCLAPI".to_string(),
},
];
assert_eq!(page.hit_vendor(), Some("oracle"));
}
#[test]
fn outcome_parses_core_words() {
assert_eq!(JavaChainOutcome::parse("hit"), JavaChainOutcome::Hit);
assert_eq!(JavaChainOutcome::parse("failed"), JavaChainOutcome::Failed);
assert_eq!(JavaChainOutcome::parse("skip"), JavaChainOutcome::Skip);
assert_eq!(JavaChainOutcome::parse("?"), JavaChainOutcome::Pending);
}
#[test]
fn majors_are_deduped_and_sorted() {
let mut page = JavaPageView::new();
page.runtimes = vec![
JavaRuntimeView {
binary: "a".to_string(),
version: "17".to_string(),
major: 17,
vendor: "Temurin".to_string(),
origin: "managed".to_string(),
},
JavaRuntimeView {
binary: "b".to_string(),
version: "17".to_string(),
major: 17,
vendor: "Oracle".to_string(),
origin: "system".to_string(),
},
];
assert_eq!(page.majors(), vec![17]);
}
}
+8 -6
View File
@@ -1,10 +1,12 @@
//! 日志页 DTO:会话列表、日志行与 D8 诊断结论。
use serde::{Deserialize, Serialize};
/// 一次启动会话(日志页左栏单位)标识。
pub type SessionId = String;
/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LogOutcome {
/// 正常退出
Ok,
@@ -15,7 +17,7 @@ pub enum LogOutcome {
}
/// 会话摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogSession {
/// 会话 id
pub id: SessionId,
@@ -30,7 +32,7 @@ pub struct LogSession {
}
/// 日志行等级(错误行高亮)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LogLevel {
/// debug
Debug,
@@ -43,7 +45,7 @@ pub enum LogLevel {
}
/// 一行日志。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogLine {
/// 原始文本(含时间戳前缀)
pub text: String,
@@ -52,7 +54,7 @@ pub struct LogLine {
}
/// D8 规则引擎结论(日志页崩溃诊断卡片)。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Diagnosis {
/// 命中规则 id(`gpu.driver_old`)
pub rule: String,
@@ -67,7 +69,7 @@ pub struct Diagnosis {
}
/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosisReport {
/// 归属会话
pub session: SessionId,
+21 -5
View File
@@ -2,22 +2,38 @@
//!
//! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*`
//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型,
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架。
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架,
//! P3 起由 [`crate::host`] 的真实后端驱动全部页面。
//!
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / events),
//! 全量 `pub use` 出去,调用方依旧写 `crate::dto::InstanceSummary` 这类路径。
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java /
//! settings / install / notices / events),全量 `pub use` 出去,调用方依旧写
//! `crate::dto::InstanceSummary` 这类路径。
//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`。
//!
//! 全部跨界类型 `Serialize + Deserialize`(AGENTS §6):事件与快照是 IPC 契约,
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
mod accounts;
mod agent;
mod events;
mod install;
mod instances;
mod java;
mod logs;
mod notices;
mod settings;
mod versions;
pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView};
pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView};
pub use events::{AppCommand, AppEvent, Backend, CoreEvent};
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
pub use instances::{
InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress,
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo,
LoaderKind, Progress,
};
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId};
pub use notices::{NoticeLevel, NoticeView};
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary};
+73
View File
@@ -0,0 +1,73 @@
//! 横幅通知 DTO:实例页/下载页顶部的「需要注意」提示条。
//!
//! 与 Toast 的分工:Toast 是「刚才发生了什么」(3 秒消失),通知条是
//! 「现在需要你处理」(用户点了才收)。隔离模式切换提示(M30 `ModeChangeWarning`)
//! 与启动失败都用它。
use serde::{Deserialize, Serialize};
/// 通知级别。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NoticeLevel {
/// 一般信息
Info,
/// 需要留意(黄条)
Warn,
/// 失败(红条)
Error,
}
impl NoticeLevel {
/// 级别文案(无障碍/测试用)。
pub fn label(self) -> &'static str {
match self {
Self::Info => "信息",
Self::Warn => "注意",
Self::Error => "错误",
}
}
}
/// 一条通知。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NoticeView {
/// 稳定 id(关闭时按它移除)
pub id: String,
/// 级别
pub level: NoticeLevel,
/// 标题
pub title: String,
/// 正文
pub body: String,
}
impl NoticeView {
/// 建一条。
pub fn new(id: &str, level: NoticeLevel, title: &str, body: &str) -> Self {
Self {
id: id.to_string(),
level,
title: title.to_string(),
body: body.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notices_are_serde_round_trip_safe() {
let notice = NoticeView::new(
"isolation:demo",
NoticeLevel::Warn,
"隔离模式已切换",
"旧目录里还有 mods/saves",
);
let text = serde_json::to_string(&notice).expect("序列化");
let back: NoticeView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, notice);
assert_eq!(back.level.label(), "注意");
}
}
+220
View File
@@ -0,0 +1,220 @@
//! 设置页 DTO:设置分组的通用行模型。
//!
//! 「行」是 UI 与 `core::setting` schema 之间的唯一形状:一行 = 一个键 + 一种控件 +
//! 当前值。写回走 [`crate::dto::AppCommand::SetSetting`],由 host 用 `SettingStore::set`
//! 落盘(不自己拼 JSON、不另立配置文件,`AGENTS §6`)。
use serde::{Deserialize, Serialize};
/// 控件类型(决定设置页画什么)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SettingControl {
/// 开关
Toggle,
/// 分段单选(`options` + `selected`)
Choice,
/// 单行文本(`value_text`)
Text,
/// 只读展示(当前路径/当前版本这类)
ReadOnly,
/// 动作按钮(`action` 给回调 id)
Action,
}
/// 一行设置。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingRowView {
/// schema 键(`download.concurrency`)
pub key: String,
/// 行标题
pub label: String,
/// 说明/当前值附注
pub description: String,
/// 控件
pub control: SettingControl,
/// 开关值
pub flag: bool,
/// 单选序号
pub selected: i32,
/// 单选项文案
pub options: Vec<String>,
/// 文本值
pub value_text: String,
/// 动作 id(`detect_region` 这类)
pub action: String,
}
impl SettingRowView {
/// 开关行。
pub fn toggle(key: &str, label: &str, description: &str, value: bool) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Toggle,
flag: value,
selected: 0,
options: Vec::new(),
value_text: String::new(),
action: String::new(),
}
}
/// 单选行。
pub fn choice(
key: &str,
label: &str,
description: &str,
options: &[&str],
selected: i32,
) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Choice,
flag: false,
selected,
options: options.iter().map(|item| (*item).to_string()).collect(),
value_text: String::new(),
action: String::new(),
}
}
/// 文本行。
pub fn text(key: &str, label: &str, description: &str, value: &str) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Text,
flag: false,
selected: 0,
options: Vec::new(),
value_text: value.to_string(),
action: String::new(),
}
}
/// 只读行。
pub fn read_only(label: &str, value: &str) -> Self {
Self {
key: String::new(),
label: label.to_string(),
description: value.to_string(),
control: SettingControl::ReadOnly,
flag: false,
selected: 0,
options: Vec::new(),
value_text: value.to_string(),
action: String::new(),
}
}
/// 动作行。
pub fn action(key: &str, label: &str, description: &str, action: &str) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Action,
flag: false,
selected: 0,
options: Vec::new(),
value_text: String::new(),
action: action.to_string(),
}
}
}
/// 一个设置分组(设置页右栏的一段)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingGroupView {
/// 分组 id(与设置页子导航的 `SettingSection` 同名:download / java / data / ai / mcp)
pub id: String,
/// 分组标题
pub title: String,
/// 行
pub rows: Vec<SettingRowView>,
}
impl SettingGroupView {
/// 建组。
pub fn new(id: &str, title: &str) -> Self {
Self {
id: id.to_string(),
title: title.to_string(),
rows: Vec::new(),
}
}
/// 追加一行(链式)。
pub fn with(mut self, row: SettingRowView) -> Self {
self.rows.push(row);
self
}
}
/// 在分组快照里找一个键的当前值(设置页回填用)。
pub fn find_row<'a>(groups: &'a [SettingGroupView], key: &str) -> Option<&'a SettingRowView> {
groups
.iter()
.flat_map(|group| group.rows.iter())
.find(|row| row.key == key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_carry_exactly_one_control_kind() {
let toggle = SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true);
assert_eq!(toggle.control, SettingControl::Toggle);
assert!(toggle.flag);
let choice = SettingRowView::choice("download.region", "地区", "", &["auto", "cn"], 1);
assert_eq!(choice.options, vec!["auto", "cn"]);
assert_eq!(choice.selected, 1);
let action = SettingRowView::action("java.action", "检测", "", "detect_region");
assert_eq!(action.action, "detect_region");
}
#[test]
fn find_row_scans_all_groups() {
let groups = vec![
SettingGroupView::new("download", "下载").with(SettingRowView::text(
"download.proxy_url",
"代理",
"",
"",
)),
SettingGroupView::new("mcp", "MCP").with(SettingRowView::toggle(
"agent.mcp.enabled",
"开关",
"",
false,
)),
];
assert_eq!(
find_row(&groups, "agent.mcp.enabled").map(|row| row.label.as_str()),
Some("开关")
);
assert!(find_row(&groups, "nope").is_none());
}
#[test]
fn rows_are_serde_round_trip_safe() {
let group = SettingGroupView::new("download", "下载").with(SettingRowView::choice(
"download.concurrency",
"并发",
"",
&["4", "8", "16"],
1,
));
let text = serde_json::to_string(&group).expect("序列化");
let back: SettingGroupView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, group);
}
}
+6 -4
View File
@@ -1,7 +1,9 @@
//! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。
use serde::{Deserialize, Serialize};
/// 版本类型(远古版按 unlisted-versions 的分类拆开)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VersionKind {
/// 正式版
Release,
@@ -12,7 +14,7 @@ pub enum VersionKind {
}
/// 远古版子类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AncientKind {
/// classic(rd-*)
Classic,
@@ -32,7 +34,7 @@ impl VersionKind {
}
/// 下载页版本行。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionSummary {
/// 版本 id(如 `1.20.4` / `rd-132211`)
pub id: String,
@@ -45,7 +47,7 @@ pub struct VersionSummary {
}
/// 下载页左侧分类(页内常驻侧栏的选中项)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VersionCategory {
/// 正式版
Release,
+274
View File
@@ -0,0 +1,274 @@
//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软登录(两条流)。
//!
//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现,
//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。
//!
//! 两条登录流:授权码([`start_browser_login`],GUI 默认:拉起系统浏览器 + 本地回调,
//! `research/08` §3 方案 A)与设备码([`start_login`],无浏览器/CLI 场景)。除了「怎么拿到
//! MS access_token」之外完全共用:同一个取消槽位、同一段账户入库、同一个刷新链。
//!
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::auth::account::Credentials;
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession};
use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::{AccountStore, AuthResult};
use unlucky_core::download::blocking::block_on;
use crate::dto::{AccountKind, AccountView, DeviceLoginView};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
use super::opener;
/// 账户列表(公开字段)。
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
let store = AccountStore::open(env.layout.auth_data());
store
.accounts()
.iter()
.map(|meta| AccountView {
id: meta.id.as_str().to_string(),
name: meta.display_name().to_string(),
kind: AccountKind::parse(meta.kind.as_str()),
profile_id: meta.profile_id.clone(),
login_name: meta.login_name.clone().unwrap_or_default(),
created: display_ms(meta.created_at_ms),
active: active.is_some_and(|id| id == meta.id.as_str()),
})
.collect()
}
/// 毫秒时间戳 → `2026-10-31 20:12`(UTC;账户列表只用来排序/辨认)。
pub fn display_ms(ms: u64) -> String {
if ms == 0 {
return String::new();
}
let total_secs = ms / 1000;
let days = total_secs / 86_400;
let rem = total_secs % 86_400;
let (year, month, day) = civil_from_days(i64::try_from(days).unwrap_or(0));
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
rem / 3_600,
(rem % 3_600) / 60
)
}
/// Howard Hinnant 的 `civil_from_days`(1970-01-01 为 0)。
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
}
/// 加一个离线账户。
///
/// # Errors
///
/// 名字不合法、同名冲突、落盘失败时返回用户可读文案。
pub fn add_offline(env: &HostEnv, name: &str) -> Result<String, String> {
let name = name.trim();
let meta = offline_meta(name, now_ms()).map_err(|error| error.to_string())?;
let display = meta.display_name().to_string();
let mut store = AccountStore::open(env.layout.auth_data());
store
.insert(meta, Credentials::Offline)
.map_err(|error| format!("添加账户失败:{error}"))?;
store
.save()
.map_err(|error| format!("保存账户失败:{error}"))?;
Ok(display)
}
/// 删除账户(按 id 或名字)。
///
/// # Errors
///
/// 找不到、落盘失败时返回用户可读文案。
pub fn remove(env: &HostEnv, key: &str) -> Result<String, String> {
let mut store = AccountStore::open(env.layout.auth_data());
let meta = store
.remove(key)
.map_err(|error| format!("删除账户失败:{error}"))?;
store
.save()
.map_err(|error| format!("保存账户失败:{error}"))?;
Ok(meta.display_name().to_string())
}
/// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。
#[derive(Debug, Clone, Default)]
pub struct LoginJob {
/// 用户点了取消(线程收尾时据此丢弃结果)
pub cancelled: Arc<AtomicBool>,
}
impl LoginJob {
/// 新建一个未取消的句柄。
pub fn new() -> Self {
Self {
cancelled: Arc::new(AtomicBool::new(false)),
}
}
/// 取消。
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
}
/// 是否已取消。
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
}
}
/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-login", move || {
let auth = match microsoft_auth(&env) {
Ok(auth) => auth,
Err(message) => return fail(&tx, message),
};
let tx_prompt = tx.clone();
let result = block_on(auth.device_code_login(|prompt| {
let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::waiting(
&prompt.user_code,
&prompt.verification_uri,
prompt.expires_in.as_secs(),
&format!("{}#{}", prompt.verification_uri, prompt.user_code),
))));
}));
finish(&env, &tx, &job, result);
});
}
/// 起一次授权码登录(后台线程:绑本地端口 → 拉起系统默认浏览器 → 等回调 → 换链)。
///
/// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的
/// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。
pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-authcode", move || {
let auth = match microsoft_auth(&env) {
Ok(auth) => auth,
Err(message) => return fail(&tx, message),
};
let tx_prompt = tx.clone();
let result = block_on(auth.authcode_login(|prompt| {
// 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。
let opened = opener::open_url(&prompt.authorization_url);
let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting(
&prompt.authorization_url,
prompt.timeout.as_secs(),
opened.err().as_deref(),
))));
}));
finish(&env, &tx, &job, result);
});
}
/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。
fn microsoft_auth(env: &HostEnv) -> Result<MicrosoftAuth, String> {
MicrosoftAuth::from_env()
.map(|auth| {
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
env.layout.logs(),
)))
})
.map_err(|error| error.to_string())
}
fn fail(tx: &JobSender, message: String) {
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(message))));
}
/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。
fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult<MicrosoftSession>) {
if job.is_cancelled() {
let _ = tx.send(HostTask::Login(Box::default()));
return;
}
match result {
Ok(session) => match store_session(env, session) {
Ok(name) => {
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name))));
let _ = tx.send(HostTask::Accounts(accounts(env, None)));
}
Err(message) => fail(tx, message),
},
Err(error) => fail(tx, error.to_string()),
}
}
/// 微软会话入库(元数据 + 凭据分文件,见 `core::auth::AccountStore`)。
fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result<String, String> {
let meta = unlucky_core::auth::account::AccountMeta::new(
unlucky_core::auth::account::AccountKind::Microsoft,
session.profile_id.clone(),
session.profile_name.clone(),
now_ms(),
);
let name = meta.display_name().to_string();
let mut store = AccountStore::open(env.layout.auth_data());
store
.insert(meta, Credentials::Microsoft(session))
.and_then(|()| store.save())
.map_err(|error| format!("账户入库失败:{error}"))?;
Ok(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn time_display_matches_known_timestamps() {
assert_eq!(display_ms(0), "");
// 1_769_000_000_000 ms = 2026-01-21T12:53:20Z(取整到分钟)
assert_eq!(display_ms(1_769_000_000_000), "2026-01-21 12:53");
}
#[test]
fn offline_add_list_remove_round_trips() {
let dir = std::env::temp_dir().join(format!("ull-host-accounts-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");
let name = add_offline(&env, "Steve").expect("加账户");
assert_eq!(name, "Steve");
let list = accounts(&env, None);
assert_eq!(list.len(), 1);
assert_eq!(list[0].kind, AccountKind::Offline);
assert!(!list[0].active);
let active = accounts(&env, Some(list[0].id.as_str()));
assert!(active[0].active);
let removed = remove(&env, "Steve").expect("删账户");
assert_eq!(removed, "Steve");
assert!(accounts(&env, None).is_empty());
assert!(add_offline(&env, " ").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn login_job_cancel_flag_is_shared() {
let job = LoginJob::new();
let clone = job.clone();
assert!(!clone.is_cancelled());
job.cancel();
assert!(clone.is_cancelled());
}
}
+162
View File
@@ -0,0 +1,162 @@
//! `CoreHost::dispatch` 的指令实现(从 `host/mod.rs` 拆出,守 400 行上限)。
//!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.env` / `self.tx` 等。
use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind};
use crate::toast::ToastKind;
use super::accounts::{self, LoginJob};
use super::{game, install, java, launch, logs, settings, versions, CoreHost};
impl CoreHost {
/// 下发一条用户意图(`Backend::dispatch` 的实现体)。
pub(super) fn dispatch_command(&mut self, command: AppCommand) {
self.dispatch_inner(command);
}
}
impl CoreHost {
/// 指令分发(见 `dispatch_command`)。
fn dispatch_inner(&mut self, command: AppCommand) {
match command {
AppCommand::RefreshAll => self.refresh_all(),
AppCommand::LaunchInstance(id) => self.start_launch(&id),
AppCommand::StopInstance(id) => match self.running_pid(&id) {
Some(pid) => match launch::stop(pid) {
Ok(()) => self.toast(ToastKind::Info, "已请求停止游戏进程"),
Err(reason) => self.toast(ToastKind::Error, reason),
},
None => self.toast(ToastKind::Warn, "这个实例当前没有在跑"),
},
AppCommand::ListVersions(_) => versions::spawn_list(self.env.clone(), self.tx.clone()),
AppCommand::InstallVersion(version) => {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
install::spawn_install(
self.env.clone(),
self.tx.clone(),
CreateRequest {
name: version.clone(),
version,
loader: LoaderKind::Vanilla,
loader_version: String::new(),
},
cancel,
);
}
AppCommand::CreateInstance(request) => {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
install::spawn_install(self.env.clone(), self.tx.clone(), request, cancel);
}
AppCommand::CancelInstall => {
// 取消只在阶段边界生效(core 的下载循环没有取消钩子):这里先把面板收回,
// 后台线程会在下一阶段开始前看到标记并收尾。
if let Some(cancel) = &self.install_cancel {
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
}
self.install = None;
self.send(CoreEvent::InstallProgress(None));
}
AppCommand::SetIsolation { id, mode } => {
match game::set_isolation(&self.env, &id, &mode) {
Ok(()) => {
self.toast(ToastKind::Success, "隔离档位已更新");
self.refresh_instances();
}
Err(reason) => self.toast(ToastKind::Error, reason),
}
}
AppCommand::RefreshLogSessions => {
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
}
AppCommand::OpenLogSession(id) => self.open_session(&id),
AppCommand::ImportCrashLog(path) => match logs::analyze_crash_file(&path) {
Ok((label, report)) => {
self.send(CoreEvent::DiagnosisReady(report));
self.toast(ToastKind::Info, label);
}
Err(reason) => self.toast(ToastKind::Warn, reason),
},
AppCommand::AddOfflineAccount(name) => match accounts::add_offline(&self.env, &name) {
Ok(display) => {
self.toast(ToastKind::Success, format!("已添加离线账户 {display}"));
self.refresh_accounts();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::RemoveAccount(key) => match accounts::remove(&self.env, &key) {
Ok(display) => {
if self.active_account.as_deref() == Some(key.as_str()) {
self.active_account = None;
}
self.toast(ToastKind::Info, format!("已删除账户 {display}"));
self.refresh_accounts();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::SwitchAccount(id) => {
self.active_account = Some(id);
self.refresh_accounts();
self.toast(ToastKind::Info, "已切换启动账户");
}
AppCommand::StartDeviceLogin => {
self.login = Some(LoginJob::new());
let job = self.login.clone().unwrap_or_default();
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
phase: crate::dto::DeviceLoginPhase::Requesting,
message: "正在向微软申请设备码…".to_string(),
..crate::dto::DeviceLoginView::new()
}));
accounts::start_login(self.env.clone(), self.tx.clone(), job);
}
AppCommand::StartBrowserLogin => {
self.login = Some(LoginJob::new());
let job = self.login.clone().unwrap_or_default();
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
phase: crate::dto::DeviceLoginPhase::Requesting,
message: "正在准备浏览器登录(本地回调端口)…".to_string(),
..crate::dto::DeviceLoginView::new()
}));
accounts::start_browser_login(self.env.clone(), self.tx.clone(), job);
}
AppCommand::CancelDeviceLogin => {
if let Some(job) = &self.login {
job.cancel();
}
self.login = None;
self.send(CoreEvent::DeviceLoginChanged(
crate::dto::DeviceLoginView::new(),
));
}
AppCommand::InstallJava(vendor) => {
self.java_busy = true;
self.java_message = format!("正在安装 Java({vendor})…");
self.refresh_java();
java::spawn_install(
self.env.clone(),
self.tx.clone(),
unlucky_core::game::java::JdkVendor::parse(&vendor),
);
}
AppCommand::SetSetting { key, value } => match settings::set(&self.env, &key, &value) {
Ok(()) => {
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
self.java_chain.clear();
self.refresh_java();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::DismissNotice(id) => {
self.notices.retain(|notice| notice.id != id);
self.send(CoreEvent::NoticesChanged(self.notices.clone()));
}
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
self.chat.dispatch(other);
for event in self.chat.drain_events() {
self.events.push_back(event);
}
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
//! 下载引擎与 Java 落点的装配(versions / install / java 三个任务共用)。
//!
//! 与 CLI `install::build_engine` 同源:内容寻址缓存落 `.ULL/cache/`,地区判定走
//! 24h 缓存 + 环境信号(**不做网络探测**,与 D7 的「不探测时」约定一致)。
use std::path::PathBuf;
use std::sync::Arc;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
use unlucky_core::download::region_system::detect_signals;
use unlucky_core::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable,
};
use unlucky_core::game::java::vendor::JdkVendor;
use unlucky_core::game::java::{self, JavaInstallRoot};
use unlucky_core::platform::paths::ProcessEnv;
use super::env::HostEnv;
/// 建下载引擎(缓存/健康度/地区判定都在数据根里)。
pub fn engine(env: &HostEnv) -> DownloadEngine {
let cache_root = env.layout.cache();
let now = now_ms();
let region_path = cache_root.join("region.json");
let region = load_cached(&region_path, now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
let _ = store_cached(&region_path, &profile);
profile
});
DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(region),
Arc::new(HealthRegistry::open_in(&cache_root, now)),
Arc::new(ContentCache::open(&cache_root)),
)
}
/// 是否国内网络(决定默认发行版顺序与镜像优先级)。
pub fn domestic(env: &HostEnv) -> bool {
engine(env).region().is_domestic()
}
/// 设置里的 `java.vendorPriority`(空 = 按地区默认序)。
pub fn java_priority(env: &HostEnv) -> Vec<JdkVendor> {
env.store()
.effective("java.vendorPriority")
.and_then(serde_json::Value::as_array)
.map(|items| {
let texts: Vec<String> = items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect();
unlucky_core::game::java::vendor::parse_priority(&texts)
})
.unwrap_or_default()
}
/// 设置里的 `java.installRoot`(空 = 按数据目录模式派生)。
pub fn java_install_override(env: &HostEnv) -> Option<PathBuf> {
env.store()
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from)
}
/// 托管 JDK 落点(解析失败 → 用户可读文案)。
pub fn java_install_root(env: &HostEnv) -> Result<JavaInstallRoot, String> {
let override_root = java_install_override(env);
JavaInstallRoot::resolve(
env.mode(),
&env.layout,
override_root.as_deref(),
env.platform,
&ProcessEnv,
)
.map_err(|error| error.to_string())
}
/// 托管 JDK 可能存在的全部安装根(扫描运行时用)。
pub fn java_scan_roots(env: &HostEnv) -> Vec<PathBuf> {
java::location::scan_roots(
env.mode(),
&env.layout,
java_install_override(env).as_deref(),
env.platform,
&ProcessEnv,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_uses_the_data_root_cache() {
let dir = std::env::temp_dir().join(format!("ull-host-engine-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.cache()).expect("cache");
let engine = engine(&env);
assert_eq!(engine.config().max_concurrency, 64);
// 地区判定不联网:只读缓存/信号
let _ = domestic(&env);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn java_merge_priority_reads_settings() {
let dir = std::env::temp_dir().join(format!("ull-host-engine-p-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
std::fs::write(
env.launcher_config(),
r#"{"java":{"vendorPriority":["zulu"]}}"#,
)
.expect("写配置");
assert_eq!(java_priority(&env), vec![JdkVendor::Zulu]);
std::fs::remove_dir_all(&dir).ok();
}
}
+170
View File
@@ -0,0 +1,170 @@
//! host 的环境快照:一份 `ResolvedData` + 由其派生的布局视图。
//!
//! app 自己不解析三模式数据根(那是 `core::platform::data_dir` 的活);这里只做两件事:
//! ①按「exe 同级便携 `launcher.json` 的 `core.*` 四键」拼请求;②把解析结果与
//! `Platform` / `DataMode` 打成一个可克隆的结构,交给后台线程。
//!
//! 截图/演示通道用 [`HostEnv::at_root`] 直接钉一个根(`<root>/.ULL` + `<root>/.minecraft`)。
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver};
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::paths::Platform;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
/// 数据根决定键(与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
pub(crate) const DATA_ROOT_KEYS: [&str; 4] = [
"core.data_mode",
"core.data_custom_root",
"core.launcher_home",
"core.game_home",
];
/// host 运行期需要的一切路径/平台信息(可克隆进线程)。
#[derive(Debug, Clone)]
pub struct HostEnv {
/// 数据根解析结果
pub data: ResolvedData,
/// `.ULL/` 布局
pub layout: LauncherLayout,
/// `.minecraft/` 布局
pub game: GameLayout,
/// 平台
pub platform: Platform,
}
impl HostEnv {
/// 按便携目录的 `core.*` 解析真实数据根;解析不出来返回 `None`(host 退回 mock)。
pub fn resolve() -> Option<Self> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf))?;
let request = request_from_portable(&exe_dir);
let resolver = Resolver::new(Platform::current(), Some(exe_dir));
let data = resolver.resolve_default(&request).ok()?.clone();
Some(Self::from_data(data))
}
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`(截图/演示通道)。
///
/// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**:
/// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。
pub fn at_root(root: impl AsRef<Path>) -> Self {
let root = root.as_ref();
let request = request_from_portable(root);
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
Self::from_data(ResolvedData {
mode: DataMode::Portable,
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
redirected_from: None,
})
}
/// 由解析结果建环境。
pub fn from_data(data: ResolvedData) -> Self {
Self {
layout: data.layout(),
game: GameLayout::new(data.game_home.clone()),
platform: Platform::current(),
data,
}
}
/// `config/launcher.json` 路径。
pub fn launcher_config(&self) -> PathBuf {
self.layout.launcher_config()
}
/// 设置存储(每次现读一份;写操作自己 save)。
pub fn store(&self) -> SettingStore {
SettingStore::load(self.launcher_config(), launcher_schema())
}
/// 启动会话根(`.ULL/data/agent/sessions`)。
pub fn sessions_root(&self) -> PathBuf {
self.layout.agent_sessions()
}
/// 数据目录模式。
pub fn mode(&self) -> DataMode {
self.data.mode
}
/// 人读一行(设置页「数据」分组展示)。
pub fn data_line(&self) -> String {
format!(
"{} · 启动器 {} · 游戏 {}",
self.data.mode,
self.data.launcher_home.display(),
self.data.game_home.display()
)
}
}
/// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。
pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
let mut request = DataDirRequest::default();
let path = portable_config_path(exe_dir);
if !path.is_file() {
return request;
}
let store = SettingStore::load(&path, launcher_schema());
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) {
request.mode = mode;
}
if let Some(root) = text(DATA_ROOT_KEYS[1]) {
request.custom_root = Some(PathBuf::from(root));
}
if let Some(home) = text(DATA_ROOT_KEYS[2]) {
request.launcher_home = Some(PathBuf::from(home));
}
if let Some(home) = text(DATA_ROOT_KEYS[3]) {
request.game_home = Some(PathBuf::from(home));
}
request
}
/// `<exe 目录>/.ULL/config/launcher.json`。
pub(crate) fn portable_config_path(exe_dir: &Path) -> PathBuf {
LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn at_root_splits_launcher_and_game_homes() {
let env = HostEnv::at_root("D:/tmp/ull-demo");
assert_eq!(env.layout.home(), Path::new("D:/tmp/ull-demo/.ULL"));
assert_eq!(env.game.root(), Path::new("D:/tmp/ull-demo/.minecraft"));
assert_eq!(env.mode(), DataMode::Portable);
assert!(env.data_line().contains("portable"));
}
#[test]
fn portable_request_reads_core_keys_only_when_file_exists() {
let dir = std::env::temp_dir().join(format!("ull-host-env-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".ULL/config")).expect("建目录");
let request = request_from_portable(&dir);
assert_eq!(request, DataDirRequest::default());
std::fs::write(
portable_config_path(&dir),
r#"{"core":{"data_mode":"custom","data_custom_root":"D:/data/ull"}}"#,
)
.expect("写配置");
let request = request_from_portable(&dir);
assert_eq!(request.mode, DataMode::Custom);
assert_eq!(
request.custom_root.as_deref(),
Some(Path::new("D:/data/ull"))
);
std::fs::remove_dir_all(&dir).ok();
}
}
+260
View File
@@ -0,0 +1,260 @@
//! 实例快照:`profiles.json` + 版本 JSON + 会话索引 + 实例设置 → [`InstanceSummary`]。
//!
//! 「磁贴上的每个字」都在这里翻译完:版本号取自继承链根、加载器取自版本 id 前缀、
//! 最后游玩时间取自会话索引、隔离档位取自实例设置(缺省跟全局 `instances.mode`)。
use std::collections::BTreeMap;
use std::path::Path;
use unlucky_core::game::install::instance::{
read_instance_config, read_profiles, write_instance_config,
};
use unlucky_core::game::isolation::IsolationMode;
use unlucky_core::game::{ProfileList, VersionJson};
use unlucky_core::launch::session_index;
use crate::dto::{
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
};
use super::env::HostEnv;
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
pub fn global_mode(env: &HostEnv) -> IsolationMode {
env.store()
.doc()
.effective_str("instances.mode")
.and_then(IsolationMode::parse)
.unwrap_or_default()
}
/// 读实例清单(文件缺失 = 空列表,不是错误)。
pub fn profiles(env: &HostEnv) -> ProfileList {
read_profiles(&env.layout).unwrap_or_default()
}
/// 全会话索引里「每个实例最近一次启动」的时间串。
pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
let index = session_index::index_path(&env.sessions_root());
let entries = session_index::read_all(&index).unwrap_or_default();
let mut map: BTreeMap<String, String> = BTreeMap::new();
for entry in entries {
if entry.instance_id.is_empty() || entry.started_at.is_empty() {
continue;
}
let current = map.get(&entry.instance_id);
if current.is_none_or(|existing| existing.as_str() < entry.started_at.as_str()) {
map.insert(entry.instance_id.clone(), entry.started_at);
}
}
map
}
/// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。
pub fn describe_version(env: &HostEnv, version_id: &str) -> (String, LoaderInfo) {
let loader = loader_from_id(version_id);
let mut current = version_id.to_string();
let mut seen = 0;
while seen < 16 {
seen += 1;
let path = env.game.version_json(&current);
let Ok(text) = std::fs::read_to_string(&path) else {
break;
};
let Ok(json) = VersionJson::from_json_str(&text) else {
break;
};
match json.inherits_from {
Some(parent) if !parent.is_empty() => current = parent,
_ => break,
}
}
(current, loader)
}
/// 版本 id → 加载器(HMCL 同款命名口径:`1.20.1-forge-47.2.0` / `fabric-loader-0.16.5-1.20.1`)。
pub fn loader_from_id(version_id: &str) -> LoaderInfo {
let lower = version_id.to_ascii_lowercase();
let grab = |marker: &str| -> String {
lower
.split_once(marker)
.map(|(_, rest)| rest.trim_start_matches('-').to_string())
.unwrap_or_default()
};
let (kind, version) = if lower.contains("neoforge") {
(LoaderKind::NeoForge, grab("neoforge-"))
} else if lower.contains("fabric-loader") {
(LoaderKind::Fabric, grab("fabric-loader-"))
} else if lower.contains("quilt-loader") {
(LoaderKind::Quilt, grab("quilt-loader-"))
} else if lower.contains("forge") {
(LoaderKind::Forge, grab("forge-"))
} else if lower.contains("optifine") {
(LoaderKind::OptiFine, grab("optifine"))
} else if lower.contains("liteloader") {
(LoaderKind::LiteLoader, grab("liteloader-"))
} else {
(LoaderKind::Vanilla, String::new())
};
LoaderInfo { kind, version }
}
/// 实例的隔离档位(实例文件优先,缺省显示全局档位)。
pub fn isolation_of(env: &HostEnv, id: &str) -> String {
let global = global_mode(env);
match read_instance_config(&env.layout, id) {
Ok(Some(config)) => config.effective_isolation(global).as_str().to_string(),
_ => global.as_str().to_string(),
}
}
/// 实例快照。
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
let profiles = profiles(env);
let played = last_played(env);
profiles
.profiles
.iter()
.map(|profile| {
let version_id = profile.version_id.clone();
let (mc_version, loader) = describe_version(env, &version_id);
InstanceSummary {
id: profile.id.as_str().to_string(),
name: profile.name.clone(),
mc_version,
loader,
last_played: played.get(profile.id.as_str()).cloned(),
runtime: InstanceRuntime::Idle,
icon: None,
isolation: isolation_of(env, profile.id.as_str()),
version_present: env.game.is_version_installed(&version_id),
version_id,
}
})
.collect()
}
/// 改实例隔离档位(写实例设置文件;不动任何数据)。
///
/// # Errors
///
/// 实例不存在、文件读不了/写不了时返回用户可读文案。
pub fn set_isolation(env: &HostEnv, id: &str, mode: &str) -> Result<(), String> {
let parsed = IsolationMode::parse(mode)
.ok_or_else(|| format!("未知隔离档位 `{mode}`(isolated / shared)"))?;
let profiles = profiles(env);
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == id)
.ok_or_else(|| format!("找不到实例 `{id}`"))?;
let mut config = read_instance_config(&env.layout, id)
.map_err(|error| format!("读实例设置失败:{error}"))?
.unwrap_or_else(|| {
unlucky_core::game::install::instance::InstanceConfig::new(
profile.id.as_str(),
&profile.version_id,
)
});
config.isolation = Some(match parsed {
IsolationMode::Isolated => unlucky_core::game::install::instance::Isolation::Isolated,
IsolationMode::Shared => unlucky_core::game::install::instance::Isolation::Shared,
});
write_instance_config(&env.layout, &config)
.map(|_| ())
.map_err(|error| format!("写实例设置失败:{error}"))
}
/// 启动前的切换检测:把 M30 的 `ModeChangeWarning` 翻成横幅通知。
///
/// 只在真启动时调用(会写 `lastModeChange.warned`,保证同一次切换只提示一次)。
pub fn mode_change_notice(env: &HostEnv, id: &str) -> Option<NoticeView> {
let mut config = read_instance_config(&env.layout, id).ok()??;
let warning = config.take_mode_change_warning()?;
let _ = write_instance_config(&env.layout, &config);
Some(NoticeView::new(
&format!("isolation:{id}"),
NoticeLevel::Warn,
"隔离档位已切换",
&warning.message(),
))
}
/// 建目录(数据根首次运行时)。
pub fn ensure_root(env: &HostEnv) {
let _ = env.layout.ensure();
let _ = std::fs::create_dir_all(env.game.root());
}
/// 版本本体目录是否存在(启动前快速判断)。
pub fn version_present(env: &HostEnv, version_id: &str) -> bool {
env.game.is_version_installed(version_id)
}
/// 路径显示成短串(通知/错误文案用)。
pub fn short_path(path: &Path) -> String {
path.display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loader_parsing_covers_all_shapes() {
let cases = [
("1.20.4", LoaderKind::Vanilla, ""),
(
"fabric-loader-0.16.5-1.20.1",
LoaderKind::Fabric,
"0.16.5-1.20.1",
),
(
"quilt-loader-0.24.0-1.20.1",
LoaderKind::Quilt,
"0.24.0-1.20.1",
),
("1.20.1-forge-47.2.0", LoaderKind::Forge, "47.2.0"),
("1.21.1-neoforge-21.1.80", LoaderKind::NeoForge, "21.1.80"),
];
for (id, kind, version) in cases {
let info = loader_from_id(id);
assert_eq!(info.kind, kind, "{id}");
assert_eq!(info.version, version, "{id}");
assert!(!info.badge().is_empty());
}
}
#[test]
fn isolation_write_then_read_round_trips() {
let dir = std::env::temp_dir().join(format!("ull-host-game-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
// 直接建一份 profiles.json(register 需要版本本体,这里只测隔离写读)
let profiles = ProfileList {
profiles: vec![unlucky_core::game::InstanceProfile {
id: unlucky_core::game::ProfileId::generate(),
name: "demo".to_string(),
version_id: "1.20.1".to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: BTreeMap::new(),
}],
..ProfileList::default()
};
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles)
.expect("写清单");
let id = profiles.profiles[0].id.as_str().to_string();
assert_eq!(isolation_of(&env, &id), "isolated");
set_isolation(&env, &id, "shared").expect("切档位");
assert_eq!(isolation_of(&env, &id), "shared");
assert!(set_isolation(&env, &id, "nope").is_err());
std::fs::remove_dir_all(&dir).ok();
}
}
+145
View File
@@ -0,0 +1,145 @@
//! `HostTask` → `CoreEvent` 的翻译(从 `host/mod.rs` 拆出,守 400 行硬限)。
//!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome};
use crate::toast::ToastKind;
use super::{logs, CoreHost, HostTask, LiveLaunch};
impl CoreHost {
/// 后台消息 → 事件。
pub(super) fn handle(&mut self, task: HostTask) {
match task {
HostTask::Versions(Ok(versions)) => self.send(CoreEvent::VersionListChanged(versions)),
HostTask::Versions(Err(reason)) => {
self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}"));
}
HostTask::Install(progress) => {
self.install = Some(*progress.clone());
self.send(CoreEvent::InstallProgress(Some(*progress)));
}
HostTask::InstallDone { progress, result } => {
self.install_cancel = None;
self.install = Some(*progress.clone());
self.send(CoreEvent::InstallProgress(Some(*progress)));
match result {
Ok(_) => {
self.refresh_instances();
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
}
Err(reason) => self.toast(ToastKind::Error, reason),
}
}
HostTask::Accounts(accounts) => {
self.accounts = accounts.clone();
self.send(CoreEvent::AccountsChanged(accounts));
self.refresh_instances();
}
HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)),
HostTask::Java(page) => self.send(CoreEvent::JavaChanged(*page)),
HostTask::Logs {
sessions,
lines,
diagnosis,
} => {
self.send(CoreEvent::LogSessionsChanged(sessions));
self.send(CoreEvent::LogLinesChanged(lines));
if let Some(report) = diagnosis {
self.send(CoreEvent::DiagnosisReady(report));
}
}
HostTask::Sessions(sessions) => self.send(CoreEvent::LogSessionsChanged(sessions)),
HostTask::Tail(batch) => {
if batch.running {
self.send(CoreEvent::LogLinesChanged(batch.lines));
} else {
self.send(CoreEvent::LogAppended(*batch));
}
}
HostTask::LaunchStarted {
id,
session_id,
pid,
} => {
self.pending.remove(&id);
self.failed.remove(&id);
self.launches.retain(|live| live.instance != id);
self.launches.push(LiveLaunch {
instance: id.clone(),
session: session_id.clone(),
pid,
});
// 实时尾随一次只跟一场(下载页/日志页共用一个滚动窗口);
// 已有别的实例在尾随时不抢占——它的日志仍在 JSONL 里,收尾后可回放。
if self.tail.session().is_none() {
self.tail.attach(
self.env
.sessions_root()
.join("game")
.join(format!("{session_id}.jsonl")),
&session_id,
);
}
self.send(CoreEvent::InstanceRuntimeChanged {
id,
runtime: InstanceRuntime::Running { pid },
});
}
HostTask::Runtime { id, runtime } => {
self.send(CoreEvent::InstanceRuntimeChanged { id, runtime });
}
HostTask::LaunchEnd(finished) => {
// 收尾可能发生在 LaunchStarted 之前(build_request / spawn 失败只发
// LaunchEnd):pending 必须在这里也清掉,否则 in_flight 恒真、该实例
// 本会话内再也起不来。
self.pending.remove(&finished.id);
self.launches.retain(|live| live.instance != finished.id);
let runtime = if finished.outcome == LogOutcome::Ok {
self.failed.remove(&finished.id);
InstanceRuntime::Idle
} else {
// 记住失败原因:随后的 refresh_instances 会重建快照,
// 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。
self.failed
.insert(finished.id.clone(), finished.summary.clone());
InstanceRuntime::Failed {
reason: finished.summary.clone(),
}
};
self.send(CoreEvent::InstanceRuntimeChanged {
id: finished.id.clone(),
runtime,
});
self.send(CoreEvent::LaunchFinished((*finished).clone()));
// 只拆自己那场的尾随:并发时另一实例的实时日志不该被这次收尾掐掉。
if self.tail.session() == Some(finished.session.as_str()) {
self.tail.detach();
}
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
self.refresh_instances();
}
HostTask::Crash(report) => {
self.send(CoreEvent::DiagnosisReady(*report));
}
HostTask::Notice(notice) => self.push_notice(*notice),
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
}
}
/// 尾随运行中会话(每 `TAIL_POLL_TICKS` 拍一次)。
pub(super) fn poll_tail(&mut self) {
let Some((session, lines)) = self.tail.poll() else {
return;
};
if lines.is_empty() {
return;
}
let running = self.launches.iter().any(|live| live.session == session);
self.send(CoreEvent::LogAppended(crate::dto::LiveLogBatch {
session,
lines,
running,
}));
}
}
+103
View File
@@ -0,0 +1,103 @@
//! 启动身份:选中账户 → `AuthPlaceholders`(**凭据只在这里读,读完即丢**)+ 收尾结局映射。
//!
//! 与 CLI `commands/identity.rs` 同源:微软轨过期就静默续期并回写凭据(refresh_token 轮换,
//! 旧值作废);零账户回落离线占位,保证没有账户时照样能启动。
use unlucky_core::auth::account::{AccountKind, Credentials};
use unlucky_core::auth::microsoft::MicrosoftAuth;
use unlucky_core::auth::offline::offline_login;
use unlucky_core::auth::AccountStore;
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::ProfileId;
use unlucky_core::launch::request::AuthPlaceholders;
use crate::dto::LogOutcome;
use super::env::HostEnv;
/// 把选中账户翻成启动占位符(**凭据只在这里读,读完即丢**)。
///
/// 微软轨:过期就静默续期并回写凭据(refresh_token 轮换,旧值作废)。
///
/// # Errors
///
/// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。
pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders, String), String> {
let store = AccountStore::open(env.layout.auth_data());
let chosen = match account {
Some(key) => store
.find(key)
.cloned()
.ok_or_else(|| format!("找不到账户 `{key}`"))?,
None => match store.accounts() {
[] => {
let uuid = ProfileId::generate().as_str().replace('-', "");
return Ok((
AuthPlaceholders {
player_name: "Player".to_string(),
uuid,
user_type: "legacy".to_string(),
user_properties: "{}".to_string(),
..AuthPlaceholders::default()
},
"未登录账户,按离线占位启动".to_string(),
));
}
[only] => only.clone(),
many => return Err(format!("有 {} 个账户,请在账户页选一个再启动", many.len())),
},
};
let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label());
match chosen.kind {
AccountKind::Offline => {
let placeholders =
offline_login(&chosen).map_err(|error| format!("离线账户解析失败:{error}"))?;
Ok((placeholders, line))
}
AccountKind::Microsoft => {
let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else {
return Err(format!(
"账户 {} 的微软凭据缺失或类型不符,请重新登录",
chosen.display_name()
));
};
let auth =
MicrosoftAuth::from_env().map_err(|error| format!("微软登录不可用:{error}"))?;
let (fresh, _) = block_on(auth.ensure_fresh(session))
.map_err(|error| format!("微软令牌刷新失败:{error}"))?;
if &fresh != session {
// refresh_token 已轮换:立刻回写,旧值作废。
let mut store = AccountStore::open(env.layout.auth_data());
let _ = store.update_credentials(&chosen.id, Credentials::Microsoft(fresh.clone()));
let _ = store.save();
}
let placeholders = fresh
.to_placeholders()
.map_err(|error| format!("微软身份转换失败:{error}"))?;
Ok((placeholders, line))
}
AccountKind::AuthlibInjector => {
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else {
return Err(format!(
"账户 {} 的第三方凭据缺失或类型不符",
chosen.display_name()
));
};
let placeholders = session
.to_placeholders()
.map_err(|error| format!("第三方身份转换失败:{error}"))?;
Ok((placeholders, line))
}
}
}
/// `SessionHandle` → 日志页的三种结局。
pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome {
if handle.exit.silent_failure.is_some() {
LogOutcome::SilentFail
} else if handle.succeeded() {
LogOutcome::Ok
} else {
LogOutcome::Crashed
}
}
+393
View File
@@ -0,0 +1,393 @@
//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线 → 登记实例。
//!
//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。
//! Forge/NeoForge 多一段 installer processors([`super::install_processors`]):
//! `ProcessorPlan::build → 依赖库下载 → 选 Java → 串行 java -cp`,**跑完才写版本 JSON**。
//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
//!
//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge(四家,与 CLI 对齐)。OptiFine/LiteLoader
//! 两家 core 侧都还没有安装管线,仍在 GUI 里显式拒装并说清去哪装。
//! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::addon::loader::{
LoaderInstallPlan, LoaderKind as CoreLoaderKind, LoaderPlanInput, LoaderSource,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{
execute, InstallError, InstallPlan, InstallReport, PlanInput, RemoteSource,
};
use crate::dto::{CreateRequest, InstallStage, LoaderKind};
use crate::toast::{Toast, ToastKind};
use super::env::HostEnv;
use super::install_journal::Journal;
use super::install_processors::{self, StageInput};
use super::job::{spawn, HostTask, JobSender};
use super::meter::{sink_for, Meter};
/// 文件级并发上限(与 CLI 同值:16 路压满带宽又不至于打爆镜像)。
pub const CONCURRENCY: usize = 16;
/// 用户取消的稳定文案(阶段边界生效)。
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)";
/// 一次安装任务的运行上下文(后台线程内一路传下去,免得每个函数排七八个参数)。
struct Job<'a> {
env: &'a HostEnv,
tx: &'a JobSender,
meter: &'a Arc<std::sync::Mutex<Meter>>,
cancel: &'a Arc<AtomicBool>,
journal: &'a Journal,
}
impl Job<'_> {
/// processor 段的进度出口(同一套 channel/meter/journal)。
fn reporter(&self) -> install_processors::Reporter<'_> {
install_processors::Reporter {
tx: self.tx,
meter: self.meter,
journal: self.journal,
}
}
}
/// 加载器徽标(`原版` / `Fabric 0.16.5`)。
pub fn loader_label(kind: LoaderKind, version: &str) -> String {
if kind == LoaderKind::Vanilla || version.trim().is_empty() {
kind.label().to_string()
} else {
format!("{} {}", kind.label(), version)
}
}
/// GUI 侧支持的加载器(五选:原版 + 四家加载器,与 CLI 的 `--loader` 词表一致)。
///
/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝,
/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。
pub fn supported(kind: LoaderKind) -> bool {
matches!(
kind,
LoaderKind::Vanilla
| LoaderKind::Fabric
| LoaderKind::Quilt
| LoaderKind::Forge
| LoaderKind::NeoForge
)
}
/// 不支持的加载器 → 给用户的可执行建议。
pub fn unsupported_reason(kind: LoaderKind) -> String {
format!(
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge);请先在实例页导入已有版本,或换成受支持的加载器。",
kind.label()
)
}
/// 后台安装(向导提交 / 下载页行内安装共用)。
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
spawn("install", move || {
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
let journal = Journal::open(&env);
journal.line(&format!(
"—— 开始安装 {}({} {})——",
request.instance_name(),
loader_label(request.loader, ""),
request.version
));
let job = Job {
env: &env,
tx: &tx,
meter: &meter,
cancel: &cancel,
journal: &journal,
};
let result = run(&job, &request);
let mut progress = meter
.lock()
.map(|guard| guard.progress.clone())
.unwrap_or_else(|_| Meter::new(&request).progress);
match result {
Ok(name) => {
journal.line(&format!("完成:实例 {name} 已登记"));
progress.stage = InstallStage::Done;
progress.files_done = progress.files_total;
progress.message = format!("已安装 {name}({})", progress.loader);
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Ok(name),
});
let _ = tx.send(HostTask::Toast(Box::new(Toast {
id: 0,
kind: ToastKind::Success,
text: "安装完成,实例已登记".to_string(),
remaining_ms: 0,
})));
}
Err(reason) => {
let cancelled = reason == CANCELLED;
progress.stage = if cancelled {
InstallStage::Cancelled
} else {
InstallStage::Failed
};
// 失败文案带上日志路径:面板只有一行、Toast 4 秒就消失,
// 装日志是用户事后能自己回看的唯一渠道(进度板之外)。
let text = if cancelled {
reason
} else {
journal.line(&format!("安装未完成:{reason}"));
format!("{reason}(日志:{})", journal.path().display())
};
progress.message.clone_from(&text);
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Err(text),
});
}
}
});
}
/// 真安装(同步跑完,调用方在后台线程里)。
fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
if request.version.trim().is_empty() {
return Err("没有选择版本".to_string());
}
if !supported(request.loader) {
return Err(unsupported_reason(request.loader));
}
let name = request.instance_name();
let env = job.env;
let engine = super::engine::engine(env);
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(InstallStage::Planning, "正在解析版本清单…");
let _ = job
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let version_id = if request.loader == LoaderKind::Vanilla {
install_vanilla(job, &engine, request)?
} else {
install_loader(job, &engine, request)?
};
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(InstallStage::Registering, "正在登记实例…");
let _ = job
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
}
let created_at =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
unlucky_core::game::install::instance::register(
&env.layout,
&name,
&version_id,
Some(&created_at),
)
.map_err(|error| format!("登记实例失败:{error}"))?;
Ok(name)
}
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
fn install_vanilla(
job: &Job<'_>,
engine: &DownloadEngine,
request: &CreateRequest,
) -> Result<String, String> {
let env = job.env;
let version = request.version.trim();
let meta_cache = env.layout.meta_cache();
let now = now_ms();
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
block_on(async {
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
let resolved = source.resolve(version).await.map_err(install_error)?;
let fetched = match resolved
.asset_index
.as_ref()
.filter(|index| index.url.is_some())
{
Some(reference) => {
let (index, bytes) = source.asset_index(reference).await.map_err(install_error)?;
Some((index, env.game.asset_index(&reference.id), bytes))
}
None => None,
};
let mut plan = InstallPlan::build(PlanInput {
layout: &env.game,
version_id: version,
resolved: &resolved,
asset_index: fetched.as_ref().map(|(index, _, _)| index),
environment: &Environment::host(),
})
.map_err(|error| error.to_string())?;
if let Some((_, dest, bytes)) = fetched {
plan.prefetch(dest, bytes);
}
if !plan.is_installable() {
return Err(format!(
"版本 {version} 装不全:{}",
plan.warnings.join(";")
));
}
let (bytes_total, _) = plan.total_bytes();
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
guard.progress.bytes_total = bytes_total;
guard.stage(
InstallStage::Downloading,
&format!("开始下载 {} 个文件…", plan.file_count()),
);
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let report = execute(engine, &plan, CONCURRENCY, Some(sink))
.await
.map_err(install_error)?;
ensure_complete(&report)?;
Ok::<String, String>(version.to_string())
})
}
/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
fn install_loader(
job: &Job<'_>,
engine: &DownloadEngine,
request: &CreateRequest,
) -> Result<String, String> {
let env = job.env;
let version = request.version.trim();
let kind = CoreLoaderKind::parse(request.loader.as_str())
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
let meta_cache = env.layout.meta_cache();
let loader_version = request.loader_version.trim().to_string();
let explicit = (!loader_version.is_empty()).then_some(loader_version.as_str());
let release = block_on(async {
LoaderSource::new(engine, &meta_cache)
.resolve(kind, version, explicit)
.await
})
.map_err(|error| error.to_string())?;
job.journal.line(&format!(
"加载器解析:{} {} → {}",
release.kind.label(),
release.loader_version,
release.version_id
));
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &env.game,
release: &release,
environment: &Environment::host(),
vanilla_installed: env.game.is_version_installed(version),
});
if plan.vanilla_install_needed {
let mut vanilla = request.clone();
vanilla.loader = LoaderKind::Vanilla;
install_vanilla(job, engine, &vanilla)?;
}
// 两段(加载器库 + 处理器依赖库)在动手前一次预记账:进度条只走一条单调曲线,
// 处理器依赖库下完不会先满格、再在加载器库开始时回落到一半。
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
let (bytes, _) = plan.install.total_bytes();
guard.expect(plan.files().len(), bytes);
}
// Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。
if release.requires_installer() {
let input = StageInput {
kind: release.kind,
mc_version: &release.mc_version,
version_id: &release.version_id,
profile: release.install_profile.as_ref(),
installer: release.installer.as_deref(),
installed: env.game.is_version_installed(&release.version_id),
};
// `None` = 这一版不需要跑处理器(版本 JSON 已在盘上 / profile 没有 client 侧步骤),
// 进度与日志都由处理器段自己写全,这里只消费返回值。
let _ = install_processors::stage(
env,
engine,
&input,
&install_processors::CORE,
&job.reporter(),
job.cancel,
)?;
}
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(
InstallStage::Downloading,
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
);
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
let report = block_on(async {
execute(engine, &plan.install, CONCURRENCY, Some(sink))
.await
.map_err(install_error)
})?;
ensure_complete(&report)?;
Ok(release.version_id)
}
/// 安装错误 → 用户可读文案。
fn install_error(error: InstallError) -> String {
match error.hint() {
Some(hint) => format!("{}({hint})", error.message()),
None => error.message().to_string(),
}
}
/// 有文件失败就报错(列前三条明细)。
fn ensure_complete(report: &InstallReport) -> Result<(), String> {
if report.is_complete() {
return Ok(());
}
let details: Vec<String> = report
.failures(3)
.iter()
.filter_map(|outcome| {
outcome
.failure
.as_ref()
.map(|failure| format!("{}:{}", outcome.name, failure.message))
})
.collect();
Err(format!(
"{} 个文件没下成(共 {} 个):{}",
report.failed(),
report.files.len(),
details.join(";")
))
}
/// 安装目录的短显示(诊断/Toast 用)。
pub fn game_root(env: &HostEnv) -> PathBuf {
env.game.root().to_path_buf()
}
#[cfg(test)]
#[path = "install_tests.rs"]
mod tests;
+79
View File
@@ -0,0 +1,79 @@
//! 安装过程的可回溯日志(`.ULL/logs/install.log`,一轮一行)。
//!
//! 起因(M45):Forge/NeoForge 装失败时,GUI 只有一行 Toast,processor 的 stderr 尾部
//! 与「哪一步挂了」在面板一关就没了。这里把安装过程按行追加到 `.ULL/logs/`
//! (与 `auth.jsonl` 同一分区),失败文案里带上日志路径,用户能自己回看。
//!
//! 三条纪律(与 auth 日志同源):
//! 1. **只追加**,不轮转、不截断(体积由「一行一条、只记诊断字段」控制);
//! 2. **只记诊断字段**:阶段、processor 坐标、退出码、已脱敏的 stderr 尾部(core 侧脱敏);
//! 3. **写盘尽力而为**:开不了文件就静默跳过,绝不让日志失败连累安装。
use std::io::Write;
use std::path::{Path, PathBuf};
use unlucky_core::launch::time;
use super::env::HostEnv;
/// 日志文件名(在 `.ULL/logs/` 下)。
pub const INSTALL_LOG_FILE: &str = "install.log";
/// 一轮安装的日志句柄(`Clone` 很便宜:只有一个路径)。
#[derive(Debug, Clone)]
pub struct Journal {
path: PathBuf,
}
impl Journal {
/// 按数据根打开(`logs/` 不存在就补建;建不出来只是没有日志)。
pub fn open(env: &HostEnv) -> Self {
let dir = env.layout.logs();
let _ = std::fs::create_dir_all(&dir);
Self {
path: dir.join(INSTALL_LOG_FILE),
}
}
/// 日志路径(失败文案里给用户看)。
pub fn path(&self) -> &Path {
&self.path
}
/// 追加一行(自动补 RFC3339 时间戳;写失败静默)。
pub fn line(&self, text: &str) {
let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
else {
return;
};
let _ = writeln!(
file,
"[{}] {text}",
time::format_rfc3339_millis(time::now())
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_are_appended_with_a_timestamp() {
let root = std::env::temp_dir().join(format!("ull-install-journal-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let env = HostEnv::at_root(&root);
let journal = Journal::open(&env);
journal.line("第一行");
journal.line("第二行");
let text = std::fs::read_to_string(journal.path()).expect("日志存在");
assert!(text.contains("第一行") && text.contains("第二行"), "{text}");
assert_eq!(text.lines().count(), 2);
assert!(text.starts_with("[2"), "带时间戳:{text}");
assert_eq!(journal.path(), env.layout.logs().join(INSTALL_LOG_FILE));
std::fs::remove_dir_all(&root).ok();
}
}
+378
View File
@@ -0,0 +1,378 @@
//! Forge / NeoForge 的 installer processors 段(M45:GUI 与 CLI 行为对齐)。
//!
//! 顺序与 CLI `ull install --loader forge` 完全一致(`crates/cli/src/commands/install/
//! loader_processors.rs`),只是把进度接到 GUI 的回流通道上:
//!
//! ```text
//! ProcessorPlan::build(纯计算:变量表 + 每步 java -cp + 依赖库清单)
//! → download_libraries(M27 引擎;进度回流 HostTask::Install)
//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘)
//! → run(串行 java -cp;每步一条面板文案 + 一行装日志)
//! ```
//!
//! 四条硬约束(与 CLI 同源,GUI 侧一条都不放宽):
//!
//! 1. **processors 跑完才允许写版本 JSON**:版本清单存在 = 这版装完了,抢在 processors
//! 前写会留下启动必崩却被报成"已安装"的实例(顺序由 `install_loader` 保证);
//! 2. **幂等**:版本 JSON 已在盘上 = 上一轮跑完了,整段跳过;
//! 3. **取消在阶段边界生效**:处理器阶段是原子段(串行外调 java,进程内没有取消钩子),
//! 进入前与退出后各查一次 `cancel`;
//! 4. **失败不静默**:结构化文案(带 `processor_*` 大类码)+ 装日志一行一条,由调用方
//! 再补 Toast(`HostTask::InstallDone` 的既有通路)。
//!
//! 执行后端走 [`Backend`]:真实实现 [`CoreBackend`] 直接转调 core,单测注入 fake,
//! 因此「计划怎么组、Java 找不到怎么说、依赖库下漏怎么报」都能在没有网络/没有 JDK
//! 的机器上断言。
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use unlucky_core::addon::loader::forge::InstallProfile;
use unlucky_core::addon::loader::processors::{
self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink,
StepStatus,
};
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{InstallReport, PlanFile, ProgressSink};
use unlucky_core::game::java::{JavaRequirement, JavaRuntime};
use crate::dto::InstallStage;
use super::env::HostEnv;
use super::install::{CANCELLED, CONCURRENCY};
use super::install_journal::Journal;
use super::job::{HostTask, JobSender};
use super::meter::{sink_for, Meter};
/// 真实后端(无状态,可以放静态里复用)。
pub(super) const CORE: CoreBackend = CoreBackend;
/// processors 段需要的输入(从 `LoaderRelease` 摘出来,便于单测构造)。
pub(super) struct StageInput<'a> {
/// 加载器种类(补库时 maven 基址的兜底来源)。
pub kind: CoreLoaderKind,
/// 目标原版版本。
pub mc_version: &'a str,
/// loader 版本 id(依赖库落点 + 临时目录名)。
pub version_id: &'a str,
/// installer 里读出来的 install profile(`requires_installer` 为真时必须有)。
pub profile: Option<&'a InstallProfile>,
/// installer jar 的磁盘路径。
pub installer: Option<&'a Path>,
/// 版本 JSON 已在盘上 = 上一轮跑完了,整段跳过(幂等)。
pub installed: bool,
}
/// processors 段的执行后端(真实实现转调 core;单测注入 fake)。
pub(super) trait Backend {
/// 下载 processor 依赖库(M27 引擎:镜像/健康度/退避/校验)。
fn download_libraries(
&self,
engine: &DownloadEngine,
files: &[PlanFile],
sink: Option<ProgressSink>,
) -> InstallReport;
/// 按 M33 的解析链挑一个能跑 installer 的 Java。
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime>;
/// 串行跑 processors。
fn run(
&self,
plan: &ProcessorPlan,
java: &JavaRuntime,
sink: Option<StepSink>,
) -> Result<ProcessorRun, ProcessorError>;
}
/// 真实后端:三件事全部转调 core 的同一个执行器(CLI 用的就是它)。
pub(super) struct CoreBackend;
impl Backend for CoreBackend {
fn download_libraries(
&self,
engine: &DownloadEngine,
files: &[PlanFile],
sink: Option<ProgressSink>,
) -> InstallReport {
block_on(processors::download_libraries(
engine,
files,
CONCURRENCY,
sink,
))
}
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime> {
processors::select_java(managed, requirement)
}
fn run(
&self,
plan: &ProcessorPlan,
java: &JavaRuntime,
sink: Option<StepSink>,
) -> Result<ProcessorRun, ProcessorError> {
processors::run(plan, java, sink)
}
}
/// 段内的进度出口:进度面板(`HostTask::Install`)+ 装日志。
pub(super) struct Reporter<'a> {
/// 后台线程 → host 的通道。
pub tx: &'a JobSender,
/// 进度记账器。
pub meter: &'a Arc<Mutex<Meter>>,
/// 装日志。
pub journal: &'a Journal,
}
impl Reporter<'_> {
/// 换阶段并推一条进度。
fn stage(&self, stage: InstallStage, message: &str) {
let Ok(mut guard) = self.meter.lock() else {
return;
};
guard.stage(stage, message);
let _ = self
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
}
/// 先把待下的库记账(文件数 + 已知字节),下载回调再逐个累加。
fn expect(&self, files: usize, bytes: u64) {
let Ok(mut guard) = self.meter.lock() else {
return;
};
guard.expect(files, bytes);
}
/// 失败留痕(文案由调用方决定怎么呈现;这里只保证日志里有一条)。
fn fail(&self, reason: String) -> String {
self.journal.line(&format!("失败:{reason}"));
reason
}
}
/// 跑 Forge / NeoForge 的 installer processors(`Ok(None)` = 这一轮不需要跑)。
///
/// # Errors
///
/// 元数据缺 profile/installer、依赖库没下全、找不到可用 Java、processor 非零退出、
/// 产物没落盘、用户取消时返回给 UI 的文案(已写进装日志)。
pub(super) fn stage(
env: &HostEnv,
engine: &DownloadEngine,
input: &StageInput<'_>,
backend: &dyn Backend,
reporter: &Reporter<'_>,
cancel: &Arc<AtomicBool>,
) -> Result<Option<ProcessorRun>, String> {
if input.installed {
reporter.journal.line(&format!(
"{} 的版本 JSON 已在盘上,跳过 installer processors",
input.version_id
));
return Ok(None);
}
let (Some(profile), Some(installer)) = (input.profile, input.installer) else {
return Err(reporter.fail(metadata_missing(input)));
};
reporter.stage(InstallStage::Installing, "正在组 installer 处理器计划…");
let scratch = env
.layout
.cache()
.join("processors")
.join(safe_name(input.version_id));
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: input.kind,
mc_version: input.mc_version,
version_id: input.version_id,
profile,
layout: &env.game,
installer,
scratch: &scratch,
environment: &Environment::host(),
})
.map_err(|error| reporter.fail(diagnostic(&error)))?;
for warning in &plan.warnings {
reporter.journal.line(&format!("规划告警:{warning}"));
}
if plan.is_empty() {
reporter
.journal
.line("install profile 没有 client 侧 processor,跳过这一段");
return Ok(None);
}
let total = plan.step_count();
let bytes = plan.libraries.iter().filter_map(|file| file.size).sum();
reporter.expect(plan.libraries.len(), bytes);
reporter.stage(
InstallStage::Downloading,
&format!("下载 installer 依赖库({} 个文件)…", plan.libraries.len()),
);
if cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let sink = sink_for(reporter.tx.clone(), Arc::clone(reporter.meter));
let libraries = backend.download_libraries(engine, &plan.libraries, Some(sink));
if !libraries.is_complete() {
return Err(reporter.fail(libraries_failed(&libraries)));
}
let managed = env.layout.java_managed();
let Some(java) = backend.select_java(&managed, &plan.java_requirement) else {
return Err(reporter.fail(java_missing(&plan.java_requirement, &managed)));
};
reporter.journal.line(&format!(
"用 {}({})跑 {total} 步 processor",
java.binary.display(),
java_label(&java)
));
reporter.stage(
InstallStage::Installing,
&format!("运行 installer 处理器 0/{total}({})…", java_label(&java)),
);
if cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let steps = step_sink(
reporter.tx.clone(),
Arc::clone(reporter.meter),
reporter.journal.clone(),
total,
);
let run = backend
.run(&plan, &java, Some(steps))
.map_err(|error| reporter.fail(diagnostic(&error)))?;
for warning in &run.warnings {
reporter.journal.line(&format!("执行告警:{warning}"));
}
reporter.journal.line(&format!(
"processors 完成:执行 {} 步、跳过 {} 步",
run.executed(),
run.skipped()
));
reporter.stage(
InstallStage::Installing,
&format!(
"installer 处理器完成(执行 {} 步、跳过 {} 步),正在写入版本清单…",
run.executed(),
run.skipped()
),
);
if cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
Ok(Some(run))
}
/// 每步完成的回调:面板一行 + 装日志一行。
///
/// `StepSink` 要求 `'static`,所以这里全持所有权(通道/记账器/日志都是克隆)。
fn step_sink(tx: JobSender, meter: Arc<Mutex<Meter>>, journal: Journal, total: usize) -> StepSink {
Arc::new(move |outcome: &StepOutcome| {
let line = step_line(outcome, total);
journal.line(&line);
let Ok(mut guard) = meter.lock() else {
return;
};
guard.stage(InstallStage::Installing, &line);
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
})
}
/// 一步的进度/日志文案(纯函数,单测直接断言)。
pub(super) fn step_line(outcome: &StepOutcome, total: usize) -> String {
let suffix = match outcome.status {
StepStatus::Skipped => "(产物已就绪,跳过)".to_string(),
StepStatus::Executed => format!("({} ms)", outcome.elapsed_ms),
};
format!(
"installer 处理器 {}/{}:{}{suffix}",
outcome.index, total, outcome.label
)
}
/// processor 错误 → 用户可读文案(保留 `processor_*` 大类码,便于按码搜日志)。
pub(super) fn diagnostic(error: &ProcessorError) -> String {
let mut text = format!("{}({})", error.message(), error.kind().as_str());
if let Some(hint) = error.hint() {
text.push_str(";建议:");
text.push_str(hint);
}
text
}
/// 缺 Java 运行时的专用文案(比通用错误多给一条「去哪装」)。
pub(super) fn java_missing(requirement: &JavaRequirement, managed: &Path) -> String {
format!(
"跑 installer 需要 {},但一个可用的 Java 运行时都没找到;请到「Java」页安装托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
requirement.describe(),
managed.display()
)
}
/// 依赖库没下全 → 列前三条失败明细。
pub(super) fn libraries_failed(report: &InstallReport) -> String {
let details: Vec<String> = report
.failures(3)
.iter()
.filter_map(|outcome| {
outcome
.failure
.as_ref()
.map(|failure| format!("{}:{}", outcome.name, failure.message))
})
.collect();
format!(
"installer 依赖库没下全({} 个失败,共 {} 个):{}",
report.failed(),
report.files.len(),
details.join(";")
)
}
/// 需要 installer 但元数据不全(缓存半截 / 换线路重解析过)。
fn metadata_missing(input: &StageInput<'_>) -> String {
format!(
"{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径;重跑一次即可(元数据缓存可能不完整)",
input.kind.label(),
input.version_id
)
}
/// Java 的一行短标识(`Java 21 · managed`)。
fn java_label(java: &JavaRuntime) -> String {
match java.major {
Some(major) => format!("Java {major} · {}", java.origin.as_str()),
None => format!("Java 主版本未知 · {}", java.origin.as_str()),
}
}
/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉,与 CLI 同规则)。
fn safe_name(raw: &str) -> String {
raw.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect::<String>()
.trim_end_matches('.')
.to_string()
}
#[cfg(test)]
#[path = "install_processors_fixture.rs"]
mod fixture;
#[cfg(test)]
#[path = "install_processors_tests.rs"]
mod tests;
@@ -0,0 +1,272 @@
//! installer processors 段的单测夹具(拆文件守 400 行上限)。
//!
//! 不需要网络、不需要 JDK、不真的起 java:`Harness` 给一个临时数据根 + 进度通道,
//! `Fake` 回放预置的下载/Java/执行结果并记录被调顺序与组出来的命令行。
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use unlucky_core::addon::loader::forge::{InstallProfile, Processor};
use unlucky_core::addon::loader::processors::{
ProcessorError, ProcessorPlan, ProcessorRun, StepOutcome, StepSink, StepStatus,
};
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
use unlucky_core::download::{DownloadEngine, DownloadErrorKind};
use unlucky_core::game::install::{
FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink,
};
use unlucky_core::game::java::{JavaOrigin, JavaRequirement, JavaRuntime};
use super::{Backend, Reporter, StageInput};
use crate::dto::{CreateRequest, LoaderKind};
use crate::host::env::HostEnv;
use crate::host::install_journal::Journal;
use crate::host::job::{channel, HostTask, JobSender};
use crate::host::meter::Meter;
static SEQ: AtomicUsize = AtomicUsize::new(0);
/// 一步的计划视图(序号 / 坐标 / 参数 / classpath 条数)。
pub(super) type StepView = (usize, String, Vec<String>, usize);
/// 一段 cases 的公共环境:临时数据根 + 引擎 + 进度通道。
pub(super) struct Harness {
dir: PathBuf,
pub(super) env: HostEnv,
pub(super) engine: DownloadEngine,
tx: JobSender,
rx: Receiver<HostTask>,
pub(super) meter: Arc<Mutex<Meter>>,
pub(super) journal: Journal,
pub(super) cancel: Arc<AtomicBool>,
}
impl Harness {
pub(super) fn new() -> Self {
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("ull-proc-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
let engine = crate::host::engine::engine(&env);
let request = CreateRequest {
version: "1.20.1".to_string(),
loader: LoaderKind::Forge,
loader_version: "47.2.0".to_string(),
name: "proc".to_string(),
};
let (tx, rx) = channel();
Self {
journal: Journal::open(&env),
meter: Arc::new(Mutex::new(Meter::new(&request))),
cancel: Arc::new(AtomicBool::new(false)),
dir,
env,
engine,
tx,
rx,
}
}
pub(super) fn reporter(&self) -> Reporter<'_> {
Reporter {
tx: &self.tx,
meter: &self.meter,
journal: &self.journal,
}
}
/// 进度面板收到的所有文案。
pub(super) fn messages(&self) -> Vec<String> {
self.rx
.try_iter()
.filter_map(|task| match task {
HostTask::Install(progress) => Some(progress.message.clone()),
_ => None,
})
.collect()
}
/// 装日志全文。
pub(super) fn log(&self) -> String {
std::fs::read_to_string(self.journal.path()).unwrap_or_default()
}
}
impl Drop for Harness {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.dir).ok();
}
}
/// 注入用的假后端:回放预置结果 + 记录调用顺序与被组出来的命令行。
pub(super) struct Fake {
pub(super) libraries_ok: bool,
pub(super) java: Option<JavaRuntime>,
pub(super) run_error: Option<ProcessorError>,
calls: RefCell<Vec<&'static str>>,
steps: RefCell<Vec<StepView>>,
}
impl Fake {
pub(super) fn new() -> Self {
Self {
libraries_ok: true,
java: Some(java()),
run_error: None,
calls: RefCell::new(Vec::new()),
steps: RefCell::new(Vec::new()),
}
}
pub(super) fn calls(&self) -> Vec<&'static str> {
self.calls.borrow().clone()
}
/// 被组出来的每步(序号 / 坐标 / 参数 / classpath 条数)。
pub(super) fn steps(&self) -> Vec<StepView> {
self.steps.borrow().clone()
}
}
impl Backend for Fake {
fn download_libraries(
&self,
_engine: &DownloadEngine,
files: &[PlanFile],
_sink: Option<ProgressSink>,
) -> InstallReport {
self.calls.borrow_mut().push("libraries");
let ok = self.libraries_ok;
let outcomes = files
.iter()
.map(|file| FileOutcome {
role: file.role,
name: file.name.clone(),
dest: file.dest.clone(),
status: if ok {
FileStatus::Skipped
} else {
FileStatus::Failed
},
bytes: 0,
source: None,
failure: (!ok).then(|| FileFailure {
kind: DownloadErrorKind::Network,
message: "连接超时".to_string(),
}),
})
.collect();
InstallReport {
files: outcomes,
version_json: None,
warnings: Vec::new(),
}
}
fn select_java(&self, _managed: &Path, _requirement: &JavaRequirement) -> Option<JavaRuntime> {
self.calls.borrow_mut().push("java");
self.java.clone()
}
fn run(
&self,
plan: &ProcessorPlan,
java: &JavaRuntime,
sink: Option<StepSink>,
) -> Result<ProcessorRun, ProcessorError> {
self.calls.borrow_mut().push("run");
*self.steps.borrow_mut() = plan
.steps
.iter()
.map(|step| {
(
step.index,
step.label.clone(),
step.args.clone(),
step.classpath.len(),
)
})
.collect();
if let Some(error) = &self.run_error {
return Err(error.clone());
}
let outcomes: Vec<StepOutcome> = plan
.steps
.iter()
.map(|step| StepOutcome {
index: step.index,
label: step.label.clone(),
status: StepStatus::Executed,
outputs: step.outputs.len(),
elapsed_ms: 12,
stdout_tail: None,
stderr_tail: None,
})
.collect();
if let Some(sink) = &sink {
for outcome in &outcomes {
sink(outcome);
}
}
Ok(ProcessorRun {
java: java.clone(),
steps: outcomes,
warnings: Vec::new(),
})
}
}
/// 一个主版本已知的假 Java。
pub(super) fn java() -> JavaRuntime {
JavaRuntime {
binary: PathBuf::from("java"),
origin: JavaOrigin::Managed,
version: Some("21.0.4".to_string()),
major: Some(21),
bits: 64,
vendor: Some("temurin".to_string()),
}
}
/// 一个最小可用的 install profile:一个 client 侧 processor、两个字面量参数。
pub(super) fn profile() -> InstallProfile {
let mut profile = InstallProfile::default();
profile.processors.push(Processor {
sides: vec!["client".to_string()],
jar: Some("net.minecraftforge:installertools:1.3.0".to_string()),
classpath: vec!["net.minecraftforge:jarsplitter:1.1.4".to_string()],
args: vec!["--task".to_string(), "'MCP_DATA'".to_string()],
..Processor::default()
});
profile
}
pub(super) fn input<'a>(profile: &'a InstallProfile, installer: &'a Path) -> StageInput<'a> {
StageInput {
kind: CoreLoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: Some(profile),
installer: Some(installer),
installed: false,
}
}
pub(super) fn installer() -> PathBuf {
PathBuf::from("cache/meta/forge-installer.jar")
}
pub(super) fn outcome(status: StepStatus) -> StepOutcome {
StepOutcome {
index: 3,
label: "net.minecraftforge:installertools:1.3.0".to_string(),
status,
outputs: 2,
elapsed_ms: 1234,
stdout_tail: None,
stderr_tail: None,
}
}
@@ -0,0 +1,213 @@
//! host/install_processors.rs 的单测(拆文件守 400 行上限)。
//!
//! 夹具在 [`super::fixture`]:这里只断言「计划怎么组」「找不到 Java 怎么说」
//! 「哪一步挂了怎么报」「进度文案长什么样」。
use std::path::PathBuf;
use unlucky_core::addon::loader::forge::InstallProfile;
use unlucky_core::addon::loader::processors::{ProcessorError, StepStatus};
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
use unlucky_core::download::DownloadErrorKind;
use unlucky_core::game::install::{FileFailure, FileOutcome, FileRole, FileStatus, InstallReport};
use unlucky_core::game::java::JavaRequirement;
use super::fixture::{input, installer, outcome, profile, Fake, Harness};
use super::{diagnostic, java_missing, libraries_failed, stage, step_line, StageInput};
#[test]
fn step_line_names_the_step_and_marks_the_outcome() {
let executed = step_line(&outcome(StepStatus::Executed), 9);
assert_eq!(
executed,
"installer 处理器 3/9:net.minecraftforge:installertools:1.3.0(1234 ms)"
);
let skipped = step_line(&outcome(StepStatus::Skipped), 9);
assert!(skipped.contains("产物已就绪"), "{skipped}");
}
#[test]
fn diagnostic_keeps_the_stable_code_and_the_hint() {
let failed = ProcessorError::failed("net.minecraftforge:installertools:1.3.0", "1", "boom")
.with_hint("确认 Java 版本与磁盘权限");
let text = diagnostic(&failed);
assert!(text.contains("processor_failed"), "{text}");
assert!(text.contains("退出码 1"), "{text}");
assert!(text.contains("boom"), "stderr 尾部要留:{text}");
assert!(text.contains("建议:确认 Java 版本"), "{text}");
let plan = diagnostic(&ProcessorError::plan("install profile 缺 `jar`"));
assert!(plan.contains("processor_plan"), "{plan}");
}
#[test]
fn java_missing_points_at_the_java_page() {
let managed = PathBuf::from("/tmp/.ULL/java");
let text = java_missing(&JavaRequirement::table(21, None), &managed);
assert!(text.contains("Java 21+"), "{text}");
assert!(text.contains("Java」页"), "{text}");
assert!(text.contains("JAVA_HOME"), "{text}");
assert!(text.contains(&managed.display().to_string()), "{text}");
}
#[test]
fn libraries_failed_lists_at_most_three() {
let files: Vec<FileOutcome> = (0..4)
.map(|index| FileOutcome {
role: FileRole::Library,
name: format!("lib{index}.jar"),
dest: PathBuf::from(format!("lib{index}.jar")),
status: FileStatus::Failed,
bytes: 0,
source: None,
failure: Some(FileFailure {
kind: DownloadErrorKind::Network,
message: "连接超时".to_string(),
}),
})
.collect();
let report = InstallReport {
files,
version_json: None,
warnings: Vec::new(),
};
let text = libraries_failed(&report);
assert!(text.contains("4 个失败,共 4 个"), "{text}");
assert!(text.contains("lib0.jar"), "{text}");
assert!(!text.contains("lib3.jar"), "最多列三条:{text}");
}
#[test]
fn stage_skips_when_the_version_json_is_already_there() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let mut input = input(&profile, &installer);
input.installed = true;
let fake = Fake::new();
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
assert!(matches!(result, Ok(None)));
assert!(fake.calls().is_empty(), "已装完就不该再动网络/Java");
assert!(h.log().contains("跳过 installer processors"), "{}", h.log());
}
#[test]
fn stage_skips_an_empty_processor_list() {
let h = Harness::new();
let installer = installer();
let profile = InstallProfile::default();
let input = input(&profile, &installer);
let fake = Fake::new();
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
assert!(matches!(result, Ok(None)), "没有 processor 就没这段");
assert!(fake.calls().is_empty());
}
#[test]
fn stage_reports_missing_installer_metadata() {
let h = Harness::new();
let input = StageInput {
kind: CoreLoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: None,
installer: None,
installed: false,
};
let fake = Fake::new();
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("install profile"), "{err}");
assert!(h.log().contains("失败:"), "{}", h.log());
}
#[test]
fn stage_builds_the_command_line_from_the_install_profile() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let fake = Fake::new();
let run = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel)
.expect("stage 成功")
.expect("应当真的跑了 processors");
assert_eq!(run.executed(), 1);
let steps = fake.steps();
assert_eq!(steps.len(), 1);
let (index, label, args, classpath) = &steps[0];
assert_eq!(*index, 1);
assert_eq!(label, "net.minecraftforge:installertools:1.3.0");
assert_eq!(args, &vec!["--task".to_string(), "MCP_DATA".to_string()]);
assert_eq!(*classpath, 1, "jar 自己不在 -cp 里(拼命令行时才补)");
assert_eq!(fake.calls(), vec!["libraries", "java", "run"]);
let messages = h.messages();
assert!(
messages.iter().any(|m| m.contains("installer 处理器 1/1")),
"面板要看到当前处理器:{messages:?}"
);
assert!(
messages
.last()
.is_some_and(|m| m.contains("正在写入版本清单")),
"{messages:?}"
);
assert!(
h.log().contains("processors 完成:执行 1 步"),
"{}",
h.log()
);
}
#[test]
fn stage_fails_loudly_without_a_usable_java() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let mut fake = Fake::new();
fake.java = None;
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("Java"), "{err}");
assert!(err.contains("托管目录"), "{err}");
assert_eq!(
fake.calls(),
vec!["libraries", "java"],
"没 Java 就不该起进程"
);
assert!(h.log().contains("失败:"), "{}", h.log());
}
#[test]
fn stage_fails_loudly_when_a_processor_exits_nonzero() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let mut fake = Fake::new();
fake.run_error = Some(ProcessorError::failed(
"net.minecraftforge:installertools:1.3.0",
"1",
"java.lang.NoSuchMethodError",
));
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("processor_failed"), "{err}");
assert!(err.contains("NoSuchMethodError"), "{err}");
let log = h.log();
assert!(log.contains("失败:"), "{log}");
assert!(log.contains("NoSuchMethodError"), "日志要能回溯:{log}");
}
#[test]
fn stage_reports_incomplete_library_downloads() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let mut fake = Fake::new();
fake.libraries_ok = false;
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("依赖库没下全"), "{err}");
assert!(err.contains("连接超时"), "{err}");
assert_eq!(fake.calls(), vec!["libraries"], "库没下全就别去选 Java");
let progress = h.meter.lock().expect("进度锁").progress.clone();
assert!(progress.files_total >= 2, "依赖库要计入文件总数");
}
+218
View File
@@ -0,0 +1,218 @@
//! host/install.rs 的单测(拆文件守 400 行上限)。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
use super::{loader_label, run, supported, unsupported_reason, Job};
use crate::dto::{CreateRequest, LoaderKind};
use crate::host::env::HostEnv;
use crate::host::install_journal::Journal;
use crate::host::job::{channel, JobSender};
use crate::host::meter::Meter;
static SEQ: AtomicUsize = AtomicUsize::new(0);
/// 一个装在临时根里的任务上下文(只走「联网前就返回」的用例)。
struct Fixture {
dir: PathBuf,
env: HostEnv,
tx: JobSender,
meter: Arc<Mutex<Meter>>,
cancel: Arc<AtomicBool>,
journal: Journal,
}
impl Fixture {
fn new(request: &CreateRequest) -> Self {
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("ull-host-install-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
let journal = Journal::open(&env);
Self {
dir,
env,
tx: channel().0,
meter: Arc::new(Mutex::new(Meter::new(request))),
cancel: Arc::new(AtomicBool::new(false)),
journal,
}
}
fn job(&self) -> Job<'_> {
Job {
env: &self.env,
tx: &self.tx,
meter: &self.meter,
cancel: &self.cancel,
journal: &self.journal,
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.dir).ok();
}
}
fn request(loader: LoaderKind, version: &str) -> CreateRequest {
CreateRequest {
version: version.to_string(),
loader,
loader_version: String::new(),
name: String::new(),
}
}
/// 一个「刚下完」的文件结局(记账用)。
fn downloaded(bytes: u64) -> FileOutcome {
FileOutcome {
role: FileRole::Library,
name: "lib.jar".to_string(),
dest: PathBuf::from("lib.jar"),
status: FileStatus::Downloaded,
bytes,
source: None,
failure: None,
}
}
#[test]
fn progress_is_pre_accounted_across_segments_so_the_bar_never_snaps_to_full() {
// M45 r1 P2-2 的回归:处理器依赖库下完那一刻,面板曾显示 36/36 满格 + 3.4MB/10MB,
// 之后加载器库的 29 个文件才被计入 → 进度条从 100% 回落到 55% 再涨回去。
let request = request(LoaderKind::Forge, "1.20.1");
let mut meter = Meter::new(&request);
// 原版段:30 个文件全下完(install_vanilla 对总量是赋值,不是累加)
meter.progress.files_total = 30;
meter.progress.bytes_total = 3_000_000;
for _ in 0..30 {
meter.note(&downloaded(100_000));
}
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
// install_loader 的顺序:加载器库与处理器依赖库都在动手前一次预记账
meter.expect(29, 10_000_000);
meter.expect(36, 4_000_000);
assert_eq!(meter.progress.files_total, 95, "两段之和一次到位");
assert_eq!(
meter.progress.bytes_total, 17_000_000,
"字节是相加不是取最大"
);
assert!(
meter.progress.ratio() < 0.7,
"预记账后不能还停在满格:{}",
meter.progress.ratio()
);
// 处理器依赖库 36 个下完 → 仍没满格(加载器库还在后面)
for _ in 0..36 {
meter.note(&downloaded(110_000));
}
let ratio = meter.progress.ratio();
assert!(
(0.6..1.0).contains(&ratio),
"回落到中间态而不是满格:{ratio}"
);
assert!(
meter.progress.bytes_done < meter.progress.bytes_total,
"字节总量要覆盖已下载量:{}/{}",
meter.progress.bytes_done,
meter.progress.bytes_total
);
// 加载器库也下完 → 这时候才该是满格
for _ in 0..29 {
meter.note(&downloaded(340_000));
}
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn loader_support_matrix_is_explicit() {
// M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。
for kind in [
LoaderKind::Vanilla,
LoaderKind::Fabric,
LoaderKind::Quilt,
LoaderKind::Forge,
LoaderKind::NeoForge,
] {
assert!(supported(kind), "{} 应当受支持", kind.label());
}
// 这两家 core 侧还没有安装管线,仍然显式拒装并说清去哪装。
for kind in [LoaderKind::OptiFine, LoaderKind::LiteLoader] {
assert!(!supported(kind), "{} 不该被当成可装", kind.label());
let reason = unsupported_reason(kind);
assert!(reason.contains(kind.label()), "{reason}");
assert!(reason.contains("还没有接入"), "{reason}");
}
}
#[test]
fn forge_install_is_no_longer_refused_at_the_door() {
// 旧文案是把用户赶去终端跑 `ull install`;M45 之后 GUI 与 CLI 同一条路径。
assert!(supported(LoaderKind::Forge));
assert!(supported(LoaderKind::NeoForge));
assert_eq!(loader_label(LoaderKind::Forge, "47.2.0"), "Forge 47.2.0");
assert_eq!(
loader_label(LoaderKind::NeoForge, "21.1.80"),
"NeoForge 21.1.80"
);
}
#[test]
fn meter_counts_files_and_skips_skipped_bytes() {
let request = CreateRequest {
version: "1.20.4".to_string(),
loader: LoaderKind::Fabric,
loader_version: "0.16.5".to_string(),
name: "生存".to_string(),
};
let mut meter = Meter::new(&request);
assert_eq!(meter.progress.loader, "Fabric 0.16.5");
assert_eq!(meter.progress.instance, "生存");
meter.progress.files_total = 2;
let mut outcome = FileOutcome {
role: FileRole::Library,
name: "a.jar".to_string(),
dest: PathBuf::from("a.jar"),
status: FileStatus::Downloaded,
bytes: 100,
source: Some("bmclapi".to_string()),
failure: None,
};
meter.note(&outcome);
assert_eq!(meter.progress.files_done, 1);
assert_eq!(meter.progress.bytes_done, 100);
outcome.status = FileStatus::Skipped;
outcome.bytes = 500;
meter.note(&outcome);
assert_eq!(meter.progress.files_done, 2);
assert_eq!(meter.progress.bytes_done, 100, "跳过的文件不计入已下载字节");
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn empty_version_is_rejected_before_network() {
let request = request(LoaderKind::Vanilla, "");
let fixture = Fixture::new(&request);
let err = run(&fixture.job(), &request).unwrap_err();
assert!(err.contains("没有选择版本"), "{err}");
}
#[test]
fn unsupported_loader_is_rejected_before_network() {
let request = request(LoaderKind::OptiFine, "1.20.1");
let fixture = Fixture::new(&request);
let err = run(&fixture.job(), &request).unwrap_err();
assert!(err.contains("还没有接入"), "{err}");
}
+304
View File
@@ -0,0 +1,304 @@
//! Java 页:四家发行版 + 回退链可视化 + 本地运行时 + 托管 JDK 安装。
//!
//! 回退链的两态:
//! - **计划链**(还没装):按生效顺序铺一排「待尝试」,让用户先看清会按什么顺序找;
//! - **真实链**(装过一次/装失败):`core::game::java::ChainLog` 的每跳结局与原因。
//!
//! 安装走 `java::install`(core 自己跑回退链),host 只负责把 `ChainLog` 翻成 DTO。
use unlucky_core::game::java::probe::JavaOrigin;
use unlucky_core::game::java::source::ChainLog;
use unlucky_core::game::java::{self, JdkVendor};
use crate::dto::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
/// 托管 JDK 的默认目标大版本(D13 基准 JDK17)。
pub const DEFAULT_JDK_MAJOR: u32 = 17;
/// 该装哪个大版本:取第一个实例版本的要求,没有实例就给 17。
pub fn target_major(env: &HostEnv) -> u32 {
let profiles = super::game::profiles(env);
profiles
.profiles
.first()
.map(|profile| {
java::requirement_for_version(&env.game, &profile.version_id)
.minimum
.max(8)
})
.unwrap_or(DEFAULT_JDK_MAJOR)
}
/// 四家发行版的展示行(镜像与吞吐口径与 CLI `ull java vendors` 一致)。
pub fn vendors(env: &HostEnv, installed_vendors: &[String]) -> Vec<JavaVendorView> {
let domestic = super::engine::domestic(env);
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
let table = unlucky_core::download::MirrorTable::builtin();
order
.iter()
.enumerate()
.map(|(index, vendor)| {
let id = vendor.id();
JavaVendorView {
id: id.to_string(),
display: vendor.display().to_string(),
rank: u32::try_from(index + 1).unwrap_or(u32::MAX),
majors: vendor
.hint_majors()
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join("/"),
sources: sources_of(&table, *vendor),
installed: installed_vendors.iter().any(|known| known == id),
}
})
.collect()
}
/// 一家的源清单(与 CLI 展示同形)。
fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) -> Vec<String> {
match vendor {
JdkVendor::Temurin => table
.adoptium
.iter()
.filter(|mirror| mirror.enabled)
.map(|mirror| mirror.name.clone())
.chain(std::iter::once(
"GitHub 官方(国内套 gh-proxy)".to_string(),
))
.collect(),
JdkVendor::Oracle => table
.oracle_mirrors()
.map(|mirror| format!("{} {}", mirror.name, mirror.base()))
.collect(),
JdkVendor::Microsoft => vec!["aka.ms → VS CDN(无国内镜像)".to_string()],
JdkVendor::Zulu => table
.zulu()
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
.unwrap_or_default(),
}
}
/// 本地运行时(托管 / JAVA_HOME / PATH / 系统安装位置,同一路径只留优先级高者)。
pub fn runtimes(env: &HostEnv) -> Vec<JavaRuntimeView> {
let roots = super::engine::java_scan_roots(env);
let candidates = java::discover::dedupe(java::candidates_in_roots(&roots));
candidates
.iter()
.map(|runtime| JavaRuntimeView {
binary: runtime.binary.display().to_string(),
version: runtime
.version
.clone()
.unwrap_or_else(|| "版本未知".to_string()),
major: runtime.major.unwrap_or(java::DEFAULT_MAJOR),
vendor: runtime.vendor.clone().unwrap_or_else(|| "—".to_string()),
origin: runtime.origin.category().to_string(),
})
.collect()
}
/// 托管目录(展示「装到哪」)。
pub fn managed_roots(env: &HostEnv) -> Vec<String> {
super::engine::java_scan_roots(env)
.iter()
.map(|root| root.display().to_string())
.collect()
}
/// 计划链(按生效顺序铺「待尝试」)。
pub fn planned_chain(env: &HostEnv) -> Vec<JavaChainStep> {
let domestic = super::engine::domestic(env);
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
order
.iter()
.map(|vendor| JavaChainStep {
vendor: vendor.id().to_string(),
outcome: JavaChainOutcome::Pending,
detail: String::new(),
})
.collect()
}
/// 真实链(`ChainLog` → DTO)。
pub fn chain_steps(log: &ChainLog) -> Vec<JavaChainStep> {
log.attempts()
.iter()
.map(|attempt| JavaChainStep {
vendor: attempt.vendor.id().to_string(),
outcome: if attempt.ok {
JavaChainOutcome::Hit
} else if attempt.detail.contains("不支持") {
JavaChainOutcome::Skip
} else {
JavaChainOutcome::Failed
},
detail: attempt.detail.clone(),
})
.collect()
}
/// 整页快照。
pub fn page(env: &HostEnv, chain: Vec<JavaChainStep>, busy: bool, message: &str) -> JavaPageView {
let runtimes = runtimes(env);
let installed_vendors: Vec<String> = runtimes
.iter()
.filter(|runtime| runtime.origin == JavaOrigin::Managed.category())
.map(|runtime| runtime.vendor.clone())
.collect();
let vendor_rows = vendors(env, &installed_vendors);
let domestic = super::engine::domestic(env);
let priority_set = !super::engine::java_priority(env).is_empty();
let chain = if chain.is_empty() {
planned_chain(env)
} else {
chain
};
JavaPageView {
region: if domestic { "domestic" } else { "global" }.to_string(),
priority_source: if priority_set {
"java.vendorPriority"
} else {
"region_default"
}
.to_string(),
order_text: vendor_rows
.iter()
.map(|vendor| vendor.display.clone())
.collect::<Vec<_>>()
.join(" → "),
vendors: vendor_rows,
chain,
runtimes,
managed_roots: managed_roots(env),
busy,
message: message.to_string(),
}
}
/// 后台装一个托管 JDK(`vendor` 为空 = 按回退链自己选)。
pub fn spawn_install(env: HostEnv, tx: JobSender, vendor: Option<JdkVendor>) {
spawn("java-install", move || {
let major = target_major(&env);
let mut busy = page(&env, Vec::new(), true, &format!("正在安装 Java {major}…"));
busy.busy = true;
let _ = tx.send(HostTask::Java(Box::new(busy)));
let engine = super::engine::engine(&env);
let root = match super::engine::java_install_root(&env) {
Ok(root) => root,
Err(reason) => {
let _ = tx.send(HostTask::Java(Box::new(page(
&env,
Vec::new(),
false,
&format!("解析 Java 安装位置失败:{reason}"),
))));
return;
}
};
let request = match vendor {
Some(vendor) => java::InstallRequest::with_vendor(major, vendor)
.with_priority(super::engine::java_priority(&env)),
None => {
java::InstallRequest::new(major).with_priority(super::engine::java_priority(&env))
}
};
let result =
unlucky_core::download::blocking::block_on(java::install(&engine, &root, request));
match result {
Ok(installed) => {
let message = if installed.reused() {
format!(
"Java {}({})已就绪:{}",
installed.major,
installed.vendor.id(),
installed.root.display()
)
} else {
format!(
"已安装 Java {}({})→ {} · {} 个文件",
installed.major,
installed.vendor.display(),
installed.root.display(),
installed.files
)
};
let chain = chain_steps(&installed.chain);
let _ = tx.send(HostTask::Java(Box::new(page(&env, chain, false, &message))));
}
Err(error) => {
let _ = tx.send(HostTask::Java(Box::new(page(
&env,
Vec::new(),
false,
&format!("Java 安装失败:{error}"),
))));
}
}
});
}
/// 「安装一个 Java」的 UI 文案(下载页 Java 分组的按钮说明)。
pub fn install_hint(env: &HostEnv) -> String {
let major = target_major(env);
format!("按回退链取一个 Java {major}(托管目录,装完自动被启动链选中)")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn planned_chain_covers_four_vendors() {
let dir = std::env::temp_dir().join(format!("ull-host-java-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
let chain = planned_chain(&env);
assert_eq!(chain.len(), 4);
assert!(chain
.iter()
.all(|step| step.outcome == JavaChainOutcome::Pending));
assert_eq!(chain[0].vendor, "temurin", "国内/国外默认都是 Temurin 第一");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn chain_log_maps_to_steps() {
let mut log = ChainLog::new();
log.skip(
JdkVendor::Microsoft,
"不支持 Java 8(microsoft 只发 11/17/21/25)",
);
log.hit(JdkVendor::Temurin, "命中 Adoptium 17");
let steps = chain_steps(&log);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].outcome, JavaChainOutcome::Skip);
assert_eq!(steps[1].outcome, JavaChainOutcome::Hit);
}
#[test]
fn page_reports_region_and_priority_source() {
let dir = std::env::temp_dir().join(format!("ull-host-java-page-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
std::fs::write(
env.launcher_config(),
r#"{"java":{"vendorPriority":["zulu"]}}"#,
)
.expect("写配置");
let page = page(&env, Vec::new(), false, "");
assert_eq!(page.priority_source, "java.vendorPriority");
assert_eq!(page.vendors[0].id, "zulu");
assert_eq!(page.vendors[0].rank, 1);
assert!(page.order_text.contains("Azul Zulu"));
assert_eq!(page.vendors.len(), 4);
std::fs::remove_dir_all(&dir).ok();
}
}
+136
View File
@@ -0,0 +1,136 @@
//! 后台任务的回流消息:`HostTask` 是唯一「线程 → host」的载荷。
//!
//! 线程只做 core 调用(网络/磁盘),产出的都是 `crate::dto` 的 DTO;
//! host 的 `tick` 把它们翻成 [`crate::dto::CoreEvent`]。
//! 这样「跨线程传的是什么」在一处看全,也不会出现半成品领域类型漏回 UI。
use std::sync::mpsc::{Receiver, Sender};
use crate::dto::{
AccountView, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LogLine, LogOutcome, LogSession,
NoticeView, SessionId, VersionSummary,
};
use crate::toast::Toast;
/// 后台任务 → host 的消息。
#[derive(Debug, Clone, PartialEq)]
pub enum HostTask {
/// 版本清单(下载页)
Versions(Result<Vec<VersionSummary>, String>),
/// 安装进度(高频,host 只做聚合与转发)
Install(Box<InstallProgressView>),
/// 安装收尾(成功带实例名,失败带文案)
InstallDone {
/// 最终进度快照
progress: Box<InstallProgressView>,
/// 成功时为 `Ok(实例名)`
result: Result<String, String>,
},
/// 账户列表
Accounts(Vec<AccountView>),
/// 设备码登录状态
Login(Box<DeviceLoginView>),
/// Java 页(发行版/回退链/运行时)
Java(Box<JavaPageView>),
/// 日志页快照(会话列表 + 选中会话的行 + 诊断)
Logs {
/// 会话列表
sessions: Vec<LogSession>,
/// 选中会话的行
lines: Vec<LogLine>,
/// 诊断结论
diagnosis: Option<DiagnosisReport>,
},
/// 实时日志增量
Tail(Box<LiveLogBatch>),
/// 启动开始(pid + 会话 id)
LaunchStarted {
/// 实例 id
id: InstanceId,
/// 会话 id
session_id: SessionId,
/// 进程号
pid: u32,
},
/// 启动阶段推进(磁贴进度环)
Runtime {
/// 实例 id
id: InstanceId,
/// 阶段状态
runtime: InstanceRuntime,
},
/// 启动收尾
LaunchEnd(Box<LaunchFinished>),
/// 崩溃日志导入的分析结论
Crash(Box<DiagnosisReport>),
/// 横幅通知(隔离切换提示等)
Notice(Box<NoticeView>),
/// Toast
Toast(Box<Toast>),
/// 会话列表刷新(只换左栏)
Sessions(Vec<LogSession>),
}
/// 后台线程用的发送端(`Sender` 克隆即可)。
pub type JobSender = Sender<HostTask>;
/// host 侧的接收端。
pub type JobReceiver = Receiver<HostTask>;
/// 建一对通道。
pub fn channel() -> (JobSender, JobReceiver) {
std::sync::mpsc::channel()
}
/// 起一个带名字的后台线程(名字只进 panic 信息,方便定位)。
pub fn spawn(name: &str, job: impl FnOnce() + Send + 'static) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name(format!("ull-host-{name}"))
.spawn(job)
.unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}"))
}
/// 人读一行「启动结束」。
pub fn launch_summary(outcome: LogOutcome, exit_code: Option<i32>) -> String {
match outcome {
LogOutcome::Ok => "正常退出".to_string(),
LogOutcome::Crashed => match exit_code {
Some(code) => format!("崩溃(exit {code})"),
None => "崩溃".to_string(),
},
LogOutcome::SilentFail => "静默失败(exit 0 但游戏没起来)".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_carries_tasks_across_threads() {
let (tx, rx) = channel();
let handle = spawn("test", move || {
tx.send(HostTask::Toast(Box::new(Toast {
id: 1,
kind: crate::toast::ToastKind::Info,
text: "hi".to_string(),
remaining_ms: 0,
})))
.expect("发送");
});
handle.join().expect("线程结束");
let task = rx.recv().expect("收到");
assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi"));
}
#[test]
fn launch_summary_names_the_outcome() {
assert_eq!(launch_summary(LogOutcome::Ok, Some(0)), "正常退出");
assert_eq!(
launch_summary(LogOutcome::Crashed, Some(1)),
"崩溃(exit 1)"
);
assert!(launch_summary(LogOutcome::SilentFail, Some(0)).contains("静默"));
}
}
+353
View File
@@ -0,0 +1,353 @@
//! 启动全链:实例 → `LaunchRequest` → 子进程 → JSONL 事件流(GUI 版)。
//!
//! 与 CLI `ull launch` 同源同序(`core` 的 `game::resolve` / `arguments.expand` /
//! `launch::adapter::build_request` / `Launcher::spawn`),差异只有两处:
//! - **不阻塞 UI**:装配与等待都在后台线程里,进度/日志经 [`super::job::HostTask`] 回流;
//! - **身份来自 GUI 选中的账户**(`AccountStore` → `AuthPlaceholders`),凭据只在
//! 本模块内被读一次,绝不进 DTO。
//!
//! 停止:`kill_tree(pid)`(core 的三段式进程树回收),`finish()` 会照常收尾写索引。
use std::path::PathBuf;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::adapter::{
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
};
use unlucky_core::launch::request::{JavaSelection, LaunchRequest, MemoryOptions};
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::launch::tree_kill;
use unlucky_core::launch::{Launcher, LauncherConfig};
use unlucky_core::platform::paths::ProcessEnv;
use crate::dto::{
InstanceId, InstanceRuntime, LaunchFinished, LaunchStage, LoaderKind, LogOutcome, Progress,
};
use super::env::HostEnv;
use super::job::{launch_summary, HostTask, JobSender};
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
/// 一次启动需要的实例信息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchTarget {
/// 实例 id
pub id: InstanceId,
/// 实例名
pub name: String,
/// 绑定的版本 id
pub version_id: String,
/// 账户 id(`None` = 离线占位)
pub account: Option<String>,
}
/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。
pub struct JavaPick {
/// 交给 adapter 的选择
pub selection: JavaSelection,
/// 人读一行
pub summary: String,
}
/// 选一个能用的 Java:托管 > JAVA_HOME > PATH(`core::game::java` 的探测与优先级)。
pub fn pick_java(env: &HostEnv, version_id: &str) -> JavaPick {
let store = env.store();
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
let roots = unlucky_core::game::java::location::scan_roots(
env.mode(),
&env.layout,
install_root.as_deref(),
env.platform,
&ProcessEnv,
);
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
match picked {
Some(runtime) => {
let major = runtime
.major
.unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR);
let summary = format!(
"Java {}({}{})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
runtime.origin.category(),
runtime
.vendor
.as_ref()
.map(|vendor| format!(" · {vendor}"))
.unwrap_or_default()
);
JavaPick {
selection: JavaSelection::new(runtime.binary, major, runtime.bits),
summary,
}
}
None => {
let binary = PathBuf::from(unlucky_core::game::java::java_binary_name());
let major = required.minimum;
JavaPick {
selection: JavaSelection::new(binary, major, 64),
summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"),
}
}
}
}
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
///
/// # Errors
///
/// 版本 JSON 缺失/非法、继承链断裂、隔离派生失败时返回用户可读文案。
pub fn build_request(
env: &HostEnv,
target: &LaunchTarget,
) -> Result<(LaunchRequest, Vec<String>), String> {
let version_id = target.version_id.clone();
let manifest_path = env.game.version_json(&version_id);
let text = std::fs::read_to_string(&manifest_path)
.map_err(|error| format!("读版本 JSON 失败 {}:{error}", manifest_path.display()))?;
let manifest =
VersionJson::from_json_str(&text).map_err(|error| format!("版本 JSON 非法:{error}"))?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(env.game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
let resolved =
game::resolve(&source, &manifest).map_err(|error| format!("解析继承链失败:{error}"))?;
let environment = Environment::host();
let expanded = resolved
.arguments
.as_ref()
.map(|arguments| arguments.expand(&environment))
.unwrap_or_default();
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() {
continue;
}
let Ok(coordinate) = library.coordinate() else {
continue;
};
let path = if library.is_local() {
env.game.version_library(&version_id, &coordinate)
} else {
env.game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, library.name.clone()));
}
let natives = unlucky_core::launch::natives::collect(
&env.game,
&version_id,
resolved.libraries.as_deref().unwrap_or_default(),
&environment,
);
let game_dir = run_dir(env, target)?;
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
let assets_root = env.game.assets_dir();
let assets_index = resolved
.assets
.clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&target.name),
version_type: None,
game_version: None,
main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game,
legacy_arguments: resolved.minecraft_arguments.as_deref(),
libraries: &libraries,
natives: &natives,
primary_jar: env.game.version_jar(&jar_id),
game_dir,
assets_index: assets_index.as_deref(),
natives_dir: env.game.natives_dir(&version_id),
library_dir: env.game.libraries_dir(),
assets_root: assets_root.clone(),
game_assets: assets_root,
};
let java = pick_java(env, &version_id);
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
let memory = env
.store()
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok());
let options = AdapterOptions {
java: java.selection,
memory: MemoryOptions {
min_mb: None,
max_mb: memory,
},
auth,
launcher_name: LAUNCHER_NAME.to_string(),
launcher_version: env!("CARGO_PKG_VERSION").to_string(),
language: "zh-CN".to_string(),
..AdapterOptions::strict()
};
let output = adapter::build_request(&snapshot, options);
let mut warnings: Vec<String> = output.warnings.iter().map(describe_warning).collect();
warnings.push(java.summary);
warnings.push(identity_line);
let mut request = output.request;
request.instance.id = target.id.clone();
request.instance.name = target.name.clone();
Ok((request, warnings))
}
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, String> {
let global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &target.id)
.map_err(|error| format!("读实例设置失败:{error}"))?;
let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None)
.map_err(|error| format!("隔离目录派生失败:{error}"))?;
if let Some(mut config) = config {
let at = format_rfc3339_millis(now());
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
let _ =
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
}
}
Ok(derivation.game_dir)
}
/// 起一次启动(后台线程;进度/日志/收尾全部走 `HostTask`)。
pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
let id = target.id.clone();
let tx_progress = tx.clone();
super::job::spawn("launch", move || {
let _ = tx_progress.send(HostTask::Runtime {
id: id.clone(),
runtime: InstanceRuntime::Busy {
stage: LaunchStage::Launch,
progress: Progress { done: 0, total: 1 },
},
});
let (request, warnings) = match build_request(&env, &target) {
Ok(ok) => ok,
Err(reason) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: String::new(),
outcome: LogOutcome::Crashed,
summary: format!("启动失败:{reason}"),
})));
return;
}
};
for warning in &warnings {
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
&format!("launch-warn:{}", target.id),
crate::dto::NoticeLevel::Info,
"启动提示",
warning,
))));
}
let config = LauncherConfig::new(env.sessions_root())
.with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
let session = match Launcher::new(config).spawn(&request) {
Ok(session) => session,
Err(error) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: String::new(),
outcome: LogOutcome::Crashed,
summary: format!("启动失败:{error}"),
})));
return;
}
};
let session_id = session.session_id().to_string();
let pid = session.pid();
let _ = tx.send(HostTask::LaunchStarted {
id: id.clone(),
session_id: session_id.clone(),
pid,
});
let _ = tx.send(HostTask::Runtime {
id: id.clone(),
runtime: InstanceRuntime::Running { pid },
});
match session.finish() {
Ok(handle) => {
let outcome = super::identity::outcome_of(&handle);
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: session_id,
outcome,
summary: format!(
"{}({},{:.1}s)",
launch_summary(outcome, Some(handle.exit.code)),
handle.exit.exit_type.as_str(),
handle.uptime_ms as f64 / 1000.0
),
})));
}
Err(error) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: session_id,
outcome: LogOutcome::SilentFail,
summary: format!("启动收尾失败:{error}"),
})));
}
}
});
}
/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。
pub fn stop(pid: u32) -> Result<(), String> {
tree_kill::kill_tree(pid, std::time::Duration::from_secs(3))
.map_err(|error| format!("停止进程 {pid} 失败:{error}"))
}
/// 适配层告警 → 人读一行。
fn describe_warning(warning: &AdapterWarning) -> String {
match warning {
AdapterWarning::MissingLibrary { name, path } => {
format!("缺少库文件 {name}({})", path.display())
}
AdapterWarning::MissingGameArguments => "版本 JSON 没有游戏参数".to_string(),
AdapterWarning::MissingAssetsIndex => "版本 JSON 没有资源索引".to_string(),
AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(),
AdapterWarning::MissingNativeJar { name, path } => {
format!("缺少 natives 构件 {name}({})", path.display())
}
}
}
/// 加载器 → 适配层的模组加载器标记(GUI 只用于显示,不改命令行)。
pub fn loader_label(kind: LoaderKind) -> &'static str {
match kind {
LoaderKind::Vanilla => "原版",
LoaderKind::Fabric => "Fabric",
LoaderKind::Quilt => "Quilt",
LoaderKind::Forge => "Forge",
LoaderKind::NeoForge => "NeoForge",
LoaderKind::OptiFine => "OptiFine",
LoaderKind::LiteLoader => "LiteLoader",
}
}
/// 隔离档位的展示串(`isolated` / `shared`)。
pub fn isolation_label(mode: IsolationMode) -> &'static str {
mode.as_str()
}
+267
View File
@@ -0,0 +1,267 @@
//! host 启动状态机单测(P2-1/P2-2 的回归网)。
//!
//! 覆盖三件事:
//! 1. `running_pid` 按实例 id 取 pid(并发启动不会把 A 的停止键指到 B);
//! 2. 重复启动守卫(`launches` 已落盘 + `pending` 窗口两态都要挡);
//! 3. `refresh_instances` 重建快照后仍保留真实运行时(Running / Busy / Failed)。
use unlucky_core::game::install::instance::write_profiles;
use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList};
use super::*;
use crate::dto::{LaunchFinished, LogOutcome};
use crate::toast::ToastKind;
/// 临时数据根守卫:测试结束(含断言 panic)时把目录删掉,不留空壳。
struct TempRoot(std::path::PathBuf);
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 建一个只有一条实例档案的数据根,返回 `(host, instance_id, 守卫)`。
fn host_with_instance(label: &str) -> (CoreHost, String, TempRoot) {
let dir = std::env::temp_dir().join(format!(
"ull-host-launch-{label}-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
let profile = InstanceProfile {
id: ProfileId::generate(),
name: "demo".to_string(),
version_id: "1.20.1".to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: std::collections::BTreeMap::new(),
};
let id = profile.id.as_str().to_string();
let profiles = ProfileList {
profiles: vec![profile],
..ProfileList::default()
};
write_profiles(&env.layout, &profiles).expect("写 profiles");
(CoreHost::headless(env), id, TempRoot(dir))
}
fn live(instance: &str, session: &str, pid: u32) -> LiveLaunch {
LiveLaunch {
instance: instance.to_string(),
session: session.to_string(),
pid,
}
}
#[test]
fn running_pid_is_per_instance_not_a_single_slot() {
let (mut host, id, _root) = host_with_instance("pid");
host.launches.push(live("a", "s-a", 111));
host.launches.push(live("b", "s-b", 222));
assert_eq!(host.running_pid("a"), Some(111));
assert_eq!(host.running_pid("b"), Some(222));
assert_eq!(
host.running_pid("c"),
None,
"没启动过的实例不该拿到别人的 pid"
);
// A 收尾后只影响 A:这正是 review r1 P2-1 的场景(旧实现共用一个槽位)。
host.launches.retain(|entry| entry.instance != "a");
assert_eq!(host.running_pid("a"), None);
assert_eq!(host.running_pid("b"), Some(222));
// 落盘实例默认是 Idle,兜底分支不误报。
assert_eq!(host.running_pid(&id), None);
}
#[test]
fn duplicate_launch_is_rejected_before_spawning() {
let (mut host, id, _root) = host_with_instance("dup");
let _ = host.drain_events();
host.launches.push(live(&id, "s", 111));
host.start_launch(&id);
let events = host.drain_events();
assert!(
!events.iter().any(|event| matches!(
event,
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
)),
"已在运行/启动中的实例不该再发 Busy(那会起第二个进程)"
);
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::Toast(toast) if toast.kind == ToastKind::Warn)),
"至少要给用户一条「已经在启动/运行中」的提示"
);
assert_eq!(host.launches.len(), 1, "启动表不该被重复启动污染");
}
#[test]
fn pending_window_also_blocks_a_second_launch() {
let (mut host, id, _root) = host_with_instance("pending");
// 走 start_launch 用的同一个入口,模拟「已受理、后台线程还没回报 LaunchStarted」。
host.mark_pending(&id);
assert!(host.in_flight(&id));
let _ = host.drain_events();
host.start_launch(&id);
let events = host.drain_events();
assert!(
!events.iter().any(|event| matches!(
event,
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
)),
"pending 窗口内重复点启动必须被挡住"
);
assert_eq!(host.pending.len(), 1);
}
#[test]
fn launch_started_registers_and_launch_end_clears() {
let (mut host, id, _root) = host_with_instance("cycle");
host.mark_pending(&id);
host.handle(HostTask::LaunchStarted {
id: id.clone(),
session_id: "20261031T120000-abcd12".to_string(),
pid: 4_242,
});
assert!(!host.pending.contains(&id), "回报后 pending 要清掉");
assert_eq!(host.running_pid(&id), Some(4_242));
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: "20261031T120000-abcd12".to_string(),
outcome: LogOutcome::Ok,
summary: "正常退出".to_string(),
})));
assert!(host.launches.is_empty(), "收尾后启动表要清空");
assert!(!host.failed.contains_key(&id));
assert_eq!(host.running_pid(&id), None);
}
#[test]
fn refresh_keeps_running_instances_running() {
let (mut host, id, _root) = host_with_instance("refresh-run");
host.launches.push(live(&id, "s", 4_242));
let _ = host.drain_events();
host.refresh_instances();
let instance = instances_from_events(host.drain_events())
.into_iter()
.find(|instance| instance.id == id)
.expect("刷新后应有该实例");
assert_eq!(
instance.runtime,
InstanceRuntime::Running { pid: 4_242 },
"刷新(装版本/账户变更/别的实例收尾)不能把在跑实例打回 Idle"
);
}
#[test]
fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
let (mut host, id, _root) = host_with_instance("refresh-busy");
// Busy:走 start_launch 用的同一个入口(mark_pending),而不是直接改字段。
host.mark_pending(&id);
let pending = instances_from_events({
host.refresh_instances();
host.drain_events()
})
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
assert!(matches!(
pending.runtime,
InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Launch,
..
}
));
// Failed:走唯一真实写入路径 LaunchEnd(不是手动塞 failed 表)。
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: "20261031T120000-dead01".to_string(),
outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(),
})));
assert!(!host.pending.contains(&id), "LaunchEnd 必须清 pending");
assert!(!host.in_flight(&id), "收尾后该实例要能被重新启动");
let failed = instances_from_events({
host.refresh_instances();
host.drain_events()
})
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
assert_eq!(
failed.runtime,
InstanceRuntime::Failed {
reason: "崩溃(exit 1)".to_string()
},
"失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)"
);
}
#[test]
fn launch_end_before_launch_started_still_releases_the_instance() {
let (mut host, id, _root) = host_with_instance("fail-early");
// build_request / spawn 失败的现场:start_launch 已标 pending、后台线程只发 LaunchEnd。
host.mark_pending(&id);
assert!(host.in_flight(&id));
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: String::new(),
outcome: LogOutcome::Crashed,
summary: "启动失败:找不到 Java".to_string(),
})));
assert!(
!host.pending.contains(&id),
"LaunchEnd 早于 LaunchStarted 时也必须清 pending(否则该实例本会话再也起不来)"
);
assert!(!host.in_flight(&id), "失败后要能重试");
let failed = instances_from_events({
host.refresh_instances();
host.drain_events()
})
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
assert_eq!(
failed.runtime,
InstanceRuntime::Failed {
reason: "启动失败:找不到 Java".to_string()
},
"早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉"
);
}
/// 从事件队列里取最后一次 `InstancesChanged`。
fn instances_from_events(events: Vec<AppEvent>) -> Vec<crate::dto::InstanceSummary> {
events
.into_iter()
.rev()
.find_map(|event| match event {
AppEvent::Core(CoreEvent::InstancesChanged(instances)) => Some(instances),
_ => None,
})
.unwrap_or_default()
}
+342
View File
@@ -0,0 +1,342 @@
//! 日志 / 诊断:会话索引 → 会话列表,事件 JSONL → 日志行,规则引擎 → 诊断卡片。
//!
//! 与 CLI `ull logs` / `ull diagnose` 同源(`launch::session_index` + `diagnose` 规则引擎):
//! 「同一份 jsonl 的两种读法」只有这一处实现(M39 ports/events.rs 的设计)。
//! 实时滚动 = host 每拍尾随启动会话的 JSONL(只读新增字节),不额外开第二条事件通道。
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use serde_json::Value;
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
use unlucky_core::diagnose::loader::RuleLoader;
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
use crate::dto::{
Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
};
use super::env::HostEnv;
/// 送进规则引擎的默认尾巴行数(与 CLI 同值,避免两条路看到不同证据)。
pub const DEFAULT_TAIL: usize = 2000;
/// 日志页一次最多渲染的行数(超出的截断,避免大文件把 UI 拖死)。
pub const MAX_RENDERED_LINES: usize = 2_000;
/// 会话索引目录(`.ULL/data/agent/sessions`)。
pub fn root(env: &HostEnv) -> PathBuf {
env.sessions_root()
}
/// 会话列表(新→旧)。
pub fn sessions(env: &HostEnv) -> Vec<LogSession> {
let index = session_index::index_path(&root(env));
let mut entries = session_index::read_all(&index).unwrap_or_default();
entries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
entries.iter().map(session_of).collect()
}
/// 索引条目 → 左栏会话行。
pub fn session_of(entry: &SessionIndexEntry) -> LogSession {
LogSession {
id: entry.session_id.clone(),
instance: if entry.instance_name.is_empty() {
entry.instance_id.clone()
} else {
entry.instance_name.clone()
},
when: display_time(&entry.started_at),
outcome: outcome_of(entry),
duration_secs: entry.duration_ms / 1000,
}
}
/// 结局:静默失败 > 崩溃(非 0 / crash_found) > 正常。
pub fn outcome_of(entry: &SessionIndexEntry) -> LogOutcome {
if entry.silent_failure {
LogOutcome::SilentFail
} else if entry.crash_found
|| entry.exit_code.is_some_and(|code| code != 0)
|| (entry.exit_code.is_none() && !entry.ended_at.is_empty())
{
LogOutcome::Crashed
} else {
LogOutcome::Ok
}
}
/// `2026-10-29T19:22:45.123Z` → `10-29 19:22`。
pub fn display_time(started_at: &str) -> String {
let body = started_at.trim_end_matches('Z');
let Some((date, time)) = body.split_once('T') else {
return started_at.to_string();
};
let date = date.get(5..).unwrap_or(date);
let time = time.get(..5).unwrap_or(time);
format!("{date} {time}")
}
/// 事件流文件路径(`<sessions>/game/<log_path>`)。
pub fn event_path(env: &HostEnv, entry: &SessionIndexEntry) -> PathBuf {
root(env).join("game").join(&entry.log_path)
}
/// 会话 id 或唯一前缀 → 索引条目。
fn find(env: &HostEnv, key: &str) -> Option<SessionIndexEntry> {
let index = session_index::index_path(&root(env));
let entries = session_index::read_all(&index).ok()?;
if let Some(exact) = entries.iter().find(|entry| entry.session_id == key) {
return Some(exact.clone());
}
entries
.iter()
.find(|entry| entry.session_id.starts_with(key))
.cloned()
}
/// 事件 JSON → 一行日志(`stdout`/`stderr` 的 `payload.line`)。
pub fn line_of(event: &Value) -> Option<LogLine> {
let text = event.pointer("/payload/line").and_then(Value::as_str)?;
let kind = event
.get("kind")
.and_then(Value::as_str)
.unwrap_or_default();
let level = match event
.pointer("/payload/level")
.and_then(Value::as_str)
.unwrap_or_default()
{
"error" => LogLevel::Error,
"warn" | "warning" => LogLevel::Warn,
"debug" | "trace" => LogLevel::Debug,
_ => {
if kind == "stderr" {
LogLevel::Warn
} else {
LogLevel::Info
}
}
};
Some(LogLine {
text: text.to_string(),
level,
})
}
/// 整个事件流 → 日志行(解析失败的行跳过,容忍被 kill 时截断的尾行)。
pub fn read_lines(path: &Path) -> Vec<LogLine> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
text.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter_map(|event| line_of(&event))
.collect()
}
/// 打开一个会话:日志行 + 规则引擎结论。
pub fn open(env: &HostEnv, key: &str) -> (Vec<LogLine>, Option<DiagnosisReport>) {
let Some(entry) = find(env, key) else {
return (Vec::new(), None);
};
let path = event_path(env, &entry);
let lines = read_lines(&path);
let diagnosis = diagnose_lines(&entry, &lines);
(lines, diagnosis)
}
/// 规则引擎跑一遍(**规则先跑,模型补位**:这里只出规则结论)。
pub fn diagnose_lines(entry: &SessionIndexEntry, lines: &[LogLine]) -> Option<DiagnosisReport> {
let stderr_text: String = lines
.iter()
.filter(|line| line.level == LogLevel::Error || line.level == LogLevel::Warn)
.map(|line| line.text.as_str())
.collect::<Vec<_>>()
.join("\n");
let body: String = lines
.iter()
.rev()
.take(DEFAULT_TAIL)
.map(|line| line.text.as_str())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
if body.is_empty() {
return None;
}
let error_lines = u32::try_from(
lines
.iter()
.filter(|line| line.level == LogLevel::Error)
.count(),
)
.unwrap_or(u32::MAX);
let exit = ExitSignal::classify(
entry.exit_code,
Some(entry.duration_ms),
error_lines,
&stderr_text,
);
let mut input = DiagnosisInput::new().with_exit(exit);
input.push_text(SignalOrigin::Stderr, body);
let loader = RuleLoader::embedded();
let report = Diagnoser::new(loader.rule_set().rules().to_vec()).analyze(&input);
let finding = report
.findings
.first()
.or_else(|| report.secondary.first())?;
Some(DiagnosisReport {
session: entry.session_id.clone(),
diagnosis: Diagnosis {
rule: finding.rule_id.clone(),
title: finding.message.clone(),
confidence: confidence_percent(finding.confidence),
hint: finding.detail.clone().unwrap_or_else(|| {
report
.exit
.as_ref()
.map(|exit| exit.exit_type.as_str().to_string())
.unwrap_or_else(|| "未知退出".to_string())
}),
fix: finding
.materialized_actions()
.first()
.map(|action| action.tool.to_string()),
},
})
}
/// 0.0~1.0 → 0~100(四舍五入)。
pub fn confidence_percent(confidence: f32) -> u8 {
let percent = (confidence.clamp(0.0, 1.0) * 100.0).round();
u8::try_from(percent as u32).unwrap_or(100)
}
/// 导入崩溃日志(`hs_err_pid*.log` / `crash-*.txt` / `latest.log`)做规则分析。
///
/// 文件名含 `hs_err` 时先走 `hs_err::parse`(结构化字段进规则引擎),否则整份文本当信号喂进去。
pub fn analyze_crash_file(path: &str) -> Result<(String, DiagnosisReport), String> {
let path = Path::new(path);
if !path.is_file() {
return Err(format!("找不到崩溃日志:{}", path.display()));
}
let text = std::fs::read_to_string(path).map_err(|error| format!("读崩溃日志失败:{error}"))?;
let name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "crash.log".to_string());
let is_hs_err = name.contains("hs_err");
let mut input = DiagnosisInput::new();
if is_hs_err {
input.push_hs_err(unlucky_core::diagnose::hs_err::parse(&text));
}
input.push_text(
if is_hs_err {
SignalOrigin::HsErr
} else if name.contains("crash") {
SignalOrigin::CrashReport
} else {
SignalOrigin::LatestLog
},
text,
);
let loader = RuleLoader::embedded();
let report = Diagnoser::new(loader.rule_set().rules().to_vec()).analyze(&input);
let finding = report
.findings
.first()
.or_else(|| report.secondary.first())
.ok_or_else(|| "规则引擎没有从这份日志里认出已知问题".to_string())?;
Ok((
format!("导入的日志:{name}"),
DiagnosisReport {
session: format!("file:{name}"),
diagnosis: Diagnosis {
rule: finding.rule_id.clone(),
title: finding.message.clone(),
confidence: confidence_percent(finding.confidence),
hint: finding
.detail
.clone()
.unwrap_or_else(|| "规则引擎命中(详见证据行)".to_string()),
fix: finding
.materialized_actions()
.first()
.map(|action| action.tool.to_string()),
},
},
))
}
/// 实时尾随器:只读文件新增的字节,解析出完整的 JSON 行。
#[derive(Debug, Default)]
pub struct Tail {
path: Option<PathBuf>,
session: Option<SessionId>,
offset: u64,
}
impl Tail {
/// 开始尾随一个会话。
pub fn attach(&mut self, path: PathBuf, session: &str) {
self.path = Some(path);
self.session = Some(session.to_string());
self.offset = 0;
}
/// 停止尾随。
pub fn detach(&mut self) {
self.path = None;
self.session = None;
self.offset = 0;
}
/// 当前尾随的会话。
pub fn session(&self) -> Option<&str> {
self.session.as_deref()
}
/// 读新行(返回 `(会话 id, 新行)`;没有新内容返回 `None`)。
pub fn poll(&mut self) -> Option<(String, Vec<LogLine>)> {
let path = self.path.clone()?;
let session = self.session.clone()?;
let file = std::fs::File::open(&path).ok()?;
let len = file.metadata().ok()?.len();
if len <= self.offset {
return None;
}
let mut file = file;
file.seek(SeekFrom::Start(self.offset)).ok()?;
let mut buffer = String::new();
file.read_to_string(&mut buffer).ok()?;
// 只吃到最后一个换行:半行留到下一拍(避免把 JSON 截断解析成脏行)
let last_newline = buffer.rfind('\n')?;
let complete = &buffer[..=last_newline];
self.offset += u64::try_from(complete.len()).unwrap_or(u64::MAX);
let lines: Vec<LogLine> = complete
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter_map(|event| line_of(&event))
.collect();
Some((session, lines))
}
}
/// 会话是否已收尾(索引里出现 `ended_at` 或文件停止增长超过一拍)。
pub fn session_finished(env: &HostEnv, session: &str) -> bool {
let index = session_index::index_path(&root(env));
session_index::read_all(&index)
.unwrap_or_default()
.iter()
.any(|entry| entry.session_id == session && !entry.ended_at.is_empty())
}
#[cfg(test)]
#[path = "logs_tests.rs"]
mod tests;
+86
View File
@@ -0,0 +1,86 @@
//! host/logs.rs 的单测(拆文件守 400 行上限)。
use super::*;
#[test]
fn time_display_and_outcomes() {
assert_eq!(display_time("2026-10-29T19:22:45.123Z"), "10-29 19:22");
assert_eq!(display_time("nope"), "nope");
let mut entry = SessionIndexEntry {
v: 1,
session_id: "s".to_string(),
instance_id: String::new(),
instance_name: String::new(),
game_version: String::new(),
started_at: String::new(),
ended_at: "2026-10-29T19:22:45Z".to_string(),
duration_ms: 1000,
exit_code: Some(0),
exit_type: Some("normal".to_string()),
silent_failure: false,
window_detected: true,
event_count: 1,
log_path: "s.jsonl".to_string(),
crash_found: false,
diagnosis_top: None,
};
assert_eq!(outcome_of(&entry), LogOutcome::Ok);
entry.exit_code = Some(1);
assert_eq!(outcome_of(&entry), LogOutcome::Crashed);
entry.silent_failure = true;
assert_eq!(outcome_of(&entry), LogOutcome::SilentFail);
}
#[test]
fn lines_map_levels_and_skip_non_line_events() {
let stdout = serde_json::json!({"kind":"stdout","payload":{"line":"hi","level":"info"}});
assert_eq!(line_of(&stdout).unwrap().level, LogLevel::Info);
let stderr = serde_json::json!({"kind":"stderr","payload":{"line":"boom","level":"error"}});
assert_eq!(line_of(&stderr).unwrap().level, LogLevel::Error);
let other = serde_json::json!({"kind":"cmd_built","payload":{"argv":[]}});
assert!(line_of(&other).is_none());
}
#[test]
fn tail_only_consumes_complete_lines() {
let dir = std::env::temp_dir().join(format!("ull-host-tail-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("目录");
let path = dir.join("s.jsonl");
std::fs::write(
&path,
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"one\"}}\n",
)
.expect("写");
let mut tail = Tail::default();
tail.attach(path.clone(), "s");
let (session, lines) = tail.poll().expect("首拍有新行");
assert_eq!(session, "s");
assert_eq!(lines.len(), 1);
assert!(tail.poll().is_none(), "没有新增就不该再产出");
// 半行不消费
let mut text = std::fs::read_to_string(&path).expect("读");
text.push_str("{\"kind\":\"stdout\",\"payload\":{\"line\":\"tw");
std::fs::write(&path, text).expect("写");
assert!(tail.poll().is_none(), "半个 JSON 行不能解析");
let mut text = std::fs::read_to_string(&path).expect("读");
text.push_str("o\"}}\n");
std::fs::write(&path, text).expect("写");
let (_, lines) = tail.poll().expect("补全后应产出");
assert_eq!(lines[0].text, "two");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn crash_import_reports_missing_file() {
let err = analyze_crash_file("D:/definitely/missing/hs_err_pid1.log").unwrap_err();
assert!(err.contains("找不到崩溃日志"), "{err}");
}
#[test]
fn confidence_percent_is_rounded_and_clamped() {
assert_eq!(confidence_percent(0.923), 92);
assert_eq!(confidence_percent(-1.0), 0);
assert_eq!(confidence_percent(2.0), 100);
}
+80
View File
@@ -0,0 +1,80 @@
//! 安装进度的记账器(文件计数 / 字节 / 速度 / ETA)。
//!
//! `execute` 的文件级回调已经把每个文件的结局给了我们,这里只做聚合;
//! 速度用相邻两次采样的字节差算(core 的事件只给绝对值)。
use std::sync::Arc;
use std::time::Instant;
use unlucky_core::game::install::{FileOutcome, FileStatus, ProgressSink};
use crate::dto::{derive_speed, CreateRequest, InstallProgressView, InstallStage};
use super::install::loader_label;
use super::job::{HostTask, JobSender};
/// 一次安装的上下文(进度回调共享)。
pub(super) struct Meter {
pub(super) progress: InstallProgressView,
previous: Option<(u64, Instant)>,
}
impl Meter {
pub(super) fn new(request: &CreateRequest) -> Self {
Self {
progress: InstallProgressView::start(
&request.version,
&request.instance_name(),
&loader_label(request.loader, &request.loader_version),
),
previous: None,
}
}
/// 换阶段(重置速度采样)。
pub(super) fn stage(&mut self, stage: InstallStage, message: &str) {
self.progress.stage = stage;
self.progress.message = message.to_string();
self.previous = None;
}
/// 预记账一段待下载的文件(文件数 + 字节)。
///
/// 一次安装可能分几段(原版 / 处理器依赖库 / 加载器库):每段开工前都先记账,
/// 整条进度曲线才是单调的——某一段下完不会先到 100%,再因为下一段的文件被计入而回落。
pub(super) fn expect(&mut self, files: usize, bytes: u64) {
let files = u64::try_from(files).unwrap_or(u64::MAX);
self.progress.files_total = self.progress.files_total.saturating_add(files);
self.progress.bytes_total = self.progress.bytes_total.saturating_add(bytes);
}
/// 记账一个文件的结局。
pub(super) fn note(&mut self, outcome: &FileOutcome) {
self.progress.files_done += 1;
if outcome.status != FileStatus::Skipped {
self.progress.bytes_done = self.progress.bytes_done.saturating_add(outcome.bytes);
}
let now = Instant::now();
if let Some(previous) = self.previous {
if let Some((speed, eta)) = derive_speed(
previous,
(self.progress.bytes_done, now),
self.progress.bytes_total,
) {
self.progress.speed_bps = speed;
self.progress.eta_secs = eta;
}
}
self.previous = Some((self.progress.bytes_done, now));
}
}
/// 进度 sink(`execute` 的文件级回调)。
pub(super) fn sink_for(tx: JobSender, meter: Arc<std::sync::Mutex<Meter>>) -> ProgressSink {
Arc::new(move |outcome: &FileOutcome| {
let Ok(mut meter) = meter.lock() else {
return;
};
meter.note(outcome);
let _ = tx.send(HostTask::Install(Box::new(meter.progress.clone())));
})
}
+344
View File
@@ -0,0 +1,344 @@
//! 真实后端:把 core(game/launch/install/auth/java/diagnose/setting)装配成 GUI 的 host。
//!
//! 边界:本模块是整个 app 里**唯一**允许 `use unlucky_core::…` 的地方(连同 `settings_bridge`);
//! 出口只有 [`crate::dto`] 的快照/事件。线程模型:
//!
//! ```text
//! dispatch(AppCommand) ──► 立即动作(读盘/写设置)或 spawn 后台线程
//! 后台线程 ──HostTask──► rx ──tick()──► CoreEvent ──► state.apply()
//! tick() 另外每 ~600ms 尾随一次运行中会话的 JSONL(实时日志滚动)
//! ```
//!
//! AI 对话暂时仍由 [`MockBackend`] 提供脚本回复(agent 的 GUI host 是后续 mission);
//! 其余页面全部走真实 core。
mod accounts;
mod dispatch;
mod engine;
pub mod env;
mod game;
mod handle;
mod identity;
mod install;
mod install_journal;
mod install_processors;
mod java;
mod job;
mod launch;
mod logs;
mod meter;
mod opener;
mod settings;
mod versions;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::time::Duration;
use crate::dto::{
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
InstanceRuntime, JavaChainStep, NoticeView,
};
use crate::mock::MockBackend;
use crate::toast::{Toast, ToastKind};
use accounts::LoginJob;
pub use env::HostEnv;
use job::{HostTask, JobReceiver, JobSender};
/// 每拍最多处理的后台消息数(防止一次 tick 吃掉整帧预算)。
const MAX_TASKS_PER_TICK: usize = 64;
/// 实时日志尾随间隔(拍数,33ms/拍 → 约 600ms 一次)。
const TAIL_POLL_TICKS: u64 = 18;
/// 一次在跑的启动(并发启动时按实例 id 区分,停止/尾随都不会串到别的实例)。
#[derive(Debug, Clone, PartialEq, Eq)]
struct LiveLaunch {
instance: String,
session: String,
pid: u32,
}
/// 真实 host。
pub struct CoreHost {
env: HostEnv,
events: VecDeque<AppEvent>,
tx: JobSender,
rx: JobReceiver,
ticks: u64,
/// 当前启动账户 id(内存态;跨会话持久化要 core schema 增键,见 handoff)
active_account: Option<String>,
accounts: Vec<AccountView>,
login: Option<LoginJob>,
java_chain: Vec<JavaChainStep>,
java_busy: bool,
java_message: String,
install: Option<InstallProgressView>,
notices: Vec<NoticeView>,
/// 在跑的启动(实例 → 会话 → pid;支持多个实例同时启动,停止按 id 找正确的 pid)
launches: Vec<LiveLaunch>,
/// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程)
pending: BTreeSet<String>,
/// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观)
failed: BTreeMap<String, String>,
/// 实时日志尾随器
tail: logs::Tail,
/// AI 对话的临时后端(agent GUI host 落地前的占位)
chat: MockBackend,
/// 当前安装任务的取消标记(阶段边界检查)
install_cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
impl CoreHost {
/// 按便携数据根装配;解析不出数据根返回 `None`(调用方退回 mock)。
pub fn open() -> Option<Self> {
HostEnv::resolve().map(Self::new)
}
/// 用给定环境装配(截图/单测通道)。
pub fn new(env: HostEnv) -> Self {
Self::build(env, true)
}
/// 单测用:不跑 `refresh_all`(不起版本清单网络线程),其余装配一致。
#[cfg(test)]
pub(crate) fn headless(env: HostEnv) -> Self {
Self::build(env, false)
}
fn build(env: HostEnv, refresh: bool) -> Self {
let (tx, rx) = job::channel();
game::ensure_root(&env);
let mut host = Self {
env,
events: VecDeque::new(),
tx,
rx,
ticks: 0,
active_account: None,
accounts: Vec::new(),
login: None,
java_chain: Vec::new(),
java_busy: false,
java_message: String::new(),
install: None,
notices: Vec::new(),
launches: Vec::new(),
pending: BTreeSet::new(),
failed: BTreeMap::new(),
tail: logs::Tail::default(),
chat: MockBackend::default(),
install_cancel: None,
};
if refresh {
host.refresh_all();
}
host
}
/// 数据根(截图/诊断展示用)。
pub fn env(&self) -> &HostEnv {
&self.env
}
fn send(&mut self, event: CoreEvent) {
self.events.push_back(AppEvent::Core(event));
}
fn toast(&mut self, kind: ToastKind, text: impl Into<String>) {
self.events.push_back(AppEvent::Toast(Toast {
id: 0,
kind,
text: text.into(),
remaining_ms: 0,
}));
}
/// 全量刷新:实例 / 会话 / 账户 / 设置 / Java,并触发一次版本清单拉取。
pub fn refresh_all(&mut self) {
self.refresh_instances();
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
self.refresh_accounts();
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
self.refresh_java();
versions::spawn_list(self.env.clone(), self.tx.clone());
}
/// 重建实例快照,并把**真实运行时**盖回去。
///
/// `game::instances` 只读盘(每条都是 `Idle`),所以在跑的实例必须在重建后按
/// host 自己的启动表覆写 `Running{pid}`/`Busy`,否则任意一次刷新(装版本成功、
/// 加删账户、另一实例收尾)都会把磁贴打回 Idle:停止键与「运行中」条消失,
/// 进程还在跑却再也停不下来。
fn refresh_instances(&mut self) {
let mut instances = game::instances(&self.env);
for instance in &mut instances {
if let Some(live) = self
.launches
.iter()
.find(|live| live.instance == instance.id)
{
instance.runtime = InstanceRuntime::Running { pid: live.pid };
} else if self.pending.contains(&instance.id) {
instance.runtime = InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Launch,
progress: crate::dto::Progress { done: 0, total: 1 },
};
} else if let Some(reason) = self.failed.get(&instance.id) {
instance.runtime = InstanceRuntime::Failed {
reason: reason.clone(),
};
}
}
self.send(CoreEvent::InstancesChanged(instances));
}
fn refresh_accounts(&mut self) {
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
if self.active_account.is_none() {
// 默认选中第一个账户(没有账户时保持 None = 离线占位)
self.active_account = self.accounts.first().map(|account| account.id.clone());
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
}
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
}
fn refresh_java(&mut self) {
let page = java::page(
&self.env,
self.java_chain.clone(),
self.java_busy,
&self.java_message,
);
self.send(CoreEvent::JavaChanged(page));
}
fn push_notice(&mut self, notice: NoticeView) {
self.notices.retain(|existing| existing.id != notice.id);
self.notices.push(notice);
self.send(CoreEvent::NoticesChanged(self.notices.clone()));
}
/// 打开一个会话(日志行 + 诊断)。
fn open_session(&mut self, id: &str) {
let (lines, diagnosis) = logs::open(&self.env, id);
self.send(CoreEvent::LogLinesChanged(lines));
self.send(CoreEvent::DiagnosisReady(diagnosis.unwrap_or(
DiagnosisReport {
session: id.to_string(),
diagnosis: crate::dto::Diagnosis {
rule: String::new(),
title: "该会话没有命中已知规则".to_string(),
confidence: 0,
hint: "可点「让 AI 详解」把日志交给模型进一步分析".to_string(),
fix: None,
},
},
)));
}
/// 由实例名/版本 id 找启动目标。
fn launch_target(&self, id: &str) -> Option<launch::LaunchTarget> {
let instance = game::instances(&self.env)
.into_iter()
.find(|instance| instance.id == id || instance.name == id)?;
Some(launch::LaunchTarget {
id: instance.id,
name: instance.name,
version_id: instance.version_id,
account: self.active_account.clone(),
})
}
/// 某实例是否已在启动/运行(`pending` 与 `launches` 的并集)。
///
/// 只查 `launches` 不够:`launch::start` 到后台线程回报 `LaunchStarted` 之间有窗口,
/// 这期间重复点启动会起第二个进程、并把旧 pid 在启动表里顶掉(旧进程变孤儿)。
fn in_flight(&self, key: &str) -> bool {
self.pending.contains(key) || self.launches.iter().any(|live| live.instance == key)
}
/// 标记「已受理、等后台线程回报」(`launch::start` 之后的窗口)。
///
/// 单测也走这个入口而不是直接改 `pending`,保证测的是真实状态流转。
fn mark_pending(&mut self, id: &str) {
self.pending.insert(id.to_string());
self.failed.remove(id);
}
fn start_launch(&mut self, id: &str) {
if self.in_flight(id) {
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
return;
}
let Some(target) = self.launch_target(id) else {
self.toast(ToastKind::Error, format!("找不到实例 {id}"));
return;
};
let instance_id = target.id.clone();
if self.in_flight(&instance_id) {
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
return;
}
self.mark_pending(&instance_id);
if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) {
self.push_notice(notice);
}
self.send(CoreEvent::InstanceRuntimeChanged {
id: instance_id.clone(),
runtime: InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Launch,
progress: crate::dto::Progress { done: 0, total: 1 },
},
});
launch::start(self.env.clone(), self.tx.clone(), target);
}
/// 某实例在跑的 pid(按实例 id 查启动表;不再有「最近一次启动」这种单槽)。
///
/// 兜底分支读实例快照的 `Running { pid }`:host 自己发的运行时事件会写回快照,
/// 但快照是重建的(`game::instances` 每次 Idle),所以这层只是给将来留的口。
fn running_pid(&self, id: &str) -> Option<u32> {
if let Some(live) = self.launches.iter().find(|live| live.instance == id) {
return Some(live.pid);
}
game::instances(&self.env)
.into_iter()
.find(|instance| instance.id == id)
.and_then(|instance| match instance.runtime {
InstanceRuntime::Running { pid } => Some(pid),
_ => None,
})
}
}
impl Backend for CoreHost {
fn drain_events(&mut self) -> Vec<AppEvent> {
self.events.drain(..).collect()
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatch_command(command);
}
fn tick(&mut self, elapsed: Duration) {
self.chat.tick(elapsed);
for event in self.chat.drain_events() {
self.events.push_back(event);
}
for _ in 0..MAX_TASKS_PER_TICK {
let Ok(task) = self.rx.try_recv() else {
break;
};
self.handle(task);
}
self.ticks += 1;
if self.ticks.is_multiple_of(TAIL_POLL_TICKS) {
self.poll_tail();
}
}
}
#[cfg(test)]
#[path = "launch_state_tests.rs"]
mod launch_state_tests;
+72
View File
@@ -0,0 +1,72 @@
//! 用系统默认浏览器打开授权 URL(授权码流唯一需要宿主做的事)。
//!
//! core 只负责拼授权 URL 与等本地回调(`core::auth::microsoft::authcode`);
//! 「谁来打开浏览器」是宿主的决定:GUI 走这里,CLI/无浏览器场景仍用设备码流。
//!
//! 平台差异收在 [`launcher`] 一处,不往业务代码里散写 `cfg` 补丁(`AGENTS.md` §7)。
use std::process::Command;
/// 拉起系统默认浏览器打开 `url`(只接受 http/https)。
///
/// 不等待浏览器退出:用户可能在浏览器里停留几分钟,宿主只关心「有没有拉起来」。
///
/// # Errors
///
/// URL 不是 http/https,或进程拉不起来时返回用户可读文案。
pub fn open_url(url: &str) -> Result<(), String> {
if !url.starts_with("https://") && !url.starts_with("http://") {
return Err(format!("不是可打开的网址:{url}"));
}
let (program, args) = launcher(url);
Command::new(program)
.args(args)
.spawn()
.map(|_child| ())
.map_err(|error| format!("拉起浏览器失败({program}):{error}"))
}
#[cfg(target_os = "windows")]
fn launcher(url: &str) -> (&'static str, Vec<String>) {
// `rundll32` 不用 shell,URL 里的 `&` 不会被当成命令分隔符(`cmd /C start` 会)。
(
"rundll32",
vec!["url.dll,FileProtocolHandler".to_string(), url.to_string()],
)
}
#[cfg(target_os = "macos")]
fn launcher(url: &str) -> (&'static str, Vec<String>) {
("open", vec![url.to_string()])
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn launcher(url: &str) -> (&'static str, Vec<String>) {
("xdg-open", vec![url.to_string()])
}
#[cfg(test)]
mod tests {
use super::{launcher, open_url};
#[test]
fn rejects_non_http_urls() {
assert!(open_url("javascript:alert(1)").is_err());
assert!(open_url("").is_err());
// 合法 URL 只验命令形态,不在单测里真去拉浏览器。
let (program, args) = launcher("http://localhost:1234/auth?code=a&state=b");
assert!(!program.is_empty());
assert!(args
.iter()
.any(|arg| arg.contains("http://localhost:1234/auth")));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_uses_the_file_protocol_handler() {
let (program, args) = launcher("https://example.invalid/");
assert_eq!(program, "rundll32");
assert_eq!(args[0], "url.dll,FileProtocolHandler");
assert_eq!(args[1], "https://example.invalid/");
}
}
+398
View File
@@ -0,0 +1,398 @@
//! 设置页分组:按 `core::setting` schema 的键生成行,写回走 `SettingStore::set`。
//!
//! 三件纪律(AGENTS §6):
//! 1. 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` / AI `set_config` 同源);
//! 2. 不自己拼 JSON,也不绕 schema(非法键/越界值由 `store.set` 拒绝);
//! 3. 这里只认「键名 + 字符串值」,领域语义留在 core。
//!
//! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。
use serde_json::Value;
use unlucky_core::game::java::JdkVendor;
use crate::dto::{SettingGroupView, SettingRowView};
use super::env::HostEnv;
/// 全部设置分组(设置页右栏一次拿全,切分组不再请求)。
pub fn groups(env: &HostEnv) -> Vec<SettingGroupView> {
vec![
download_group(env),
java_group(env),
data_group(env),
ai_group(env),
mcp_group(env),
]
}
/// 读一个键的字符串值(缺省给空串)。
fn text(env: &HostEnv, key: &str) -> String {
env.store()
.doc()
.effective_str(key)
.unwrap_or_default()
.to_string()
}
/// 读一个键的整数(缺省给 `fallback`)。
fn int(env: &HostEnv, key: &str, fallback: i64) -> i64 {
env.store()
.effective(key)
.and_then(Value::as_i64)
.unwrap_or(fallback)
}
/// 读一个键的布尔(缺省给 `fallback`)。
fn flag(env: &HostEnv, key: &str, fallback: bool) -> bool {
env.store()
.effective(key)
.and_then(Value::as_bool)
.unwrap_or(fallback)
}
/// 枚举值在选项表里的序号(找不到给 0)。
fn selected(options: &[&str], value: &str) -> i32 {
options
.iter()
.position(|option| *option == value)
.map(|index| i32::try_from(index).unwrap_or(0))
.unwrap_or(0)
}
/// 下载(D7):并发/重试/地区/镜像偏好/代理。
fn download_group(env: &HostEnv) -> SettingGroupView {
let concurrency = int(env, "download.concurrency", 8).to_string();
let retry = int(env, "download.retry_limit", 3).to_string();
let concurrency_options = ["4", "8", "16"];
let retry_options = ["0", "1", "3", "5"];
let regions = ["auto", "cn", "global"];
let mirrors = ["auto", "official", "mirror"];
let proxies = ["none", "system", "manual"];
let region_value = text(env, "download.region");
let mirror_value = text(env, "download.mirror_preference");
let proxy_value = text(env, "download.proxy_mode");
SettingGroupView::new("download", "下载与镜像(D7)")
.with(SettingRowView::choice(
"download.concurrency",
"并发数",
"同时下载的文件数(海量小文件,16 路足够压满带宽)",
&concurrency_options,
selected(&concurrency_options, &concurrency),
))
.with(SettingRowView::choice(
"download.retry_limit",
"单文件重试次数",
"失败后换源重试的上限",
&retry_options,
selected(&retry_options, &retry),
))
.with(SettingRowView::choice(
"download.region",
"地区判定",
"auto = 时区+语言+探测;决定是否优先国内镜像",
&regions,
selected(
&regions,
if region_value.is_empty() {
"auto"
} else {
&region_value
},
),
))
.with(SettingRowView::choice(
"download.mirror_preference",
"镜像偏好",
"auto = 按健康度评分;official = 官方源优先",
&mirrors,
selected(
&mirrors,
if mirror_value.is_empty() {
"auto"
} else {
&mirror_value
},
),
))
.with(SettingRowView::choice(
"download.proxy_mode",
"代理模式",
"none / system(跟随系统)/ manual(下面填地址)",
&proxies,
selected(
&proxies,
if proxy_value.is_empty() {
"none"
} else {
&proxy_value
},
),
))
.with(SettingRowView::text(
"download.proxy_url",
"HTTP 代理",
"manual 模式生效;留空则用系统代理",
&text(env, "download.proxy_url"),
))
.with(SettingRowView::action(
"download.action.detect_region",
"重新检测地区",
"清掉 24h 地区缓存,下次下载重新判定",
"detect_region",
))
}
/// Java(D13/D23):自动安装/内存/选路/发行版优先级。
fn java_group(env: &HostEnv) -> SettingGroupView {
let memory = int(env, "java.default_memory_mb", 2048).to_string();
let memory_options = ["1024", "2048", "4096", "8192"];
let priority: Vec<String> = env
.store()
.effective("java.vendorPriority")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let priority_text = if priority.is_empty() {
"地区默认序(Temurin → …)".to_string()
} else {
priority.join(" → ")
};
let roots = super::engine::java_scan_roots(env);
SettingGroupView::new("java", "Java(D13)")
.with(SettingRowView::toggle(
"java.auto_install",
"缺 Java 时自动下载",
"按回退链装一个托管 JDK(基准 JDK17)",
flag(env, "java.auto_install", true),
))
.with(SettingRowView::choice(
"java.default_memory_mb",
"默认最大堆内存",
"实例没单独指定时用这个值(MB)",
&memory_options,
selected(&memory_options, &memory),
))
.with(SettingRowView::text(
"java.selected_path",
"指定 Java 可执行文件",
"留空 = 自动挑(托管 > JAVA_HOME > PATH)",
&text(env, "java.selected_path"),
))
.with(SettingRowView::text(
"java.installRoot",
"托管 JDK 安装根",
"留空 = 按数据目录模式派生(portable 进数据根 / standard 进系统目录)",
&text(env, "java.installRoot"),
))
.with(SettingRowView::text(
"java.extra_args",
"附加 JVM 参数",
"原样拼到命令行尾部",
&text(env, "java.extra_args"),
))
.with(SettingRowView::read_only(
"发行版优先级(java.vendorPriority)",
&priority_text,
))
.with(SettingRowView::read_only(
"托管根",
&roots
.iter()
.map(|root| root.display().to_string())
.collect::<Vec<_>>()
.join(" · "),
))
}
/// 数据目录(D6 三模式)。
fn data_group(env: &HostEnv) -> SettingGroupView {
let modes = ["portable", "standard", "custom"];
let mode = env.mode().as_str().to_string();
SettingGroupView::new("data", "数据目录(D6 三模式)")
.with(SettingRowView::choice(
"core.data_mode",
"存储模式",
"便携(运行目录)/ 标准(系统应用数据目录)/ 自定义",
&modes,
selected(&modes, &mode),
))
.with(SettingRowView::text(
"core.data_custom_root",
"自定义根目录",
"custom 模式生效(绝对路径)",
&text(env, "core.data_custom_root"),
))
.with(SettingRowView::read_only(
"启动器数据目录",
&env.data.launcher_home.display().to_string(),
))
.with(SettingRowView::read_only(
"游戏目录",
&env.data.game_home.display().to_string(),
))
.with(SettingRowView::read_only(
"生效模式",
&format!("{}(改数据根需重启启动器)", env.mode()),
))
}
/// AI(D4):开关/端点/模型/YOLO。
fn ai_group(env: &HostEnv) -> SettingGroupView {
let providers = ["openai_compatible", "anthropic", "ollama"];
let provider = text(env, "ai.provider");
SettingGroupView::new("ai", "AI 助手(D4)")
.with(SettingRowView::toggle(
"ai.enabled",
"启用 AI 助手",
"关闭后规则引擎诊断仍可用",
flag(env, "ai.enabled", true),
))
.with(SettingRowView::choice(
"ai.provider",
"模型端点",
"OpenAI 兼容(含本地 llama.cpp)/ Anthropic / Ollama",
&providers,
selected(
&providers,
if provider.is_empty() {
"openai_compatible"
} else {
&provider
},
),
))
.with(SettingRowView::text(
"ai.base_url",
"端点 base_url",
"如 http://127.0.0.1:8080/v1",
&text(env, "ai.base_url"),
))
.with(SettingRowView::text(
"ai.model",
"模型名",
"如 deepseek-chat",
&text(env, "ai.model"),
))
.with(SettingRowView::toggle(
"ai.yolo",
"YOLO 模式",
"跳过确认弹窗(审计照记;账户/凭据操作永不 YOLO)",
flag(env, "ai.yolo", false),
))
}
/// M39 的对外 MCP server(默认关、默认只读)。
fn mcp_group(env: &HostEnv) -> SettingGroupView {
let port = int(env, "agent.mcp.port", 47_823);
SettingGroupView::new("mcp", "MCP server(M39)")
.with(SettingRowView::toggle(
"agent.mcp.enabled",
"对外提供 MCP server",
"127.0.0.1 上的 Streamable HTTP(stdio 需显式启动)",
flag(env, "agent.mcp.enabled", false),
))
.with(SettingRowView::text(
"agent.mcp.port",
"端口",
"1024~65535,只绑 127.0.0.1",
&port.to_string(),
))
.with(SettingRowView::toggle(
"agent.mcp.allow_write",
"允许外部客户端写配置",
"默认只读;写仍受 schema 白名单与分级确认约束",
flag(env, "agent.mcp.allow_write", false),
))
.with(SettingRowView::read_only(
"客户端地址",
&format!("http://127.0.0.1:{port}/mcp(令牌见 agent.mcp.token,AI 不可写)"),
))
}
/// 写一条设置。`value` 是字符串形态,按 schema 键的类型转换后交 `store.set`。
///
/// # Errors
///
/// 未知键、值类型不符、越界、落盘失败时返回用户可读文案。
pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
if key == "download.action.detect_region" {
return detect_region(env);
}
let parsed = parse_value(key, value)?;
let mut store = env.store();
store
.set(key, parsed)
.map_err(|error| format!("设置 {key} 失败:{error}"))?;
store
.save()
.map_err(|error| format!("保存设置失败:{error}"))?;
Ok(())
}
/// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。
fn parse_value(key: &str, value: &str) -> Result<Value, String> {
if matches!(key, "download.concurrency" | "download.retry_limit") {
let number: u32 = value
.trim()
.parse()
.map_err(|_| format!("{key} 需要整数:{value}"))?;
return Ok(Value::from(number));
}
if matches!(key, "java.default_memory_mb" | "agent.mcp.port") {
let number: u32 = value
.trim()
.parse()
.map_err(|_| format!("{key} 需要整数:{value}"))?;
return Ok(Value::from(number));
}
if matches!(
key,
"java.auto_install"
| "ai.enabled"
| "ai.yolo"
| "agent.mcp.enabled"
| "agent.mcp.allow_write"
) {
let flag = match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "on" | "yes" => true,
"0" | "false" | "off" | "no" => false,
_ => return Err(format!("{key} 需要布尔值:{value}")),
};
return Ok(Value::Bool(flag));
}
Ok(Value::String(value.to_string()))
}
/// 清掉 24h 地区缓存(下次建引擎重新判定)。
fn detect_region(env: &HostEnv) -> Result<(), String> {
let path = env.layout.cache().join("region.json");
if path.is_file() {
std::fs::remove_file(&path).map_err(|error| format!("清地区缓存失败:{error}"))?;
}
Ok(())
}
/// 设置页「重置发行版优先级」用(清空 enum_list)。
///
/// # Errors
///
/// 落盘失败时返回用户可读文案。
pub fn clear_vendor_priority(env: &HostEnv) -> Result<(), String> {
let mut store = env.store();
store
.set("java.vendorPriority", Value::Array(Vec::new()))
.and_then(|()| store.save())
.map_err(|error| format!("清空发行版优先级失败:{error}"))?;
let _ = JdkVendor::ALL;
Ok(())
}
#[cfg(test)]
#[path = "settings_tests.rs"]
mod tests;
+77
View File
@@ -0,0 +1,77 @@
//! host/settings.rs 的单测(拆文件守 400 行上限)。
use super::*;
fn env_in(dir: &std::path::Path) -> HostEnv {
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config");
HostEnv::at_root(dir)
}
#[test]
fn groups_cover_the_six_required_areas() {
let dir = std::env::temp_dir().join(format!("ull-host-settings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = env_in(&dir);
let groups = groups(&env);
let ids: Vec<&str> = groups.iter().map(|group| group.id.as_str()).collect();
assert_eq!(ids, vec!["download", "java", "data", "ai", "mcp"]);
let keys: Vec<&str> = groups
.iter()
.flat_map(|group| group.rows.iter())
.map(|row| row.key.as_str())
.collect();
for key in [
"download.region",
"download.mirror_preference",
"java.default_memory_mb",
"core.data_mode",
"agent.mcp.enabled",
"agent.mcp.port",
"ai.base_url",
] {
assert!(keys.contains(&key), "缺少 {key}");
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn set_parses_types_and_persists() {
let dir = std::env::temp_dir().join(format!("ull-host-settings-w-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = env_in(&dir);
set(&env, "download.concurrency", "16").expect("写并发");
set(&env, "agent.mcp.enabled", "true").expect("写开关");
set(&env, "agent.mcp.port", "48000").expect("写端口");
set(&env, "download.region", "cn").expect("写地区");
let groups = groups(&env);
let row = |key: &str| {
groups
.iter()
.flat_map(|g| g.rows.iter())
.find(|r| r.key == key)
.cloned()
};
assert_eq!(row("download.concurrency").unwrap().selected, 2);
assert!(row("agent.mcp.enabled").unwrap().flag);
assert_eq!(row("agent.mcp.port").unwrap().value_text, "48000");
assert_eq!(row("download.region").unwrap().selected, 1);
assert!(set(&env, "download.concurrency", "abc").is_err());
assert!(set(&env, "agent.mcp.enabled", "maybe").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn detect_region_action_removes_cache() {
let dir = std::env::temp_dir().join(format!("ull-host-settings-r-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = env_in(&dir);
let path = env.layout.cache().join("region.json");
std::fs::create_dir_all(env.layout.cache()).expect("cache");
std::fs::write(&path, "{}").expect("写缓存");
set(&env, "download.action.detect_region", "").expect("检测");
assert!(!path.exists());
std::fs::remove_dir_all(&dir).ok();
}
+173
View File
@@ -0,0 +1,173 @@
//! 版本清单(下载页):远端 manifest(12h 缓存)+ 内嵌 unlisted 清单 → 分类好的行。
//!
//! 分类口径抄 analyse/03 §2.4:官方 `type` 决定 release/snapshot/远古;远古再按 id
//! 前缀拆 classic(`rd-*`)/ old_alpha(`a*`)/ old_beta(`b*`),其余归 unlisted 汇总。
//! 拉取失败但有旧缓存时用旧缓存(离线可用),全无才报错。
use std::collections::BTreeSet;
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::game::install::RemoteSource;
use unlucky_core::game::unlisted;
use unlucky_core::game::version::{ReleaseType, RemoteVersion};
use crate::dto::{AncientKind, VersionKind, VersionSummary};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
/// 版本 id / 官方类型 → 下载页的四种归类。
pub fn kind_of(id: &str, release_type: ReleaseType) -> VersionKind {
match release_type {
ReleaseType::Release => VersionKind::Release,
ReleaseType::Snapshot | ReleaseType::Pending | ReleaseType::Unobfuscated => {
VersionKind::Snapshot
}
ReleaseType::OldAlpha | ReleaseType::OldBeta | ReleaseType::Modified => {
VersionKind::Ancient(ancient_kind(id))
}
ReleaseType::Unknown => {
if id.starts_with("rd-") || id.starts_with("a") || id.starts_with("b") {
VersionKind::Ancient(ancient_kind(id))
} else {
VersionKind::Release
}
}
}
}
/// 远古版子类(`rd-*` → classic;`a1.*` → old_alpha;`b1.*` → old_beta;其余 unlisted)。
pub fn ancient_kind(id: &str) -> AncientKind {
if id.starts_with("rd-") || id.starts_with("inf-") {
AncientKind::Classic
} else if id.starts_with("a1.") || id.starts_with("a0.") || id.starts_with('a') {
AncientKind::OldAlpha
} else if id.starts_with("b1.") || id.starts_with('b') {
AncientKind::OldBeta
} else {
AncientKind::Unlisted
}
}
/// 发布日期取 `releaseTime` 的日期部分(缺失时退回 `time`,再退回空串)。
fn released_of(version: &RemoteVersion) -> String {
version
.release_time
.as_deref()
.or(version.time.as_deref())
.map(|text| text.get(..10).unwrap_or(text).to_string())
.unwrap_or_default()
}
/// 拉清单并翻译成 DTO(安装状态按盘上版本判定)。
fn fetch(env: &HostEnv) -> Result<Vec<VersionSummary>, String> {
let engine = super::engine::engine(env);
let meta_cache = env.layout.meta_cache();
let now = now_ms();
let (mut list, _freshness) = block_on(async {
RemoteSource::new(&engine, &env.game, &meta_cache, now)
.manifest(false)
.await
})
.map_err(|error| format!("拉取版本清单失败:{error}"))?;
// 内嵌 unlisted 清单先合(同 id 先插入者胜),保证远古版显式可达(09 §G2)。
unlisted::merge_into(&mut list);
list.sort_newest_first();
let installed: BTreeSet<String> = env
.game
.installed_versions()
.unwrap_or_default()
.into_iter()
.collect();
Ok(list
.versions
.iter()
.map(|version| VersionSummary {
id: version.id.clone(),
kind: kind_of(&version.id, version.version_type),
released: released_of(version),
installed: installed.contains(&version.id),
})
.collect())
}
/// 后台拉清单(网络在后台线程里,UI 不卡)。
pub fn spawn_list(env: HostEnv, tx: JobSender) {
spawn("versions", move || {
let result = fetch(&env);
let _ = tx.send(HostTask::Versions(result));
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinds_follow_official_types() {
assert_eq!(
kind_of("1.21.9", ReleaseType::Release),
VersionKind::Release
);
assert_eq!(
kind_of("1.21.9-pre3", ReleaseType::Snapshot),
VersionKind::Snapshot
);
assert_eq!(
kind_of("rd-132211", ReleaseType::OldAlpha),
VersionKind::Ancient(AncientKind::Classic)
);
assert_eq!(
kind_of("a1.1.1", ReleaseType::OldAlpha),
VersionKind::Ancient(AncientKind::OldAlpha)
);
assert_eq!(
kind_of("b1.7.3", ReleaseType::OldBeta),
VersionKind::Ancient(AncientKind::OldBeta)
);
assert_eq!(
kind_of("x-modified", ReleaseType::Modified),
VersionKind::Ancient(AncientKind::Unlisted)
);
}
#[test]
fn released_uses_date_part() {
let version = RemoteVersion {
id: "1.20.4".to_string(),
version_type: ReleaseType::Release,
url: String::new(),
time: None,
release_time: Some("2023-12-07T12:00:00+00:00".to_string()),
sha1: None,
size: None,
};
assert_eq!(released_of(&version), "2023-12-07");
let empty = RemoteVersion {
id: "x".to_string(),
version_type: ReleaseType::Unknown,
url: String::new(),
time: None,
release_time: None,
sha1: None,
size: None,
};
assert_eq!(released_of(&empty), "");
}
#[test]
fn unlisted_merge_keeps_ancient_versions_reachable() {
// core 的内嵌清单里一定有远古版(`rd-*` / `a1.*` / `b1.*`)
let mut list = unlucky_core::game::version::VersionList::default();
let added = unlisted::merge_into(&mut list);
assert!(added > 0, "内嵌 unlisted 清单不该为空");
assert!(
list.versions.iter().any(|v| v.id.starts_with("a1.")
|| v.id.starts_with("b1.")
|| v.id.starts_with("rd-")),
"远古版必须显式可达"
);
}
}
+10 -20
View File
@@ -32,36 +32,40 @@
#![allow(missing_docs, dead_code)]
mod appearance_bind;
mod backend;
mod bg_image;
mod callbacks;
mod callbacks_pages;
mod conv;
mod dialogs;
mod dto;
mod host;
mod mock;
mod mock_data;
mod page_sync;
mod png;
mod prefs;
mod router;
mod screenshot;
mod settings_bridge;
mod state;
mod theme_runtime;
mod toast;
mod ui_bind;
mod wizard;
slint::include_modules!();
fn main() -> Result<(), slint::PlatformError> {
apply_renderer_flag();
let app = ui_bind::AppUi::new(MainWindow::new()?);
let app = ui_bind::AppUi::with_options(MainWindow::new()?, screenshot::app_options());
// 截图验收通道(M41 视觉验收;无交互桌面/无 GPU 环境下的唯一可靠取图方式:
// 不截屏幕,而是让 Slint 把当前场景渲染进内存像素缓冲再自己编码 PNG)。
if let Some(path) = screenshot_path() {
if std::env::args().any(|arg| arg == "--screenshot-settings") {
app.open_settings_appearance();
}
app.render_screenshot(&path)?;
// P3 追加页面选择与中间态(安装进度 / 启动事件流),见 `crate::screenshot`。
if let Some(options) = screenshot::shot_options() {
screenshot::capture(&app, &options)?;
return Ok(());
}
@@ -78,17 +82,3 @@ fn apply_renderer_flag() {
std::env::set_var("SLINT_BACKEND", "winit-software");
}
}
/// `--screenshot <path>` / `--screenshot=<path>`:渲染一帧并写 PNG 后退出。
fn screenshot_path() -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if let Some(value) = arg.strip_prefix("--screenshot=") {
return Some(value.to_owned());
}
if arg == "--screenshot" {
return args.next();
}
}
None
}
+3 -3
View File
@@ -260,9 +260,9 @@ impl Backend for MockBackend {
crate::mock_data::mock_diagnosis(id),
)));
}
AppCommand::RefreshInstances
| AppCommand::RefreshLogSessions
| AppCommand::StopAgent => {}
AppCommand::RefreshAll | AppCommand::RefreshLogSessions | AppCommand::StopAgent => {}
// 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟
_ => {}
}
}
+12
View File
@@ -22,6 +22,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
last_played: Some("2026-10-29T19:22:00Z".to_string()),
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
},
InstanceSummary {
id: "vanilla-latest".to_string(),
@@ -34,6 +37,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
last_played: Some("2026-10-28T21:03:00Z".to_string()),
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.21.9".to_string(),
version_present: true,
},
InstanceSummary {
id: "ancient-1.7.10".to_string(),
@@ -46,6 +52,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "shared".to_string(),
version_id: "1.7.10".to_string(),
version_present: false,
},
InstanceSummary {
id: "modded-dev".to_string(),
@@ -60,6 +69,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
reason: "exit0 静默失败".to_string(),
},
icon: None,
isolation: "isolated".to_string(),
version_id: "1.21.1-neoforge-21.1.72".to_string(),
version_present: true,
},
]
}
+36
View File
@@ -0,0 +1,36 @@
//! `PageState` global 的状态灌入(从 `ui_bind` 拆出,守 400 行上限)。
//!
//! 一个函数只做「AppState → Slint global 属性」的搬运,同步发生在每一拍末尾
//! (`ui_bind::Inner::sync`)。分文件而不是砍内容:这些字段每一行都对应一个页面元素。
use crate::conv;
use crate::state::AppState;
use crate::ui_bind::model;
use crate::{MainWindow, PageState};
use slint::ComponentHandle;
/// 把页面状态灌进 `PageState`。
pub(crate) fn apply(ui: &MainWindow, state: &AppState) {
// 导航底部账户 + PageState 全量页面状态(账户/Java/安装/设置/通知/向导)
ui.set_account_name(conv::active_account_name(state).into());
let page = ui.global::<PageState<'_>>();
page.set_accounts(model(conv::accounts(state)));
page.set_active_account(conv::active_account_name(state).into());
page.set_device(conv::device(state));
page.set_java_vendors(model(conv::java_vendors(state)));
page.set_java_chain(model(conv::java_chain(state)));
page.set_java_runtimes(model(conv::java_runtimes(state)));
page.set_java_order(state.java.order_text.clone().into());
page.set_java_region(state.java.region.clone().into());
page.set_java_priority_source(state.java.priority_source.clone().into());
page.set_java_message(state.java.message.clone().into());
page.set_java_busy(state.java.busy);
let major = state.java.majors().last().copied().unwrap_or(17);
page.set_java_target_major(i32::try_from(major).unwrap_or(17));
page.set_install(conv::install(state));
page.set_setting_groups(model(conv::setting_groups(state)));
page.set_setting_tab(state.settings_tab.clone().into());
page.set_notices(model(conv::notices(state)));
page.set_wizard(conv::wizard(state));
page.set_wizard_versions(model(conv::wizard_versions(state)));
}
+177
View File
@@ -0,0 +1,177 @@
//! 截图验收通道的参数解析与流程(`--screenshot*` / `--data-dir` / `--mock`)。
//!
//! 为什么用进程内渲染而不是截屏:验收机没有交互桌面(`CopyFromScreen` 直接失败),
//! 而 Slint 的软件渲染器能把场景渲进内存像素缓冲(`Window::take_snapshot`),
//! 结果与屏幕上逐像素一致且不需要 GPU(M41 落地的通道,这里只加页面/中间态参数)。
//!
//! 中间态(安装进度 / 启动事件流)走真实后端:`--screenshot-install <版本>` 会真的发起
//! 一次安装并停若干毫秒后取图,`--screenshot-launch <实例>` 同理。
use std::path::PathBuf;
use crate::backend::AppOptions;
use crate::dto::AppCommand;
use crate::ui_bind::AppUi;
/// 一次截图请求。
#[derive(Debug, Clone, Default)]
pub struct ShotOptions {
/// 输出 PNG 路径
pub path: String,
/// 页面名(instances / download / java / logs / settings / account / appearance / agent)
pub page: Option<String>,
/// 打开新建实例向导
pub wizard: bool,
/// 发起一次真实安装并停 `dwell_ms` 后取图(中间态)
pub install: Option<String>,
/// 安装走哪个加载器(`--screenshot-loader forge`;缺省 = 原版)
pub loader: Option<String>,
/// 发起一次真实启动并停 `dwell_ms` 后取图(中间态)
pub launch: Option<String>,
/// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片)
pub session: Option<String>,
/// 中间态停留毫秒(默认 2500)
pub dwell_ms: u64,
}
impl ShotOptions {
/// 中间态默认停留时长。
pub const DEFAULT_DWELL_MS: u64 = 2_500;
}
/// 从命令行读启动选项(数据根 / mock)。
pub fn app_options() -> AppOptions {
AppOptions {
data_root: value_of("--data-dir").map(PathBuf::from),
mock: flag("--mock"),
}
}
/// 从命令行读截图请求;没有 `--screenshot` 时返回 `None`。
pub fn shot_options() -> Option<ShotOptions> {
let path = value_of("--screenshot")?;
let page = value_of("--screenshot-page");
let mut options = ShotOptions {
path,
page: if flag("--screenshot-settings") {
Some("appearance".to_string())
} else {
page
},
wizard: flag("--screenshot-wizard"),
install: value_of("--screenshot-install"),
loader: value_of("--screenshot-loader"),
launch: value_of("--screenshot-launch"),
session: value_of("--screenshot-session"),
dwell_ms: value_of("--screenshot-dwell")
.and_then(|value| value.parse().ok())
.unwrap_or(ShotOptions::DEFAULT_DWELL_MS),
};
if options.dwell_ms < 500 {
options.dwell_ms = 500;
}
options.page = options.page.filter(|page| !page.trim().is_empty());
Some(options)
}
/// 执行截图流程:切页 → (可选)中间态 → 渲染 PNG。
///
/// # Errors
/// 窗口显示/快照/写文件失败时返回错误。
pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::PlatformError> {
if let Some(page) = &options.page {
app.open_page(page);
// 先给后端一次全量刷新:版本清单要在后台线程里拉(12h 缓存),
// 下载页/Java 页/向导需要它才有「有数据态」。
let warmup = if matches!(page.as_str(), "download" | "java") {
4_000
} else {
1_200
};
app.dispatch_and_run(AppCommand::RefreshAll, warmup);
}
if options.wizard {
app.open_wizard();
}
if let Some(version) = &options.install {
app.dispatch_and_run(
install_command(version, options.loader.as_deref()),
options.dwell_ms,
);
}
if let Some(session) = &options.session {
app.open_session(session);
app.dispatch_and_run(AppCommand::RefreshLogSessions, options.dwell_ms.min(1_000));
}
if let Some(instance) = &options.launch {
app.dispatch_and_run(
AppCommand::LaunchInstance(instance.clone()),
options.dwell_ms,
);
}
app.render_screenshot(&options.path)?;
// 启动中间态截完就把进程收掉:截图进程退出后子进程会变孤儿,
// 所以在取图之后显式 stop(kill_tree),不留给系统。
if let Some(instance) = &options.launch {
app.dispatch_and_run(AppCommand::StopInstance(instance.clone()), 1_500);
}
Ok(())
}
/// 截图通道的安装指令:缺省原版,给了 `--screenshot-loader` 就走向导同一条路
/// (`CreateInstance`),因此 Forge/NeoForge 的中间态也能取图。
fn install_command(version: &str, loader: Option<&str>) -> AppCommand {
match loader.map(crate::dto::LoaderKind::parse) {
Some(Some(kind)) => AppCommand::CreateInstance(crate::dto::CreateRequest {
version: version.to_string(),
loader: kind,
loader_version: String::new(),
name: version.to_string(),
}),
_ => AppCommand::InstallVersion(version.to_string()),
}
}
/// `--flag` 是否存在。
pub fn flag(name: &str) -> bool {
std::env::args().skip(1).any(|arg| arg == name)
}
/// `--key value` / `--key=value` 的取值。
pub fn value_of(name: &str) -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if let Some(value) = arg.strip_prefix(&format!("{name}=")) {
return Some(value.to_string());
}
if arg == name {
return args.next();
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_dwell_is_a_visible_mid_state() {
const DEFAULT: u64 = ShotOptions::DEFAULT_DWELL_MS;
let dwell = DEFAULT;
assert!(dwell >= 1_000, "中间态至少要停 1 秒才看得见进度");
}
#[test]
fn options_without_screenshot_are_none() {
// 测试进程没有 --screenshot 参数
assert!(
shot_options().is_none() || std::env::args().any(|arg| arg.contains("--screenshot"))
);
}
#[test]
fn value_of_ignores_absent_flags() {
assert!(value_of("--definitely-not-a-real-flag").is_none());
}
}
+95 -5
View File
@@ -5,12 +5,14 @@
use crate::dialogs::DialogQueue;
use crate::dto::{
AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DiagnosisReport, InstanceRuntime,
InstanceSummary, LogLine, LogSession, Progress, ToolCallView, VersionSummary,
AccountView, AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DeviceLoginView,
DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine,
LogSession, NoticeView, Progress, SettingGroupView, ToolCallView, VersionSummary,
};
use crate::prefs::UiPrefs;
use crate::router::{AiPanelMode, Route, Router};
use crate::toast::ToastCenter;
use crate::toast::{ToastCenter, ToastKind};
use crate::wizard::WizardState;
/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -75,6 +77,9 @@ pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
.collect()
}
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
pub const MAX_LIVE_LINES: usize = 2_000;
/// 应用全局状态。
#[derive(Debug)]
pub struct AppState {
@@ -106,6 +111,24 @@ pub struct AppState {
pub installed_only: bool,
/// 下载页版本号筛选词
pub download_query: String,
/// 账户列表(三轨统一视图;凭据不在其中)
pub accounts: Vec<AccountView>,
/// 微软登录状态
pub device_login: DeviceLoginView,
/// Java 页快照(发行版 / 回退链 / 运行时)
pub java: JavaPageView,
/// 安装进度(None = 没有安装任务)
pub install: Option<InstallProgressView>,
/// 设置分组(设置页右栏)
pub settings_groups: Vec<SettingGroupView>,
/// 当前选中的设置分组(download / java / data / ai / mcp)
pub settings_tab: String,
/// 横幅通知
pub notices: Vec<NoticeView>,
/// 新建实例向导
pub wizard: WizardState,
/// 正在实时尾随的会话
pub live_session: Option<String>,
}
impl Default for AppState {
@@ -132,6 +155,15 @@ impl AppState {
instance_query: String::new(),
installed_only: false,
download_query: String::new(),
accounts: Vec::new(),
device_login: DeviceLoginView::new(),
java: JavaPageView::new(),
install: None,
settings_groups: Vec::new(),
settings_tab: "download".to_string(),
notices: Vec::new(),
wizard: WizardState::new(),
live_session: None,
}
}
@@ -186,12 +218,51 @@ impl AppState {
}
CoreEvent::VersionListChanged(versions) => self.versions = versions,
CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions,
CoreEvent::LogLinesChanged(lines) => self.log_lines = lines,
CoreEvent::LogLinesChanged(lines) => {
self.log_lines = lines;
self.live_session = None;
}
CoreEvent::LogAppended(batch) => self.append_live_lines(&batch),
CoreEvent::LaunchFinished(finished) => {
self.live_session = None;
self.selected_session = Some(finished.session.clone());
let kind = if finished.outcome == crate::dto::LogOutcome::Ok {
ToastKind::Success
} else {
ToastKind::Error
};
self.toasts.push(kind, finished.summary);
}
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
CoreEvent::DeviceLoginChanged(view) => {
if view.phase == crate::dto::DeviceLoginPhase::Succeeded {
self.toasts
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
}
if view.phase == crate::dto::DeviceLoginPhase::Failed {
self.toasts.push(ToastKind::Error, view.message.clone());
}
self.device_login = view;
}
CoreEvent::JavaChanged(page) => self.java = page,
CoreEvent::InstallProgress(progress) => match progress {
Some(progress) => {
let terminal = progress.stage.is_terminal();
self.install = Some(progress);
if terminal {
self.wizard.open = false;
self.wizard.submitting = false;
}
}
None => self.install = None,
},
CoreEvent::SettingsChanged(groups) => self.settings_groups = groups,
CoreEvent::NoticesChanged(notices) => self.notices = notices,
}
}
/// agent 事件。
/// agent 事件(AI 会话的流式消息与工具调用)。
pub fn apply_agent(&mut self, event: AgentEvent) {
match event {
AgentEvent::MessageAppended(message) => {
@@ -242,6 +313,25 @@ impl AppState {
}
}
/// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。
fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) {
if self
.live_session
.as_deref()
.is_some_and(|session| session != batch.session)
{
// 换了一个会话在跑:清掉上一场的行,避免两场日志混在一起
self.log_lines.clear();
}
self.live_session = batch.running.then(|| batch.session.clone());
self.selected_session = Some(batch.session.clone());
self.log_lines.extend(batch.lines.iter().cloned());
if self.log_lines.len() > MAX_LIVE_LINES {
let drop = self.log_lines.len() - MAX_LIVE_LINES;
self.log_lines.drain(..drop);
}
}
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
pub fn tiles(&self) -> Vec<TileCell<'_>> {
let query = self.instance_query.trim().to_lowercase();
+3
View File
@@ -23,6 +23,9 @@ fn instance(id: &str, name: &str) -> InstanceSummary {
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
}
}
+4 -2
View File
@@ -3,11 +3,13 @@
//! 与模态对话框队列([`crate::dialogs`])分开:Toast 不阻塞、不排序、可叠加,
//! 只做「先进先出 + 上限裁剪 + TTL 到期」。UI 侧由 Rust 定时器每帧调用 [`ToastCenter::expire`]。
use serde::{Deserialize, Serialize};
/// Toast 标识。
pub type ToastId = u64;
/// Toast 语义色。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToastKind {
/// 一般信息
Info,
@@ -20,7 +22,7 @@ pub enum ToastKind {
}
/// 一条 Toast;`remaining_ms` 由 [`ToastCenter::expire`] 递减。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Toast {
/// 队列内唯一 id(由 [`ToastCenter::push_request`] 重新编号)
pub id: ToastId,
+87 -88
View File
@@ -14,14 +14,14 @@ use std::time::Duration;
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::backend::{build_backend, config_path, AppOptions};
use crate::bg_image::BackgroundLayers;
use crate::conv;
use crate::dto::{AppCommand, Backend, InstanceId};
use crate::mock::MockBackend;
use crate::dto::{AppCommand, Backend};
use crate::prefs::BgFill;
use crate::settings_bridge::SettingsBridge;
use crate::state::AppState;
use crate::{MainWindow, SettingSection};
use crate::{MainWindow, PageState, SettingSection};
/// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。
const TICK_MS: u64 = 33;
@@ -67,19 +67,28 @@ pub(crate) struct BackgroundKey {
}
impl AppUi {
/// 建状态 + 假后端、读配置、接全部回调、灌初始事件并做第一次同步。
/// 建状态 + 真实后端、读配置、接全部回调、灌初始事件并做第一次同步。
pub fn new(ui: MainWindow) -> Self {
Self::with_options(ui, AppOptions::default())
}
/// 带启动选项装配(数据根覆盖 / 强制 mock)。
pub fn with_options(ui: MainWindow, options: AppOptions) -> Self {
// 先读一次落盘配置:主题三态 + 外观四件必须在首帧前就位(否则会闪一下出厂外观)
let bridge = SettingsBridge::open();
let bridge = match &options.data_root {
Some(root) => SettingsBridge::with_path(config_path(root)),
None => SettingsBridge::open(),
};
let (appearance, theme) = bridge.load();
let mut state = AppState::new();
state.prefs.appearance = appearance;
state.prefs.theme = theme;
let backend = build_backend(&options);
let inner = Rc::new(Inner {
ui,
state: RefCell::new(state),
backend: RefCell::new(Box::new(MockBackend::new())),
backend: RefCell::new(backend),
bridge: RefCell::new(bridge),
background: RefCell::new(BackgroundLayers::default()),
background_key: RefCell::new(None),
@@ -88,6 +97,7 @@ impl AppUi {
});
crate::callbacks::wire(Rc::downgrade(&inner));
crate::callbacks_pages::wire_pages(Rc::downgrade(&inner));
inner.drain_events();
inner.align_system_theme();
inner.apply_theme();
@@ -134,6 +144,71 @@ impl AppUi {
slint::platform::update_timers_and_animations();
}
/// 直接跳到一个页面(截图验收用:每页一张)。
pub fn open_page(&self, page: &str) {
let route = match page {
"download" | "java" => crate::router::Route::Download,
"logs" => crate::router::Route::Logs,
"settings" | "account" | "appearance" => crate::router::Route::Settings,
"agent" => crate::router::Route::Agent,
_ => crate::router::Route::Instances,
};
{
let mut state = self.inner.state.borrow_mut();
state.navigate(route);
match page {
"account" => state.settings_tab = "account".to_string(),
"java" => state.settings_tab = "java".to_string(),
"settings" => state.settings_tab = "download".to_string(),
_ => {}
}
let download_tab = i32::from(page == "java");
self.inner
.ui
.global::<PageState<'_>>()
.set_download_tab(download_tab);
}
match page {
"account" => self.inner.ui.set_settings_section(SettingSection::Account),
"java" => self.inner.ui.set_settings_section(SettingSection::Java),
"appearance" => self
.inner
.ui
.set_settings_section(SettingSection::Appearance),
"settings" => self.inner.ui.set_settings_section(SettingSection::Download),
_ => {}
}
self.inner.sync();
slint::platform::update_timers_and_animations();
}
/// 打开新建实例向导(截图验收用)。
pub fn open_wizard(&self) {
self.inner.state.borrow_mut().wizard = crate::wizard::WizardState::open();
self.inner.sync();
slint::platform::update_timers_and_animations();
}
/// 打开一个日志会话(截图验收:日志行 + 诊断卡片 + 头部标题都要有值)。
pub fn open_session(&self, id: &str) {
self.inner.state.borrow_mut().selected_session = Some(id.to_string());
self.inner
.dispatch(AppCommand::OpenLogSession(id.to_string()));
self.inner.sync();
slint::platform::update_timers_and_animations();
}
/// 派发一条指令并跑若干毫秒(截图中间态:安装进度 / 启动事件流)。
pub fn dispatch_and_run(&self, command: AppCommand, millis: u64) {
self.inner.dispatch(command);
let steps = (millis / TICK_MS).max(1);
for _ in 0..steps {
self.inner.tick();
slint::platform::update_timers_and_animations();
std::thread::sleep(Duration::from_millis(TICK_MS));
}
}
/// 渲染当前场景并存成 PNG(`--screenshot` 通道;见 `crate::png`)。
///
/// 为什么要这条通道:验收环境可能没有交互桌面(`CopyFromScreen` 直接失败),
@@ -150,8 +225,8 @@ impl AppUi {
.set_size(slint::WindowSize::Logical(slint::LogicalSize::new(
1180.0, 760.0,
)));
// 先走几拍:把 mock 事件(实例/版本/日志)与 33ms 节拍的效果灌进去
for _ in 0..4 {
// 先走几拍:把后端事件(实例/版本/日志/账户/设置)与 33ms 节拍的效果灌进去
for _ in 0..8 {
self.tick_once();
std::thread::sleep(Duration::from_millis(TICK_MS));
}
@@ -245,89 +320,13 @@ impl Inner {
ui.set_dialog(dialog);
ui.set_dialog_open(open);
ui.set_dialog_queued(queued);
// PageState(账户/Java/安装/设置/通知/向导)在独立模块里灌,守 400 行
crate::page_sync::apply(ui, &state);
}
}
/// Slint 列表模型(VecModel → ModelRc)。
pub(crate) fn model<T: Clone + 'static>(items: Vec<T>) -> ModelRc<T> {
ModelRc::new(VecModel::from(items))
}
/// 停止实例的指令构造(阶段 C 的磁贴「停止」键用;P0 只保留 API)。
#[allow(dead_code)]
pub fn stop_instance(id: &InstanceId) -> AppCommand {
AppCommand::StopInstance(id.clone())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::send_agent_message;
use crate::dto::{Backend, ChatRole};
use crate::mock::MockBackend;
use crate::state::AppState;
/// 集成测试(callbacks→state→mock 全链路):发一次提问后
/// ①用户消息恰出现一次(无本地回显重复)②所有 assistant 气泡收敛(无幽灵气泡)。
///
/// 分层单测漏掉这条正是 review P1-1 的根因,故这里用真实 mock 后端跑完整事件流。
#[test]
fn send_path_has_no_duplicate_user_message_and_no_ghost_stream() {
let mut state = AppState::new();
let mut backend = MockBackend::new();
// 先灌入种子事件(实例/版本/日志快照)
for event in backend.drain_events() {
state.apply(event);
}
send_agent_message(&mut state, &mut backend, "装哪个版本");
fn user_count(state: &AppState) -> usize {
state
.ai
.messages
.iter()
.filter(|message| message.role == ChatRole::User)
.count()
}
assert_eq!(
user_count(&state),
1,
"用户消息必须恰好一条(本地回显会造成两条)"
);
assert!(state.ai.streaming);
// 推进到流式收敛(mock 300ms/片 + 工具调用 + 收尾消息)
for _ in 0..12 {
backend.tick(Duration::from_millis(400));
for event in backend.drain_events() {
state.apply(event);
}
}
assert_eq!(user_count(&state), 1, "事件回流后用户消息仍应只有一条");
assert!(
state.ai.messages.iter().all(|message| !message.streaming),
"所有气泡必须收敛(幽灵气泡会永久 streaming)"
);
assert!(!state.ai.streaming);
assert!(
state
.ai
.messages
.iter()
.any(|message| message.role == ChatRole::Assistant && !message.text.is_empty()),
"增量应累积到 assistant 正文"
);
assert_eq!(
state
.ai
.messages
.iter()
.filter(|message| message.role == ChatRole::Assistant)
.count(),
2,
"一轮对话 = 流式回复 + 收尾回复"
);
}
}
+275
View File
@@ -0,0 +1,275 @@
//! 新建实例向导(09 §G5)的纯状态机:版本 → 加载器 → 实例名 → 确认 → 进度。
//!
//! 放纯 Rust 而不是 Slint 里:步骤推进、名字合法性、加载器约束都能单测,
//! Slint 只负责画。提交时由 host 转成 [`crate::dto::CreateRequest`] 走安装管线。
use crate::dto::LoaderKind;
/// 向导步骤。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WizardStep {
/// 选版本
#[default]
Version,
/// 选加载器
Loader,
/// 起名
Name,
/// 确认(显示将下载的项)
Confirm,
/// 进度(提交后由安装进度接管)
Progress,
}
impl WizardStep {
/// 步骤序号(0 起,用于「第 N 步」文案)。
pub fn index(self) -> usize {
match self {
Self::Version => 0,
Self::Loader => 1,
Self::Name => 2,
Self::Confirm => 3,
Self::Progress => 4,
}
}
/// 步骤标题。
pub fn title(self) -> &'static str {
match self {
Self::Version => "选择版本",
Self::Loader => "选择加载器",
Self::Name => "实例名",
Self::Confirm => "确认",
Self::Progress => "安装中",
}
}
/// 下一步(进度是终点)。
pub fn next(self) -> Self {
match self {
Self::Version | Self::Progress => {
if self == Self::Progress {
Self::Progress
} else {
Self::Loader
}
}
Self::Loader => Self::Name,
Self::Name => Self::Confirm,
Self::Confirm => Self::Progress,
}
}
/// 上一步(版本是起点)。
pub fn back(self) -> Self {
match self {
Self::Version | Self::Progress => Self::Version,
Self::Loader => Self::Version,
Self::Name => Self::Loader,
Self::Confirm => Self::Name,
}
}
}
/// 向导状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WizardState {
/// 是否打开(全屏模态)
pub open: bool,
/// 当前步骤
pub step: WizardStep,
/// 选中的版本 id
pub version: String,
/// 选中的加载器
pub loader: LoaderKind,
/// 加载器版本(空 = 最新)
pub loader_version: String,
/// 实例名草稿
pub name: String,
/// 校验错误(显示在向导内,不弹 Toast)
pub error: String,
/// 已提交(防重复点)
pub submitting: bool,
}
impl Default for WizardState {
fn default() -> Self {
Self::new()
}
}
impl WizardState {
/// 关闭态。
pub fn new() -> Self {
Self {
open: false,
step: WizardStep::Version,
version: String::new(),
loader: LoaderKind::Vanilla,
loader_version: String::new(),
name: String::new(),
error: String::new(),
submitting: false,
}
}
/// 打开向导并复位。
pub fn open() -> Self {
Self {
open: true,
..Self::new()
}
}
/// 选了版本:自动把实例名填成版本号(用户不改就用它)。
pub fn select_version(&mut self, version: &str) {
self.version = version.to_string();
if self.name.trim().is_empty() {
self.name = version.to_string();
}
self.error.clear();
}
/// 选加载器(原版会清掉加载器版本)。
pub fn select_loader(&mut self, loader: LoaderKind) {
self.loader = loader;
if loader == LoaderKind::Vanilla {
self.loader_version.clear();
}
self.error.clear();
}
/// 推进到下一步;校验不通过时留在原地并写 `error`。
pub fn advance(&mut self) -> bool {
match self.step {
WizardStep::Version => {
if self.version.trim().is_empty() {
self.error = "请先选择一个版本".to_string();
return false;
}
}
WizardStep::Name => {
if let Err(error) = validate_name(&self.name) {
self.error = error;
return false;
}
}
WizardStep::Loader | WizardStep::Confirm | WizardStep::Progress => {}
}
self.error.clear();
self.step = self.step.next();
true
}
/// 回退一步。
pub fn step_back(&mut self) {
self.error.clear();
self.step = self.step.back();
}
/// 提交前的最后校验(Confirm 步)。
pub fn ready_to_submit(&self) -> Result<(), String> {
if self.version.trim().is_empty() {
return Err("版本为空".to_string());
}
validate_name(&self.name)?;
Ok(())
}
/// 提交成功后进入进度步(关掉可编辑态)。
pub fn submitted(&mut self) {
self.submitting = true;
self.step = WizardStep::Progress;
self.error.clear();
}
}
/// 实例名合法性(不能空、不能含路径分隔符/控制字符、长度上限 64)。
pub fn validate_name(name: &str) -> Result<(), String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("实例名不能为空".to_string());
}
if trimmed.chars().count() > 64 {
return Err("实例名最多 64 个字符".to_string());
}
if trimmed.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
return Err("实例名不能包含 / \\ : * ? \" < > |".to_string());
}
if trimmed.chars().any(|ch| ch.is_control()) {
return Err("实例名不能包含控制字符".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn steps_walk_forward_and_back() {
assert_eq!(WizardStep::Version.next(), WizardStep::Loader);
assert_eq!(WizardStep::Loader.next(), WizardStep::Name);
assert_eq!(WizardStep::Name.next(), WizardStep::Confirm);
assert_eq!(WizardStep::Confirm.next(), WizardStep::Progress);
assert_eq!(WizardStep::Progress.next(), WizardStep::Progress);
assert_eq!(WizardStep::Confirm.back(), WizardStep::Name);
assert_eq!(WizardStep::Version.back(), WizardStep::Version);
assert_eq!(WizardStep::Progress.index(), 4);
}
#[test]
fn version_selection_fills_name_but_does_not_overwrite() {
let mut wizard = WizardState::open();
assert!(wizard.open);
wizard.select_version("1.20.4");
assert_eq!(wizard.name, "1.20.4");
wizard.name = "生存".to_string();
wizard.select_version("1.21");
assert_eq!(wizard.name, "生存", "用户改过的名字不该被覆盖");
}
#[test]
fn advance_requires_a_version_and_a_valid_name() {
let mut wizard = WizardState::open();
assert!(!wizard.advance(), "没有版本不能进下一步");
assert_eq!(wizard.error, "请先选择一个版本");
wizard.select_version("1.20.4");
assert!(wizard.advance());
assert_eq!(wizard.step, WizardStep::Loader);
assert!(wizard.advance());
assert_eq!(wizard.step, WizardStep::Name);
wizard.name = "a/b".to_string();
assert!(!wizard.advance(), "非法名字不能确认");
assert!(wizard.error.contains("不能包含"));
wizard.name = "生存".to_string();
assert!(wizard.advance());
assert_eq!(wizard.step, WizardStep::Confirm);
assert!(wizard.ready_to_submit().is_ok());
wizard.submitted();
assert_eq!(wizard.step, WizardStep::Progress);
assert!(wizard.submitting);
}
#[test]
fn loader_selection_clears_loader_version_for_vanilla() {
let mut wizard = WizardState::open();
wizard.loader_version = "0.16.5".to_string();
wizard.select_loader(LoaderKind::Fabric);
assert_eq!(wizard.loader_version, "0.16.5");
wizard.select_loader(LoaderKind::Vanilla);
assert_eq!(wizard.loader, LoaderKind::Vanilla);
assert!(wizard.loader_version.is_empty());
}
#[test]
fn name_validation_rejects_long_and_control_input() {
assert!(validate_name("生存").is_ok());
assert!(validate_name("").is_err());
assert!(validate_name(&"x".repeat(65)).is_err());
assert!(validate_name("a\u{7}b").is_err());
}
}
@@ -0,0 +1,73 @@
// 安装进度卡片(下载页底部 / 向导进度步共用):阶段 + 进度条 + 文件/字节/速度/剩余 + 取消。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiInstallProgress, InstallStage } from "../models.slint";
import { UiText, TextRole } from "../components/label.slint";
import { ProgressBar } from "../components/progress-bar.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
export component InstallPanel inherits Rectangle {
in property <UiInstallProgress> data;
callback cancel();
height: 96px;
visible: root.data.visible;
background: Theme.bg-surface;
border-width: 1px;
border-color: Theme.stroke;
VerticalLayout {
padding: Metrics.gap-3;
spacing: Metrics.gap-1;
HorizontalLayout {
spacing: Metrics.gap-2;
AppIcon {
icon-size: 16px;
kind: root.data.failed ? IconKind.error
: root.data.terminal ? IconKind.check
: IconKind.download;
tint: root.data.failed ? Theme.state-danger
: root.data.terminal ? Theme.state-success
: Theme.accent;
}
UiText {
role: TextRole.bodyStrong;
text: root.data.instance + " · " + root.data.loader;
horizontal-stretch: 1;
}
UiText {
role: TextRole.caption;
text: root.data.stage_label;
color: root.data.failed ? Theme.state-danger : Theme.fg-secondary;
}
if !root.data.terminal : MetroButton {
kind: ButtonKind.subtle;
text: "取消";
button-height: 24px;
clicked => { root.cancel(); }
}
}
ProgressBar {
ratio: root.data.ratio;
known: root.data.known;
}
HorizontalLayout {
spacing: Metrics.gap-3;
UiText {
role: TextRole.caption;
text: root.data.message;
horizontal-stretch: 1;
overflow: elide;
}
UiText { role: TextRole.caption; text: root.data.files_label; }
UiText { role: TextRole.caption; text: root.data.bytes_label; }
UiText { role: TextRole.caption; text: root.data.speed; color: Theme.accent; }
UiText { role: TextRole.caption; text: root.data.eta; }
}
}
}
+57
View File
@@ -0,0 +1,57 @@
// 横幅通知条:隔离档位切换提示(M30 ModeChangeWarning)/ 启动失败 / 启动告警。
// 与 Toast 的分工:Toast 是「刚发生了什么」,通知条是「现在需要你处理」(用户点了才收)。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiNotice, NoticeKind } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
// 横幅通知:隔离档位切换提示(M30 ModeChangeWarning)/ 启动失败 / 启动告警。
// 与 Toast 的分工:Toast 是「刚发生了什么」,通知条是「现在需要你处理」。
export component NoticeBar inherits Rectangle {
in property <[UiNotice]> notices;
visible: root.notices.length > 0;
height: root.notices.length > 0 ? 48px : 0px;
background: Theme.state-warn-soft;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
for notice in root.notices : Rectangle {
horizontal-stretch: 1;
background: transparent;
HorizontalLayout {
spacing: Metrics.gap-2;
AppIcon {
icon-size: 16px;
kind: notice.kind == NoticeKind.error ? IconKind.error : IconKind.warn;
tint: notice.kind == NoticeKind.error ? Theme.state-danger : Theme.state-warn;
}
VerticalLayout {
vertical-stretch: 1;
spacing: 0px;
UiText { role: TextRole.caption; text: notice.title; horizontal-stretch: 1; }
UiText {
role: TextRole.caption;
text: notice.body;
horizontal-stretch: 1;
overflow: elide;
color: Theme.fg-secondary;
}
}
MetroButton {
kind: ButtonKind.subtle;
text: "知道了";
button-height: 22px;
clicked => { PageState.notice-dismiss(notice.id); }
}
}
}
}
}
@@ -0,0 +1,34 @@
// 细进度条(0.0~1.0):安装进度 / 下载页顶部 / 向导进度共用。
// 未知总量(known = false)时画一条不确定态的弱色轨道,而不是假装 0%。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
export component ProgressBar inherits Rectangle {
in property <float> ratio: 0.0;
in property <bool> known: true;
in property <length> bar-height: 6px;
height: root.bar-height;
border-radius: root.bar-height / 2;
background: Theme.bg-hover;
clip: true;
Rectangle {
x: 0;
y: 0;
width: root.known ? max(0px, root.width * clamp(root.ratio, 0.0, 1.0)) : 0px;
height: root.height;
border-radius: root.bar-height / 2;
background: Theme.accent;
animate width { duration: 180ms; easing: ease-out; }
}
if !root.known : Rectangle {
x: 0;
y: 0;
width: root.width / 3;
height: root.height;
border-radius: root.bar-height / 2;
background: Theme.accent-soft;
}
}
+2
View File
@@ -9,6 +9,8 @@
// 亮/暗双套色板(LightPalette/DarkPalette)、主题三态解析、排版 ramp 与字体栈;M11 的占位
// NavItem/Tile 组件由 `components/nav.slint` 与 `components/tile.slint` 取代,占位内核不再保留。
// P3:页面状态 global 从根文件再导出一遍,Rust 侧才能拿到 `PageState` 类型。
export { PageState } from "page_state.slint";
import { AppShell } from "shell.slint";
import { Theme } from "theme/tokens.slint";
import { Metrics } from "theme/metrics.slint";
+146
View File
@@ -216,3 +216,149 @@ export struct UiToast {
kind: ToastKind,
text: string,
}
// ── P3 全量接线的视图模型(账户 / Java / 安装 / 设置分组 / 通知 / 向导)──────────
// 这些类型由 Rust 的 dto 单向转换(`conv/pages.rs`),Slint 只读不写业务真相。
// 全部经 `page_state.slint` 的 `PageState` global 下发(避免 main/shell 的属性转发膨胀)。
export enum AccountKind {
offline,
microsoft,
authlib,
}
export struct UiAccount {
id: string,
name: string,
kind: AccountKind,
kind_label: string,
profile_id: string,
login_name: string,
created: string,
active: bool,
}
/// 微软登录面板(用户码 / 授权链接都不是凭据,可直接展示)。
export struct UiDeviceCode {
visible: bool,
busy: bool,
user_code: string,
verification_uri: string,
qr_link: string,
expires: int,
message: string,
phase_label: string,
}
export enum JavaStepOutcome {
pending,
skip,
failed,
hit,
}
export struct UiJavaStep {
vendor: string,
outcome: JavaStepOutcome,
outcome_label: string,
detail: string,
}
export struct UiJavaVendor {
id: string,
display: string,
rank: int,
majors: string,
sources: string,
installed: bool,
}
export struct UiJavaRuntime {
binary: string,
version: string,
vendor: string,
origin: string,
}
export enum InstallStage {
planning,
downloading,
installing,
registering,
done,
failed,
cancelled,
}
export struct UiInstallProgress {
visible: bool,
version: string,
instance: string,
loader: string,
stage: InstallStage,
stage_label: string,
ratio: float,
known: bool,
files_label: string,
bytes_label: string,
speed: string,
eta: string,
message: string,
terminal: bool,
failed: bool,
}
export enum SettingControl {
toggle,
choice,
text,
readonly,
action,
}
export struct UiSettingRow {
key: string,
label: string,
description: string,
control: SettingControl,
flag: bool,
selected: int,
options: [string],
value_text: string,
action: string,
}
export struct UiSettingGroup {
id: string,
title: string,
rows: [UiSettingRow],
}
export enum NoticeKind {
info,
warn,
error,
}
export struct UiNotice {
id: string,
kind: NoticeKind,
title: string,
body: string,
}
export struct UiWizard {
open: bool,
step: int,
step_label: string,
step_index: int,
step_total: int,
version: string,
loader: int,
loader_label: string,
loader_version: string,
name: string,
error: string,
busy: bool,
can-next: bool,
}
+77
View File
@@ -0,0 +1,77 @@
// P3 全量接线的页面状态:一个导出的 global,Slint 任何页面都能直接读/回推。
//
// 为什么用 global 而不是继续经 main.slint → shell.slint 逐层转发:属性表已经 100+ 条,
// 再加五页的数据会把 main.slint 顶破 400 行上限(AGENTS §3)。global 在同一个 Slint
// 编译单元内是单例,Rust 侧用 `ui.global::<PageState>()` 读写,语义与 property 相同。
//
// 纪律:这里只放「页面要显示什么」与「用户点了什么」,业务真相在 Rust 的 state.rs。
import { UiAccount, UiDeviceCode, UiJavaVendor, UiJavaStep, UiJavaRuntime, UiInstallProgress, UiSettingGroup, UiNotice, UiWizard, UiVersion } from "models.slint";
export global PageState {
// ── 账户页 ────────────────────────────────────────────────
in-out property <[UiAccount]> accounts;
in property <string> active-account: "";
in property <UiDeviceCode> device;
/// 离线添加表单的输入(不落盘,点「添加」才提交)
in-out property <string> offline-name: "";
// ── Java(下载页 Java 分组 / 设置页 Java 分组)──────────────
in property <[UiJavaVendor]> java-vendors;
in property <[UiJavaStep]> java-chain;
in property <[UiJavaRuntime]> java-runtimes;
in property <string> java-order: "";
in property <string> java-region: "";
in property <string> java-priority-source: "";
in property <string> java-message: "";
in property <bool> java-busy: false;
in property <int> java-target-major: 17;
// ── 安装进度(下载页 + 向导共用)────────────────────────────
in property <UiInstallProgress> install;
/// 下载页右侧视图:0 = 版本列表,1 = Java 运行时(截图通道也从这里切)
in-out property <int> download-tab: 0;
// ── 设置分组(settings_bridge 同源 launcher.json)──────────
in property <[UiSettingGroup]> setting-groups;
in-out property <string> setting-tab: "download";
// ── 通知条 / 向导 ─────────────────────────────────────────
in property <[UiNotice]> notices;
in property <UiWizard> wizard;
in property <[UiVersion]> wizard-versions;
// ── 账户 ──────────────────────────────────────────────────
callback account-add-offline(string);
callback account-remove(string);
callback account-switch(string);
callback msa-begin();
callback msa-browser-begin();
callback msa-cancel();
// ── Java ──────────────────────────────────────────────────
callback java-install(string);
// ── 设置 ──────────────────────────────────────────────────
callback setting-changed(string, string);
callback setting-action(string);
callback settings-tab-changed(string);
// ── 通知 ──────────────────────────────────────────────────
callback notice-dismiss(string);
// ── 向导 ──────────────────────────────────────────────────
callback wizard-open();
callback wizard-close();
callback wizard-select-version(string);
callback wizard-select-loader(int);
callback wizard-name-changed(string);
callback wizard-next();
callback wizard-back();
callback wizard-confirm();
callback install-cancel();
// ── 日志页:崩溃日志导入 / 手动刷新 ──────────────────────────
in-out property <string> crash-path: "";
callback crash-import(string);
callback logs-refresh();
}
+204
View File
@@ -0,0 +1,204 @@
// 下载页 · Java 分组(G2 的第二个分类):四家发行版 + 回退链可视化 + 本地运行时。
//
// 回退链是「可观测性」核心:安装失败时用户要能看见 Temurin 跳过 → Oracle 失败 →
// Microsoft 命中的每一步原因(D23),而不是只看到一句「下载失败」。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiJavaVendor, UiJavaStep, UiJavaRuntime, JavaStepOutcome } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { Badge } from "../components/badge.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { ScrollView } from "std-widgets.slint";
component ChainStep inherits Rectangle {
in property <UiJavaStep> step;
height: 26px;
background: transparent;
HorizontalLayout {
spacing: Metrics.gap-1;
AppIcon {
icon-size: 12px;
kind: root.step.outcome == JavaStepOutcome.hit ? IconKind.check
: root.step.outcome == JavaStepOutcome.failed ? IconKind.error
: root.step.outcome == JavaStepOutcome.skip ? IconKind.warn
: IconKind.more;
tint: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success
: root.step.outcome == JavaStepOutcome.failed ? Theme.state-danger
: Theme.fg-secondary;
}
UiText {
role: TextRole.caption;
text: root.step.vendor;
width: 72px;
color: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success : Theme.fg-primary;
}
Badge {
text: root.step.outcome_label;
tint: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success
: root.step.outcome == JavaStepOutcome.failed ? Theme.state-danger
: Theme.fg-secondary;
}
UiText {
role: TextRole.caption;
text: root.step.detail;
horizontal-stretch: 1;
overflow: elide;
}
}
}
component VendorRow inherits Rectangle {
in property <UiJavaVendor> data;
callback install();
height: 56px;
background: vendor-touch.has-hover ? Theme.bg-hover : transparent;
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
UiText {
role: TextRole.bodyStrong;
text: root.data.rank + ". " + root.data.display;
width: 170px;
}
if root.data.installed : Badge { text: "已装"; tint: Theme.state-success; }
UiText {
role: TextRole.caption;
text: "Java " + root.data.majors;
width: 96px;
}
UiText {
role: TextRole.caption;
text: root.data.sources;
horizontal-stretch: 1;
overflow: elide;
}
MetroButton {
kind: ButtonKind.standard;
text: "安装此家";
button-height: 26px;
enabled: !PageState.java-busy;
clicked => { root.install(); }
}
}
vendor-touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.install(); }
}
}
export component JavaPanel inherits Rectangle {
background: Theme.bg-canvas;
VerticalLayout {
// 顶栏:地区 / 优先级来源 / 目标大版本 / 一键按回退链安装
Rectangle {
height: 56px;
background: Theme.bg-canvas;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-3;
UiText {
role: TextRole.subtitle;
text: "Java 运行时";
width: 110px;
}
UiText {
role: TextRole.caption;
text: "地区 " + PageState.java-region + " · 优先级 " + PageState.java-priority-source
+ " · 目标 Java " + PageState.java-target-major;
horizontal-stretch: 1;
overflow: elide;
}
MetroButton {
kind: ButtonKind.accent;
text: PageState.java-busy ? "安装中…" : "按回退链安装";
button-height: 28px;
enabled: !PageState.java-busy;
clicked => { PageState.java-install(""); }
}
}
}
UiText {
x: Metrics.gap-4;
role: TextRole.caption;
text: PageState.java-order;
horizontal-stretch: 1;
overflow: elide;
}
if PageState.java-message != "" : Rectangle {
height: 30px;
background: Theme.accent-soft;
UiText {
x: Metrics.gap-4;
role: TextRole.caption;
text: PageState.java-message;
overflow: elide;
}
}
Rectangle { height: 1px; background: Theme.stroke; }
ScrollView {
vertical-stretch: 1;
VerticalLayout {
padding-bottom: Metrics.gap-4;
// 回退链
UiText {
x: Metrics.gap-4;
role: TextRole.bodyStrong;
text: "回退链";
}
for step in PageState.java-chain : ChainStep { step: step; }
UiText {
x: Metrics.gap-4;
role: TextRole.bodyStrong;
text: "四家发行版";
}
for vendor in PageState.java-vendors : VendorRow {
data: vendor;
install => { PageState.java-install(vendor.id); }
}
UiText {
x: Metrics.gap-4;
role: TextRole.bodyStrong;
text: "本地已发现";
}
if PageState.java-runtimes.length == 0 : UiText {
x: Metrics.gap-4;
role: TextRole.caption;
text: "还没有探测到 Java:点上面的「按回退链安装」装一个托管 JDK。";
}
for runtime in PageState.java-runtimes : Rectangle {
height: 24px;
HorizontalLayout {
padding-left: Metrics.gap-4;
spacing: Metrics.gap-2;
UiText { role: TextRole.caption; text: "Java " + runtime.version; width: 120px; }
UiText { role: TextRole.caption; text: runtime.vendor; width: 96px; }
UiText { role: TextRole.caption; text: runtime.origin; width: 80px; }
UiText { role: TextRole.caption; text: runtime.binary; horizontal-stretch: 1; overflow: elide; }
}
}
}
}
}
}
+174 -124
View File
@@ -1,14 +1,19 @@
// G2 下载页(09 §2 P0·G2 / Q24-4 定案):页内独立左侧分类栏(常驻,非 tab)+ 右侧版本列表。
// 远古版入口显式可达(ULL 对 HMCL「隐藏」行为的纠偏),下挂 classic/alpha/beta/unlisted 子类树,
// 进入远古版分类时顶部提示兼容性 + 每行带「远古版」徽标(防误装)。
//
// P3 接线:右侧多一个「Java 运行时」视图(四家发行版 + 回退链),页面底部常驻安装进度条。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiVersion, VersionKind, DownloadCategory } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { Badge } from "../components/badge.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { SearchField, CheckBox } from "../widgets/inputs.slint";
import { InstallPanel } from "../components/install-panel.slint";
import { JavaPanel } from "download-java.slint";
import { ListView } from "std-widgets.slint";
component CategoryRow inherits Rectangle {
@@ -112,6 +117,8 @@ export component DownloadPage inherits Rectangle {
in-out property <string> query: "";
/// 远古版子类树是否展开(默认展开,显式可达)
in-out property <bool> ancient-expanded: true;
/// 右侧视图:0 = 版本列表,1 = Java 运行时
in-out property <int> view-tab <=> PageState.download-tab;
callback category-changed(DownloadCategory);
callback install(string);
@@ -123,148 +130,191 @@ export component DownloadPage inherits Rectangle {
background: Theme.bg-canvas;
HorizontalLayout {
// ── 页内分类侧栏(常驻)───────────────────────────────────
VerticalLayout {
// 视图切换(版本 / Java)
Rectangle {
width: 200px;
height: 40px;
background: Theme.bg-surface;
border-width: 1px;
border-color: Theme.stroke;
HorizontalLayout {
padding-left: Metrics.gap-4;
spacing: Metrics.gap-2;
UiText {
role: TextRole.subtitle;
text: "版本获取";
width: 96px;
}
MetroButton {
kind: root.view-tab == 0 ? ButtonKind.accent : ButtonKind.subtle;
text: "版本列表";
button-height: 26px;
clicked => { root.view-tab = 0; }
}
MetroButton {
kind: root.view-tab == 1 ? ButtonKind.accent : ButtonKind.subtle;
text: "Java 运行时";
button-height: 26px;
clicked => { root.view-tab = 1; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
if PageState.java-busy : UiText {
role: TextRole.caption;
text: "Java 安装中…";
color: Theme.accent;
}
}
}
Rectangle { height: 1px; background: Theme.stroke; }
if root.view-tab == 0 : HorizontalLayout {
vertical-stretch: 1;
// ── 页内分类侧栏(常驻)───────────────────────────────────
Rectangle {
width: 200px;
background: Theme.bg-surface;
border-width: 1px;
border-color: Theme.stroke;
VerticalLayout {
padding-top: Metrics.gap-2;
CategoryRow {
label: "正式版 release";
active: root.category == DownloadCategory.release;
clicked => { root.category-changed(DownloadCategory.release); }
}
CategoryRow {
label: "预览版 snapshot";
active: root.category == DownloadCategory.snapshot;
clicked => { root.category-changed(DownloadCategory.snapshot); }
}
// 远古版:可展开的父节点 + 子类树
CategoryRow {
label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸";
active: root.category == DownloadCategory.ancientClassic
|| root.category == DownloadCategory.ancientAlpha
|| root.category == DownloadCategory.ancientBeta
|| root.category == DownloadCategory.ancientUnlisted;
clicked => { root.toggle-ancient(); }
}
if root.ancient-expanded : CategoryRow {
label: "classic(rd-*)";
sub: true;
active: root.category == DownloadCategory.ancientClassic;
clicked => { root.category-changed(DownloadCategory.ancientClassic); }
}
if root.ancient-expanded : CategoryRow {
label: "old_alpha(a1.*)";
sub: true;
active: root.category == DownloadCategory.ancientAlpha;
clicked => { root.category-changed(DownloadCategory.ancientAlpha); }
}
if root.ancient-expanded : CategoryRow {
label: "old_beta(b1.*)";
sub: true;
active: root.category == DownloadCategory.ancientBeta;
clicked => { root.category-changed(DownloadCategory.ancientBeta); }
}
if root.ancient-expanded : CategoryRow {
label: "unlisted 汇总";
sub: true;
active: root.category == DownloadCategory.ancientUnlisted;
clicked => { root.category-changed(DownloadCategory.ancientUnlisted); }
}
CategoryRow {
label: "整合包(P1)";
disabled: true;
}
CategoryRow {
label: "Mod(P1)";
disabled: true;
}
Rectangle { vertical-stretch: 1; background: transparent; }
}
}
// ── 右侧版本列表 ──────────────────────────────────────────
VerticalLayout {
padding-top: Metrics.gap-2;
vertical-stretch: 1;
CategoryRow {
label: "正式版 release";
active: root.category == DownloadCategory.release;
clicked => { root.category-changed(DownloadCategory.release); }
}
CategoryRow {
label: "预览版 snapshot";
active: root.category == DownloadCategory.snapshot;
clicked => { root.category-changed(DownloadCategory.snapshot); }
Rectangle {
height: Metrics.page-header-height;
background: Theme.bg-canvas;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-3;
SearchField {
width: 220px;
placeholder: "筛选版本号";
text <=> root.query;
edited(t) => { root.query-changed(t); }
}
CheckBox {
text: "仅已装";
checked: root.installed-only;
toggled(v) => { root.installed-only-changed(v); }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
}
}
// 远古版:可展开的父节点 + 子类树
CategoryRow {
label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸";
active: root.category == DownloadCategory.ancientClassic
// 远古版兼容性提示条(首次进入显示,显式可达的代价告知)
Rectangle {
height: 44px;
visible: root.category == DownloadCategory.ancientClassic
|| root.category == DownloadCategory.ancientAlpha
|| root.category == DownloadCategory.ancientBeta
|| root.category == DownloadCategory.ancientUnlisted;
clicked => { root.toggle-ancient(); }
}
if root.ancient-expanded : CategoryRow {
label: "classic(rd-*)";
sub: true;
active: root.category == DownloadCategory.ancientClassic;
clicked => { root.category-changed(DownloadCategory.ancientClassic); }
}
if root.ancient-expanded : CategoryRow {
label: "old_alpha(a1.*)";
sub: true;
active: root.category == DownloadCategory.ancientAlpha;
clicked => { root.category-changed(DownloadCategory.ancientAlpha); }
}
if root.ancient-expanded : CategoryRow {
label: "old_beta(b1.*)";
sub: true;
active: root.category == DownloadCategory.ancientBeta;
clicked => { root.category-changed(DownloadCategory.ancientBeta); }
}
if root.ancient-expanded : CategoryRow {
label: "unlisted 汇总";
sub: true;
active: root.category == DownloadCategory.ancientUnlisted;
clicked => { root.category-changed(DownloadCategory.ancientUnlisted); }
background: Theme.state-warn-soft;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-2;
AppIcon { kind: IconKind.warn; icon-size: 16px; tint: Theme.state-warn; }
UiText {
role: TextRole.caption;
text: "远古版(classic/alpha/beta)需要旧版 Java 与旧启动参数,装完请先看兼容性说明。";
wrap: word-wrap;
horizontal-stretch: 1;
}
MetroButton {
kind: ButtonKind.subtle;
text: "说明";
button-height: 26px;
clicked => { root.ancient-help(); }
}
}
}
CategoryRow {
label: "整合包(P1)";
disabled: true;
}
CategoryRow {
label: "Mod(P1)";
disabled: true;
}
Rectangle { height: 1px; background: Theme.stroke; }
Rectangle { vertical-stretch: 1; background: transparent; }
ListView {
vertical-stretch: 1;
for v in root.versions : VersionRow {
data: v;
install => { root.install(v.id); }
}
}
}
}
// ── 右侧版本列表 ──────────────────────────────────────────
VerticalLayout {
if root.view-tab == 1 : JavaPanel {
vertical-stretch: 1;
}
Rectangle {
height: Metrics.page-header-height;
background: Theme.bg-canvas;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-3;
UiText {
role: TextRole.subtitle;
text: "版本获取";
width: 96px;
}
SearchField {
width: 220px;
placeholder: "筛选版本号";
text <=> root.query;
edited(t) => { root.query-changed(t); }
}
CheckBox {
text: "仅已装";
checked: root.installed-only;
toggled(v) => { root.installed-only-changed(v); }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
}
}
// 远古版兼容性提示条(首次进入显示,显式可达的代价告知)
Rectangle {
height: 44px;
visible: root.category == DownloadCategory.ancientClassic
|| root.category == DownloadCategory.ancientAlpha
|| root.category == DownloadCategory.ancientBeta
|| root.category == DownloadCategory.ancientUnlisted;
background: Theme.state-warn-soft;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-2;
AppIcon { kind: IconKind.warn; icon-size: 16px; tint: Theme.state-warn; }
UiText {
role: TextRole.caption;
text: "远古版(classic/alpha/beta)需要旧版 Java 与旧启动参数,装完请先看兼容性说明。";
wrap: word-wrap;
horizontal-stretch: 1;
}
MetroButton {
kind: ButtonKind.subtle;
text: "说明";
button-height: 26px;
clicked => { root.ancient-help(); }
}
}
}
Rectangle { height: 1px; background: Theme.stroke; }
ListView {
vertical-stretch: 1;
for v in root.versions : VersionRow {
data: v;
install => { root.install(v.id); }
}
}
// 安装进度(下载页行内安装 / 向导共用同一份进度)
InstallPanel {
data: PageState.install;
cancel => { PageState.install-cancel(); }
}
}
}
+49 -19
View File
@@ -8,6 +8,7 @@ import { AppIcon, IconKind } from "../components/icons.slint";
import { Badge } from "../components/badge.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { SearchField } from "../widgets/inputs.slint";
import { PageState } from "../page_state.slint";
import { ListView } from "std-widgets.slint";
component SessionRow inherits Rectangle {
@@ -106,6 +107,8 @@ export component LogsPage inherits Rectangle {
callback ask-ai(string);
callback apply-fix();
callback query-changed(string);
/// 重新扫会话索引(手动刷新)
callback refresh();
background: Theme.bg-canvas;
@@ -157,30 +160,57 @@ export component LogsPage inherits Rectangle {
VerticalLayout {
vertical-stretch: 1;
// 会话头:标题 + 实时标记 + 筛选 + 刷新(第一行),崩溃日志导入(第二行)
Rectangle {
height: 44px;
height: 84px;
background: Theme.bg-surface;
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
VerticalLayout {
HorizontalLayout {
height: 44px;
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
UiText {
role: TextRole.bodyStrong;
text: root.selected == "" ? "未选择会话" : root.selected;
horizontal-stretch: 1;
UiText {
role: TextRole.bodyStrong;
text: root.selected == "" ? "未选择会话" : root.selected;
horizontal-stretch: 1;
overflow: elide;
}
if root.live : Badge { text: "实时"; tint: Theme.state-success; }
SearchField {
width: 140px;
placeholder: "筛选日志行";
text <=> root.query;
edited(t) => { root.query-changed(t); }
}
MetroButton {
kind: ButtonKind.subtle;
text: "刷新";
button-height: 26px;
clicked => { root.refresh(); }
}
}
SearchField {
width: 200px;
placeholder: "筛选日志行";
text <=> root.query;
edited(t) => { root.query-changed(t); }
}
MetroButton {
kind: ButtonKind.subtle;
text: "打开目录";
button-height: 26px;
// 崩溃日志导入分析(hs_err_pid*.log / crash-*.txt / latest.log)
HorizontalLayout {
height: 40px;
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
SearchField {
horizontal-stretch: 1;
placeholder: "崩溃日志路径(如 .minecraft/crash-reports/crash-*.txt)";
text <=> PageState.crash-path;
}
MetroButton {
kind: ButtonKind.standard;
text: "导入分析";
button-height: 26px;
enabled: PageState.crash-path != "";
clicked => { PageState.crash-import(PageState.crash-path); }
}
}
}
}
+201
View File
@@ -0,0 +1,201 @@
// 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软登录(授权码浏览器 / 设备码)+ 删除/切换。
//
// 凭据红线:这里能看到的只有「名字 / 轨道 / 公开 UUID / 登录名 / 创建时间」——
// token 与密码永远不进 UI(Rust 侧的 AccountView 不含这些字段)。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { AccountKind, UiAccount } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { Badge } from "../components/badge.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { SearchField } from "../widgets/inputs.slint";
import { SettingGroup } from "../widgets/setting-item.slint";
component AccountRow inherits Rectangle {
in property <UiAccount> data;
callback activate();
callback remove();
height: 56px;
background: root.data.active ? Theme.accent-soft : transparent;
if root.data.active : Rectangle {
x: 0;
y: 0;
width: 3px;
height: root.height;
background: Theme.accent;
}
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
AppIcon {
icon-size: 16px;
kind: root.data.kind == AccountKind.offline ? IconKind.instances : IconKind.ai;
tint: root.data.active ? Theme.accent : Theme.fg-secondary;
}
VerticalLayout {
spacing: 0px;
vertical-stretch: 1;
UiText { role: TextRole.bodyStrong; text: root.data.name; horizontal-stretch: 1; }
UiText {
role: TextRole.caption;
text: root.data.profile_id + (root.data.login_name == "" ? "" : " · " + root.data.login_name);
horizontal-stretch: 1;
overflow: elide;
}
}
Badge { text: root.data.kind_label; tint: Theme.fg-secondary; }
UiText { role: TextRole.caption; text: root.data.created; width: 108px; }
if !root.data.active : MetroButton {
kind: ButtonKind.standard;
text: "用这个";
button-height: 24px;
clicked => { root.activate(); }
}
if root.data.active : Badge { text: "启动中"; tint: Theme.accent; }
MetroButton {
kind: ButtonKind.subtle;
text: "删除";
button-height: 24px;
clicked => { root.remove(); }
}
}
}
export component AccountSection inherits Rectangle {
background: transparent;
VerticalLayout {
// 微软登录面板(设备码显示用户码;授权码显示可复制的授权链接;失败显示原因)
if PageState.device.visible : Rectangle {
height: PageState.device.busy ? 96px : 84px;
background: PageState.device.phase_label == "失败" ? Theme.state-danger-soft : Theme.accent-soft;
VerticalLayout {
padding: Metrics.gap-3;
spacing: Metrics.gap-1;
HorizontalLayout {
spacing: Metrics.gap-2;
AppIcon {
icon-size: 16px;
kind: PageState.device.phase_label == "失败" ? IconKind.error : IconKind.ai;
tint: PageState.device.phase_label == "失败" ? Theme.state-danger : Theme.accent;
}
UiText {
role: TextRole.bodyStrong;
text: "微软登录 · " + PageState.device.phase_label;
horizontal-stretch: 1;
}
if PageState.device.busy : MetroButton {
kind: ButtonKind.subtle;
text: "取消";
button-height: 24px;
clicked => { PageState.msa-cancel(); }
}
if !PageState.device.busy : MetroButton {
kind: ButtonKind.subtle;
text: "关闭";
button-height: 24px;
clicked => { PageState.msa-cancel(); }
}
}
if PageState.device.user_code != "" : HorizontalLayout {
spacing: Metrics.gap-3;
UiText {
role: TextRole.title;
text: PageState.device.user_code;
color: Theme.accent;
}
UiText {
role: TextRole.caption;
text: "在浏览器打开 " + PageState.device.verification_uri + " 输入上面的用户码";
horizontal-stretch: 1;
overflow: elide;
}
}
UiText {
role: TextRole.caption;
text: PageState.device.message;
horizontal-stretch: 1;
wrap: word-wrap;
}
if PageState.device.qr_link != "" : UiText {
role: TextRole.monoSmall;
text: PageState.device.qr_link;
horizontal-stretch: 1;
overflow: elide;
}
}
}
// 离线添加表单
SettingGroup { title: "添加离线账户"; }
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-2;
SearchField {
width: 220px;
text <=> PageState.offline-name;
placeholder: "玩家名(离线 UUID 自动生成)";
}
MetroButton {
kind: ButtonKind.standard;
text: "添加离线";
button-height: 28px;
enabled: PageState.offline-name != "";
clicked => { PageState.account-add-offline(PageState.offline-name); }
}
MetroButton {
kind: ButtonKind.accent;
text: "微软登录";
button-height: 28px;
enabled: !PageState.device.busy;
clicked => { PageState.msa-browser-begin(); }
}
MetroButton {
kind: ButtonKind.subtle;
text: "设备码登录";
button-height: 28px;
enabled: !PageState.device.busy;
clicked => { PageState.msa-begin(); }
}
}
// 账户列表
SettingGroup { title: "账户(" + PageState.accounts.length + ")"; }
if PageState.accounts.length == 0 : Rectangle {
height: 72px;
VerticalLayout {
alignment: center;
spacing: 2px;
UiText {
role: TextRole.caption;
text: "还没有账户:启动时会用「Player」离线占位。";
horizontal-alignment: center;
}
}
}
for account in PageState.accounts : AccountRow {
data: account;
activate => { PageState.account-switch(account.id); }
remove => { PageState.account-remove(account.id); }
}
UiText {
role: TextRole.caption;
x: Metrics.gap-4;
text: "凭据只存在 <数据根>/data/auth/(账号文件与凭据文件分离),界面与 AI 都拿不到 token。";
horizontal-stretch: 1;
wrap: word-wrap;
}
}
}
+117
View File
@@ -0,0 +1,117 @@
// 设置页右栏的「schema 分组」渲染器:下载 / Java / 数据 / AI / MCP 五组。
//
// 行来自 Rust 侧的 `SettingGroupView`(core::setting schema 的键),这里只按控件类型画:
// 开关 → toggle;分段 → segmented;文本 → text;只读 → 纯文案;动作 → 按钮。
// 写回一律 `PageState.setting-changed(key, value)` / `setting-action(action)`,
// 由 host 走 `SettingStore::set`(同源 launcher.json)。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiSettingGroup, UiSettingRow, SettingControl } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint";
component RowView inherits Rectangle {
in property <UiSettingRow> item;
/// 文本编辑的本地缓冲(编辑中不回写模型,失焦/回车才提交)
in-out property <string> draft;
height: root.item.control == SettingControl.readonly ? 48px
: root.item.description == "" ? 44px : 58px;
background: transparent;
init => { self.draft = root.item.value_text; }
changed item => { self.draft = root.item.value_text; }
if root.item.control == SettingControl.toggle : SettingItem {
label: root.item.label;
description: root.item.description;
kind: ControlKind.toggle;
checked: root.item.flag;
toggled(v) => { PageState.setting-changed(root.item.key, v ? "true" : "false"); }
}
if root.item.control == SettingControl.choice : SettingItem {
label: root.item.label;
description: root.item.description;
kind: ControlKind.segmented;
options: root.item.options;
selected: root.item.selected;
changed(i) => {
if (i >= 0 && i < root.item.options.length) {
PageState.setting-changed(root.item.key, root.item.options[i]);
}
}
}
if root.item.control == SettingControl.text : SettingItem {
label: root.item.label;
description: root.item.description;
kind: ControlKind.text;
value: root.draft;
edited(t) => {
root.draft = t;
PageState.setting-changed(root.item.key, t);
}
}
if root.item.control == SettingControl.action : SettingItem {
label: root.item.label;
description: root.item.description;
kind: ControlKind.button;
button-text: "执行";
clicked => { PageState.setting-action(root.item.action); }
}
if root.item.control == SettingControl.readonly : HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-4;
spacing: Metrics.gap-3;
UiText {
role: TextRole.body;
text: root.item.label;
width: 200px;
}
UiText {
role: TextRole.caption;
text: root.item.description;
horizontal-stretch: 1;
wrap: word-wrap;
}
}
}
export component SettingsGroups inherits Rectangle {
in property <string> tab: "";
background: transparent;
VerticalLayout {
padding-bottom: Metrics.gap-5;
if PageState.setting-groups.length == 0 : Rectangle {
height: 120px;
VerticalLayout {
alignment: center;
spacing: 2px;
UiText {
role: TextRole.caption;
text: "设置还没加载出来(host 未就绪或数据根不可写)";
horizontal-alignment: center;
}
}
}
for group in PageState.setting-groups : Rectangle {
visible: group.id == root.tab;
height: group.rows.length * 58px + 40px;
background: transparent;
VerticalLayout {
SettingGroup { title: group.title; }
for setting-row in group.rows : RowView { item: setting-row; }
}
}
}
}
+12 -95
View File
@@ -8,6 +8,9 @@ import { SettingSection, TileSize, ThemeMode, BgFillKind } from "../models.slint
import { UiText, TextRole } from "../components/label.slint";
import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint";
import { AppearanceSection } from "settings-appearance.slint";
import { AccountSection } from "settings-account.slint";
import { SettingsGroups } from "settings-groups.slint";
import { PageState } from "../page_state.slint";
import { SearchField } from "../widgets/inputs.slint";
import { AboutSlint, ScrollView } from "std-widgets.slint";
@@ -235,101 +238,15 @@ export component SettingsPage inherits Rectangle {
toggled(v) => { root.close-to-tray-changed(v); }
}
// ── 数据 ────────────────────────────────────────
if root.section == SettingSection.data : SettingGroup { title: "数据目录(D6 三模式)"; }
if root.section == SettingSection.data : SettingItem {
label: "存储模式";
description: "便携(运行目录 .ULL/.minecraft)/ 标准(系统应用数据目录)/ 自定义";
kind: ControlKind.segmented;
options: ["便携", "标准", "自定义"];
selected: root.data-mode;
changed(i) => {
root.data-mode = i;
root.data-mode-changed(i);
}
}
if root.section == SettingSection.data : SettingItem {
label: "当前路径";
description: root.data-path;
kind: ControlKind.button;
button-text: "迁移…";
clicked => { root.migrate-data(); }
}
// ── 下载 ────────────────────────────────────────
if root.section == SettingSection.download : SettingGroup { title: "下载(D7)"; }
if root.section == SettingSection.download : SettingItem {
label: "并发数";
kind: ControlKind.segmented;
options: ["4", "8", "16"];
selected: root.download-concurrency;
changed(i) => {
root.download-concurrency = i;
root.download-concurrency-changed(i);
}
}
if root.section == SettingSection.download : SettingItem {
label: "地区判定";
description: "当前:" + root.region-label + "(决定是否走国内镜像)";
kind: ControlKind.button;
button-text: "重新检测";
clicked => { root.detect-region(); }
}
// ── Java ────────────────────────────────────────
if root.section == SettingSection.java : SettingGroup { title: "Java(D13)"; }
if root.section == SettingSection.java : SettingItem {
label: "Java 管理";
description: "基准 JDK17 自动下载 · 多版本可选 · 不匹配时黄条提示";
kind: ControlKind.button;
button-text: "管理多版本";
clicked => { root.manage-java(); }
}
// ── 账户 ────────────────────────────────────────
if root.section == SettingSection.account : SettingGroup { title: "账户(D9)"; }
if root.section == SettingSection.account : SettingItem {
label: "当前账户";
description: root.account-label;
kind: ControlKind.button;
button-text: "登录 / 切换";
clicked => { root.login(); }
}
// ── AI ──────────────────────────────────────────
if root.section == SettingSection.ai : SettingGroup { title: "AI 助手(D4)"; }
if root.section == SettingSection.ai : SettingItem {
label: "模型端点";
description: "OpenAI 兼容 / 本地 llama.cpp / Anthropic / 自定义";
kind: ControlKind.segmented;
options: ["OpenAI", "llama.cpp", "Anthropic", "自定义"];
selected: root.ai-endpoint;
changed(i) => {
root.ai-endpoint = i;
root.ai-endpoint-changed(i);
}
}
if root.section == SettingSection.ai : SettingItem {
label: "YOLO 模式";
description: "跳过全部确认弹窗(审计照记),账户/凭据操作永不 YOLO";
kind: ControlKind.toggle;
checked: root.yolo;
toggled(v) => { root.yolo-changed(v); }
}
if root.section == SettingSection.ai : SettingItem {
label: "MCP server";
description: "允许外部客户端(如 Claude Desktop)调用工具集";
kind: ControlKind.toggle;
checked: root.mcp-enabled;
toggled(v) => { root.mcp-changed(v); }
}
if root.section == SettingSection.ai : SettingItem {
label: "审计日志";
description: "AI 的所有写操作都有记录";
kind: ControlKind.button;
button-text: "查看";
clicked => { root.open-audit-log(); }
}
// ── 数据 / 下载 / Java / 账户 / AI / MCP ────────────
// 行来自 core::setting schema(`PageState.setting-groups`),写回走
// `SettingStore::set`——与 `ull config set` / AI `set_config` 同一份 launcher.json。
if root.section == SettingSection.data : SettingsGroups { tab: "data"; }
if root.section == SettingSection.download : SettingsGroups { tab: "download"; }
if root.section == SettingSection.java : SettingsGroups { tab: "java"; }
if root.section == SettingSection.ai : SettingsGroups { tab: "ai"; }
if root.section == SettingSection.ai : SettingsGroups { tab: "mcp"; }
if root.section == SettingSection.account : AccountSection { }
// ── 代理 ────────────────────────────────────────
if root.section == SettingSection.proxy : SettingGroup { title: "代理"; }
+14
View File
@@ -7,6 +7,8 @@ import {
RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind,
UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast,
} from "models.slint";
import { PageState } from "page_state.slint";
import { NewInstanceWizard } from "widgets/wizard.slint";
import { TitleBar } from "components/titlebar.slint";
import { Nav } from "components/nav.slint";
import { AiPanel } from "components/ai-panel.slint";
@@ -14,11 +16,13 @@ import { UiText, TextRole } from "components/label.slint";
import { MetroButton, ButtonKind } from "widgets/button.slint";
import { DialogHost } from "widgets/dialog.slint";
import { ToastHost } from "widgets/toast.slint";
import { NoticeBar } from "components/notice-bar.slint";
import { InstancesPage } from "pages/instances.slint";
import { DownloadPage } from "pages/download.slint";
import { LogsPage } from "pages/logs.slint";
import { SettingsPage } from "pages/settings.slint";
export component AppShell inherits Rectangle {
// ── 窗口 ────────────────────────────────────────────────────
in property <string> app-title: "Unlucky Launcher";
@@ -189,6 +193,10 @@ export component AppShell inherits Rectangle {
request-close => { root.request-close(); }
}
NoticeBar {
notices: PageState.notices;
}
HorizontalLayout {
// 左侧主导航
Nav {
@@ -259,6 +267,7 @@ export component AppShell inherits Rectangle {
select(id) => { root.log-select(id); }
ask-ai(id) => { root.log-ask-ai(id); }
apply-fix => { root.log-apply-fix(); }
refresh => { PageState.logs-refresh(); }
}
if root.route == RouteKind.settings : SettingsPage {
@@ -356,6 +365,11 @@ export component AppShell inherits Rectangle {
}
}
// 新建实例向导(全屏模态;数据来自 PageState)
NewInstanceWizard {
versions: root.versions;
}
// 覆盖层:模态队列(一次一条)+ 右下 Toast
DialogHost {
current: root.dialog;
+301
View File
@@ -0,0 +1,301 @@
// 新建实例向导(G5,全屏模态):版本 → 加载器 → 名字 → 确认 → 进度。
// 步骤推进/校验在 Rust(`crate::wizard`),这里只画当前步与提交按钮。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiVersion, VersionKind } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { Badge } from "../components/badge.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { SearchField } from "../widgets/inputs.slint";
import { InstallPanel } from "../components/install-panel.slint";
import { ScrollView } from "std-widgets.slint";
component StepDot inherits Rectangle {
in property <int> index: 0;
in property <string> label: "";
height: 28px;
background: transparent;
HorizontalLayout {
spacing: Metrics.gap-1;
alignment: center;
Rectangle {
width: 18px;
height: 18px;
border-radius: 9px;
background: PageState.wizard.step >= root.index ? Theme.accent : Theme.bg-hover;
UiText {
role: TextRole.caption;
text: root.index + 1;
horizontal-alignment: center;
color: PageState.wizard.step >= root.index ? Theme.fg-on-accent : Theme.fg-secondary;
}
}
UiText {
role: TextRole.caption;
text: root.label;
color: PageState.wizard.step >= root.index ? Theme.fg-primary : Theme.fg-disabled;
}
}
}
component VersionChoice inherits Rectangle {
in property <UiVersion> data;
callback picked();
height: 36px;
background: PageState.wizard.version == root.data.id ? Theme.accent-soft
: (touch.has-hover ? Theme.bg-hover : transparent);
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
UiText { role: TextRole.body; text: root.data.id; width: 160px; }
Badge {
text: root.data.kind == VersionKind.release ? "正式版" : "预览版";
tint: Theme.fg-secondary;
}
UiText { role: TextRole.caption; text: root.data.released; horizontal-stretch: 1; }
if PageState.wizard.version == root.data.id : AppIcon {
kind: IconKind.check;
icon-size: 14px;
tint: Theme.accent;
}
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.picked(); }
}
}
component LoaderChoice inherits Rectangle {
in property <string> label: "";
in property <string> hint: "";
in property <int> index: 0;
callback picked();
height: 48px;
background: PageState.wizard.loader == root.index ? Theme.accent-soft
: (touch.has-hover ? Theme.bg-hover : transparent);
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
VerticalLayout {
vertical-stretch: 1;
spacing: 0px;
UiText { role: TextRole.body; text: root.label; }
UiText { role: TextRole.caption; text: root.hint; horizontal-stretch: 1; }
}
if PageState.wizard.loader == root.index : AppIcon {
kind: IconKind.check;
icon-size: 14px;
tint: Theme.accent;
}
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.picked(); }
}
}
export component NewInstanceWizard inherits Rectangle {
// 版本列表由 shell 传入(与下载页共用一份,避免二次拉取)
in property <[UiVersion]> versions;
in-out property <string> query: "";
visible: PageState.wizard.open;
background: rgba(0, 0, 0, 0.45);
// 吃掉底层点击(模态)
TouchArea { }
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(parent.width - 80px, 720px);
height: min(parent.height - 60px, 560px);
background: Theme.bg-surface;
border-radius: Metrics.radius-md;
border-width: 1px;
border-color: Theme.stroke;
VerticalLayout {
// 标题栏
Rectangle {
height: 52px;
background: transparent;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
UiText {
role: TextRole.subtitle;
text: "新建实例 · " + PageState.wizard.step_label;
horizontal-stretch: 1;
}
MetroButton {
kind: ButtonKind.subtle;
text: "关闭";
button-height: 24px;
clicked => { PageState.wizard-close(); }
}
}
}
// 步骤指示
HorizontalLayout {
padding-left: Metrics.gap-4;
spacing: Metrics.gap-3;
StepDot { index: 0; label: "版本"; }
StepDot { index: 1; label: "加载器"; }
StepDot { index: 2; label: "名字"; }
StepDot { index: 3; label: "确认"; }
StepDot { index: 4; label: "进度"; }
}
Rectangle { height: 1px; background: Theme.stroke; }
// 步骤内容
Rectangle {
vertical-stretch: 1;
background: transparent;
// ① 选版本(只列正式版/快照,远古版走下载页的显式入口)
if PageState.wizard.step == 0 : VerticalLayout {
padding: Metrics.gap-3;
spacing: Metrics.gap-2;
SearchField {
width: 220px;
text <=> root.query;
placeholder: "筛选版本号";
}
ScrollView {
vertical-stretch: 1;
VerticalLayout {
for v in root.versions : VersionChoice {
visible: !v.ancient;
data: v;
picked => { PageState.wizard-select-version(v.id); }
}
}
}
}
// ② 选加载器
if PageState.wizard.step == 1 : VerticalLayout {
padding: Metrics.gap-3;
spacing: 0px;
LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } }
LoaderChoice { index: 1; label: "Fabric"; hint: "轻量模组加载器(GUI 可直接装)"; picked => { PageState.wizard-select-loader(1); } }
LoaderChoice { index: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } }
LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(3); } }
LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(4); } }
}
// ③ 名字
if PageState.wizard.step == 2 : VerticalLayout {
padding: Metrics.gap-4;
spacing: Metrics.gap-2;
UiText { role: TextRole.body; text: "实例名(默认用版本号)"; }
SearchField {
width: 320px;
text: PageState.wizard.name;
placeholder: "例如:生存 1.20.4";
edited(t) => { PageState.wizard-name-changed(t); }
}
if PageState.wizard.error != "" : UiText {
role: TextRole.caption;
text: PageState.wizard.error;
color: Theme.state-danger;
}
UiText {
role: TextRole.caption;
text: "不能包含 / \\ : * ? \" < > |,最多 64 个字符。";
}
}
// ④ 确认
if PageState.wizard.step == 3 : VerticalLayout {
padding: Metrics.gap-4;
spacing: Metrics.gap-2;
UiText { role: TextRole.bodyStrong; text: "将创建实例"; }
UiText { role: TextRole.body; text: "版本 " + PageState.wizard.version; }
UiText { role: TextRole.body; text: "加载器 " + PageState.wizard.loader_label; }
UiText { role: TextRole.body; text: "实例名 " + PageState.wizard.name; }
UiText {
role: TextRole.caption;
text: "确认后会按 core 安装管线下载:版本 JSON → 客户端 jar → 库 → 资源索引 → 登记实例。";
wrap: word-wrap;
}
if PageState.wizard.error != "" : UiText {
role: TextRole.caption;
text: PageState.wizard.error;
color: Theme.state-danger;
}
}
// ⑤ 进度
if PageState.wizard.step == 4 : VerticalLayout {
padding: Metrics.gap-3;
spacing: Metrics.gap-2;
InstallPanel {
data: PageState.install;
cancel => { PageState.install-cancel(); }
}
UiText {
role: TextRole.caption;
text: "装完会自动跳到实例页;失败原因会显示在上面。";
}
}
}
Rectangle { height: 1px; background: Theme.stroke; }
// 底部按钮
Rectangle {
height: 56px;
background: transparent;
HorizontalLayout {
padding: Metrics.gap-4;
spacing: Metrics.gap-2;
Rectangle { horizontal-stretch: 1; background: transparent; }
MetroButton {
kind: ButtonKind.subtle;
text: "上一步";
button-height: 30px;
enabled: PageState.wizard.step > 0 && PageState.wizard.step < 4;
clicked => { PageState.wizard-back(); }
}
if PageState.wizard.step < 3 : MetroButton {
kind: ButtonKind.accent;
text: "下一步";
button-height: 30px;
enabled: PageState.wizard.can-next;
clicked => { PageState.wizard-next(); }
}
if PageState.wizard.step == 3 : MetroButton {
kind: ButtonKind.accent;
text: "开始安装";
button-height: 30px;
enabled: PageState.wizard.can-next && !PageState.wizard.busy;
clicked => { PageState.wizard-confirm(); }
}
if PageState.wizard.step == 4 : MetroButton {
kind: ButtonKind.standard;
text: "后台继续";
button-height: 30px;
clicked => { PageState.wizard-close(); }
}
}
}
}
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ impl std::fmt::Display for AccountId {
pub enum AccountKind {
/// 离线(本地生成的 UUID,不联网)。
Offline,
/// 微软(device-code)。
/// 微软(device-code / 授权码浏览器)。
Microsoft,
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
AuthlibInjector,
+284
View File
@@ -0,0 +1,284 @@
//! 授权码流(`research/08` §3 方案 A):授权 URL 构造 + code 换 token + 整链编排。
//!
//! 与设备码流的分工:**只多一跳**——拿 MS access_token 的方式不同,往后的
//! XBL → XSTS → login_with_xbox → entitlements → profile 与刷新链完全复用
//! ([`super::chain::complete_chain`]),所以两条流的凭据存储与刷新语义天然一致。
//!
//! # 三个硬性细节(都是「抄错就失败」的那一类)
//!
//! 1. **租户 `consumers`**:授权页与 token 端点都走 `login.microsoftonline.com/consumers/…`,
//! 换成 `common`/AAD 租户 id 时 `XboxLive.signin` 直接被拒(`research/08` §2 约束 1)。
//! 2. **重定向 URI 严格一致**:`http://localhost:{port}/auth` —— 授权 URL 里的、
//! token 请求体里的、本地服务器实际监听的必须是**同一个字符串**(含端口与路径),
//! 所以本模块只从 [`RedirectUri`] 取这一个来源。
//! 3. **`prompt=select_account`**:不带这个参数,用户会被静默塞进上一次的账号(坑 6)。
//!
//! scope 与设备码流共用 [`super::flow::SCOPE`](`XboxLive.signin offline_access`)。
use std::fmt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use reqwest::Client;
use crate::auth::error::AuthResult;
use crate::auth::http::post_form;
use crate::auth::secret::Secret;
use super::callback::{CallbackServer, REDIRECT_PATH};
use super::chain::complete_chain;
use super::endpoints::MicrosoftEndpoints;
use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE};
use super::now_ms;
use super::session::MicrosoftSession;
/// 授权码 grant 类型。
pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code";
/// 默认等回调的时间预算(用户要开浏览器、登录、点同意)。
pub const DEFAULT_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
/// 默认的端口绑定尝试次数。
pub const DEFAULT_BIND_ATTEMPTS: u8 = 3;
/// 回填到授权 URL 与 token 请求里的重定向 URI(**唯一来源**)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedirectUri {
port: u16,
url: String,
}
impl RedirectUri {
/// 按实际绑定的端口构造 `http://localhost:{port}/auth`。
///
/// 用 `localhost` 而不是 `127.0.0.1`:微软对两者处理不一致(`research/08` 坑 5)。
#[must_use]
pub fn new(port: u16) -> Self {
Self {
port,
url: format!("http://localhost:{port}{REDIRECT_PATH}"),
}
}
/// 端口。
#[must_use]
pub fn port(&self) -> u16 {
self.port
}
/// URI 字面量(授权 URL 与 token 请求共用,保证逐字节一致)。
#[must_use]
pub fn as_str(&self) -> &str {
&self.url
}
}
impl fmt::Display for RedirectUri {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.url)
}
}
/// 授权码流的运行参数(生产用 [`AuthCodePolicy::default`],测试压短超时/固定端口)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthCodePolicy {
/// 等浏览器回调的上限。
pub callback_timeout: Duration,
/// 端口绑定尝试次数(`>= 1`)。
pub bind_attempts: u8,
/// 固定端口(`None` = 由内核分配,生产路径)。
pub port: Option<u16>,
/// CSRF 用的 `state`(`None` = 每次随机生成)。
pub state: Option<String>,
}
impl Default for AuthCodePolicy {
fn default() -> Self {
Self {
callback_timeout: DEFAULT_CALLBACK_TIMEOUT,
bind_attempts: DEFAULT_BIND_ATTEMPTS,
port: None,
state: None,
}
}
}
/// 要宿主做的事:打开浏览器(core 不碰 UI:谁来开由 CLI/GUI 决定)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthCodePrompt {
/// 授权页 URL(宿主交给系统默认浏览器;也可展示给用户手动打开)。
pub authorization_url: String,
/// 本地回调地址(展示/排障用;与授权 URL 里的 `redirect_uri` 同源)。
pub redirect_uri: String,
/// 最多等多久。
pub timeout: Duration,
}
/// 拼授权 URL(`response_type=code` + `prompt=select_account` + 严格编码的重定向 URI)。
#[must_use]
pub fn authorization_url(
endpoints: &MicrosoftEndpoints,
client_id: &str,
redirect_uri: &RedirectUri,
state: &str,
) -> String {
format!(
"{}?client_id={}&response_type=code&redirect_uri={}&scope={}&prompt=select_account&state={}",
endpoints.authorize,
encode(client_id),
encode(redirect_uri.as_str()),
encode(SCOPE),
encode(state),
)
}
/// 授权码换 token(`grant_type=authorization_code`)。
///
/// # Errors
///
/// OAuth 拒绝([`crate::auth::AuthError::Denied`] /
/// [`crate::auth::AuthError::CredentialExpired`])、网络失败、响应缺 token。
pub(crate) async fn exchange_code(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
code: &Secret,
redirect_uri: &RedirectUri,
) -> AuthResult<OAuthTokens> {
let reply = post_form(
client,
&endpoints.token,
vec![
("client_id", client_id.to_string()),
("grant_type", AUTHORIZATION_CODE_GRANT.to_string()),
("code", code.expose().to_string()),
("redirect_uri", redirect_uri.as_str().to_string()),
("scope", SCOPE.to_string()),
],
)
.await?;
if reply.text("error").is_some() || !reply.is_success() {
return Err(oauth_error(&reply, &endpoints.token));
}
tokens_from(&reply, &endpoints.token)
}
/// 授权码登录主体:绑端口 → 出授权 URL(交给 `present`)→ 等回调 → 换 token → 整链。
///
/// `present` 在**端口绑定之后、等待之前**被调用:宿主此时才知道端口,也只有这时
/// 打开浏览器才不会让用户对着还没起来的服务端登录。
pub(crate) async fn login<F>(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
policy: AuthCodePolicy,
present: F,
) -> AuthResult<MicrosoftSession>
where
F: FnOnce(&AuthCodePrompt),
{
let state = policy.state.clone().unwrap_or_else(nonce);
let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?;
let redirect_uri = RedirectUri::new(server.port());
present(&AuthCodePrompt {
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
redirect_uri: redirect_uri.as_str().to_string(),
timeout: policy.callback_timeout,
});
let code = server.wait_for_code(policy.callback_timeout).await?;
let tokens = exchange_code(
client,
endpoints,
client_id,
&Secret::new(code),
&redirect_uri,
)
.await?;
complete_chain(client, endpoints, tokens, now_ms()).await
}
/// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。
///
/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少
/// 128 位熵,挡住「猜到 state 就能伪造回调」这类攻击面。
fn nonce() -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_nanos())
.unwrap_or(0);
let mut halves = [String::new(), String::new()];
for half in &mut halves {
// 每次 `RandomState::new()` 都是独立随机种子 → 两段各 64 位、彼此不可推。
let mut hasher = RandomState::new().build_hasher();
hasher.write_u128(nanos);
hasher.write_u32(std::process::id());
*half = format!("{:016x}", hasher.finish());
}
format!("{}{}", halves[0], halves[1])
}
/// 百分号编码(RFC 3986 unreserved 之外一律转义;空格用 `%20` 而不是 `+`)。
fn encode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for byte in raw.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(char::from(byte));
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::{authorization_url, encode, RedirectUri, AUTHORIZATION_CODE_GRANT};
use crate::auth::microsoft::MicrosoftEndpoints;
#[test]
fn redirect_uri_is_localhost_with_exact_port_and_path() {
let uri = RedirectUri::new(51_234);
assert_eq!(uri.as_str(), "http://localhost:51234/auth");
assert_eq!(uri.port(), 51_234);
assert_eq!(uri.to_string(), uri.as_str());
}
#[test]
fn authorization_url_matches_research_08_vector() {
let endpoints = MicrosoftEndpoints::consumers();
let uri = RedirectUri::new(8_080);
let url = authorization_url(&endpoints, "0000-client", &uri, "state-123");
assert_eq!(
url,
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize\
?client_id=0000-client&response_type=code\
&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth\
&scope=XboxLive.signin%20offline_access&prompt=select_account&state=state-123"
);
// 三个硬性细节各断言一遍,改坏了立刻知道是哪一条。
assert!(url.contains("/consumers/oauth2/v2.0/authorize"));
assert!(url.contains("prompt=select_account"));
assert!(url.contains("scope=XboxLive.signin%20offline_access"));
}
#[test]
fn encode_escapes_everything_but_unreserved() {
assert_eq!(encode("abc-._~09"), "abc-._~09");
assert_eq!(encode("a b/c:"), "a%20b%2Fc%3A");
}
#[test]
fn grant_type_is_the_standard_one() {
assert_eq!(AUTHORIZATION_CODE_GRANT, "authorization_code");
}
#[test]
fn generated_state_has_128_bits_of_entropy() {
let first = super::nonce();
let second = super::nonce();
assert_eq!(first.len(), 32, "128 位 = 32 个 hex 字符:{first}");
assert!(first.chars().all(|c| c.is_ascii_hexdigit()), "{first}");
assert_ne!(first, second, "每次登录的 state 必须不同");
}
}
+346
View File
@@ -0,0 +1,346 @@
//! 授权码流的本地回调服务器(`research/08` §3 方案 A:HMCL 主流做法)。
//!
//! 只干一件事:等浏览器把 `?code=` 送到 `http://localhost:{port}/auth`,把 code 交出去。
//! 协议面故意压到最小(只认一个 GET 路径、只回一个静态页),这样它能被单测直接驱动。
//!
//! # 四条容易踩的细节
//!
//! 1. **重定向 URI 用 `localhost`,监听绑 `127.0.0.1`**:微软对 `localhost` 与 `127.0.0.1`
//! 处理不一致(坑 5),URI 只能写前者;浏览器把 `localhost` 解析到 `::1` 时会被拒一次
//! 再回落 IPv4,Chrome/Edge 都走这条回退,所以只绑 v4 就够。
//! 2. **端口由内核分配**(绑定 `:0`),端口被占时按 `attempts` 重试;授权 URL 里的
//! `redirect_uri` 必须用**最终绑到的端口**回填,换 token 时再原样带上(URI 严格一致)。
//! 3. **非 `/auth` 的请求(`/favicon.ico`、浏览器预连)回 404 后继续等**:让一个 favicon
//! 把整次登录判成失败是最容易犯的错。
//! 4. **`state` 不匹配的请求一律回 400 后继续等**——包括带 `error=` 的那些:旧标签页的
//! 迟到回调不该打断新的登录,更要紧的是 RFC 6749 §4.1.2.1 / RFC 9207 要求**错误响应同样
//! 要校验 `state`**;否则任何人 `GET /auth?error=access_denied` 就能静默中止别人的登录。
//! 真正的超时由调用方的时间预算兜底。
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::auth::error::{AuthError, AuthResult};
/// 回调路径(重定向 URI 的路径段)。
pub const REDIRECT_PATH: &str = "/auth";
/// 一次回调请求最多读这么多字节(超了就丢弃连接,防被灌内存)。
pub const MAX_REQUEST_HEAD: usize = 8 * 1024;
/// 端口被占用时的重试间隔。
const RETRY_DELAY: Duration = Duration::from_millis(20);
/// 单条连接读完请求头的上限(浏览器/预连不把请求头发完时不能把整个登录卡死)。
const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(5);
const SUCCESS_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
<title>登录完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
<h1>登录完成</h1><p>可以关闭此窗口,回到启动器继续。</p></body></html>";
const DENIED_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
<title>登录未完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
<h1>登录未完成</h1><p>授权没有完成,可以回到启动器重试。</p></body></html>";
const BAD_REQUEST_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
<title>回调无效</title></head><body style=\"font-family:system-ui;padding:40px\">\
<h1>回调无效</h1><p>这个链接不属于本次登录。</p></body></html>";
const NOT_FOUND_PAGE: &str = "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<title>Not Found</title></head><body><h1>Not Found</h1></body></html>";
/// 本地回调服务器(一个实例只服务一次登录)。
#[derive(Debug)]
pub struct CallbackServer {
listener: TcpListener,
port: u16,
state: String,
}
impl CallbackServer {
/// 绑定回调端口。
///
/// `port` 为 `None` 时由内核分配(生产路径);给定端口时按 `attempts` 重试
/// (CLI/测试里固定端口用,端口被占会退避再试)。
///
/// # Errors
///
/// 全部尝试都绑定失败时返回 [`AuthError::Network`](文案带端口与重试次数)。
pub async fn bind(
port: Option<u16>,
attempts: u8,
state: impl Into<String>,
) -> AuthResult<Self> {
let wanted = port.unwrap_or(0);
let attempts = attempts.max(1);
let mut last = String::new();
for attempt in 0..attempts {
match TcpListener::bind(("127.0.0.1", wanted)).await {
Ok(listener) => {
let port = listener
.local_addr()
.map_err(|error| AuthError::network("localhost", error.to_string()))?
.port();
return Ok(Self {
listener,
port,
state: state.into(),
});
}
Err(error) => {
last = error.to_string();
if attempt + 1 < attempts {
tokio::time::sleep(RETRY_DELAY).await;
}
}
}
}
let what = if wanted == 0 {
"本地回调端口分配失败".to_string()
} else {
format!("本地回调端口 {wanted} 绑定失败(重试 {attempts} 次)")
};
Err(AuthError::network("localhost", format!("{what}:{last}")))
}
/// 实际端口(回填 `redirect_uri` 用)。
#[must_use]
pub fn port(&self) -> u16 {
self.port
}
/// 本次登录的 `state`(授权 URL 与回调校验共用)。
#[must_use]
pub fn state(&self) -> &str {
&self.state
}
/// 等浏览器把 code 送到 `/auth`,最多等 `timeout`。
///
/// # Errors
///
/// 超时([`AuthError::Timeout`])、用户在授权页点了取消([`AuthError::Canceled`])、
/// 授权页回了别的 `error`([`AuthError::Denied`])、socket 出错([`AuthError::Network`])。
pub async fn wait_for_code(self, timeout: Duration) -> AuthResult<String> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(wait_timeout(timeout));
}
let accepted = tokio::time::timeout(remaining, self.listener.accept()).await;
let (mut stream, _) = match accepted {
Ok(Ok(pair)) => pair,
Ok(Err(error)) => {
return Err(AuthError::network("localhost", error.to_string()));
}
Err(_) => return Err(wait_timeout(timeout)),
};
// 一条连接最多读这么久请求头:慢客户端/半开连接不能把整个登录拖过时间预算。
// 预算按 **accept 之后** 的剩余时间重算(用 accept 前那份会让总时长超发最多 5s)。
let budget = REQUEST_READ_TIMEOUT
.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
if budget.is_zero() {
return Err(wait_timeout(timeout));
}
match tokio::time::timeout(budget, self.handle(&mut stream)).await {
Ok(result) => {
if let Some(code) = result? {
return Ok(code);
}
}
Err(_) => continue,
}
}
}
/// 处理一条连接:`Some(code)` = 登录完成,`None` = 与本次登录无关,继续等。
async fn handle(&self, stream: &mut TcpStream) -> AuthResult<Option<String>> {
let Some(target) = read_request_target(stream).await else {
return Ok(None);
};
let (path, query) = match target.split_once('?') {
Some((path, query)) => (path, query),
None => (target.as_str(), ""),
};
if path != REDIRECT_PATH {
respond(stream, 404, NOT_FOUND_PAGE).await;
return Ok(None);
}
let params = Query::parse(query);
// state 必须先校验,且**先于** error:错误响应同样要认 state(RFC 6749 §4.1.2.1 /
// RFC 9207)。否则任意一次 `GET /auth?error=access_denied`(state 随便给或不给)就能
// 把这台机器上正在进行的登录静默中止 —— 不需要知道随机 state、不需要 code。
if params.get("state") != Some(self.state.as_str()) {
respond(stream, 400, BAD_REQUEST_PAGE).await;
return Ok(None);
}
if let Some(error) = params.get("error") {
respond(stream, 200, DENIED_PAGE).await;
return Err(oauth_error(error, params.get("error_description")));
}
let Some(code) = params.get("code").filter(|code| !code.is_empty()) else {
respond(stream, 400, BAD_REQUEST_PAGE).await;
return Ok(None);
};
respond(stream, 200, SUCCESS_PAGE).await;
Ok(Some(code.to_string()))
}
}
fn wait_timeout(timeout: Duration) -> AuthError {
AuthError::Timeout {
what: format!("等待浏览器回调(上限 {} 秒)", timeout.as_secs()),
}
}
/// 授权页回的 `error` → 错误分流(`access_denied` 归取消,其余归拒绝)。
fn oauth_error(code: &str, description: Option<&str>) -> AuthError {
if matches!(code, "access_denied" | "authorization_declined") {
return AuthError::Canceled;
}
AuthError::Denied {
code: code.to_string(),
description: description.unwrap_or("授权页没有给出原因").to_string(),
}
}
/// 读请求行里的 target(`/auth?code=...`)。非 GET/HEAD、超长头、连接中断都给 `None`。
async fn read_request_target(stream: &mut TcpStream) -> Option<String> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
if let Some(end) = find_headers_end(&buffer) {
let head = String::from_utf8_lossy(&buffer[..end]).into_owned();
let mut parts = head.lines().next()?.split(' ');
let method = parts.next()?;
if !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") {
return None;
}
return parts.next().map(str::to_string);
}
if buffer.len() > MAX_REQUEST_HEAD {
return None;
}
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return None,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
}
}
fn find_headers_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4)
}
async fn respond(stream: &mut TcpStream, status: u16, page: &str) {
let response = format!(
"HTTP/1.1 {status} {}\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{page}",
reason(status),
page.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
_ => "Status",
}
}
/// 回调 query 的最小解析(`k=v` 对,percent 解码)。
struct Query(Vec<(String, String)>);
impl Query {
fn parse(raw: &str) -> Self {
Self(
raw.split('&')
.filter(|pair| !pair.is_empty())
.filter_map(|pair| {
let (key, value) = pair.split_once('=')?;
Some((decode(key), decode(value)))
})
.collect(),
)
}
fn get(&self, key: &str) -> Option<&str> {
self.0
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value.as_str())
}
}
/// `percent` 解码(`+` 视为空格;坏转义原样保留)。
fn decode(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' if index + 2 < bytes.len() => {
let parsed = std::str::from_utf8(&bytes[index + 1..index + 3])
.ok()
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
match parsed {
Some(byte) => {
out.push(byte);
index += 3;
}
None => {
out.push(b'%');
index += 1;
}
}
}
b'+' => {
out.push(b' ');
index += 1;
}
byte => {
out.push(byte);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::{decode, oauth_error, Query};
use crate::auth::error::AuthError;
#[test]
fn query_decodes_percent_and_plus() {
let query = Query::parse("code=ab%2Fcd&state=s+1&empty=");
assert_eq!(query.get("code"), Some("ab/cd"));
assert_eq!(query.get("state"), Some("s 1"));
assert_eq!(query.get("empty"), Some(""));
assert_eq!(query.get("missing"), None);
}
#[test]
fn decode_keeps_broken_escapes() {
assert_eq!(decode("100%"), "100%");
assert_eq!(decode("%zz"), "%zz");
assert_eq!(decode("a%20b"), "a b");
}
#[test]
fn access_denied_maps_to_canceled() {
assert_eq!(oauth_error("access_denied", None), AuthError::Canceled);
match oauth_error("invalid_request", Some("bad scope")) {
AuthError::Denied { code, description } => {
assert_eq!(code, "invalid_request");
assert_eq!(description, "bad scope");
}
other => panic!("应是 Denied,实际 {other:?}"),
}
}
}
+20 -6
View File
@@ -1,8 +1,11 @@
//! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。
//!
//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」):
//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`;
//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。
//! 两条流**共用同一个端点族**(`research/08` §2 硬性约束 1:租户只能用 `consumers`):
//! - 设备码流:`devicecode` + `token`;
//! - 授权码流:`authorize`(浏览器打开)+ 同一个 `token`(`grant_type=authorization_code`)。
//!
//! 都走 `login.microsoftonline.com/consumers/oauth2/v2.0/*`:用 `common` 或 AAD 租户 id
//! 拿 `XboxLive.signin` 会直接被拒(`analyse/04` §4 的「第一个坑」)。
//!
//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链,
//! 生产默认走 [`MicrosoftEndpoints::consumers`]。
@@ -12,7 +15,9 @@
pub struct MicrosoftEndpoints {
/// 设备码申请(`POST`,表单 `client_id` + `scope`)。
pub device_code: String,
/// 换 token / 刷新 token(`POST`,表单)。
/// 授权码流的授权页(`GET`,浏览器打开;`research/08` §3 方案 A)。
pub authorize: String,
/// 换 token / 刷新 token / 授权码换 token(`POST`,表单)。
pub token: String,
/// Xbox Live 用户认证(`POST` JSON)。
pub xbl_authenticate: String,
@@ -35,6 +40,8 @@ impl MicrosoftEndpoints {
Self {
device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
.to_string(),
authorize: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
.to_string(),
token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(),
xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(),
xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(),
@@ -54,6 +61,7 @@ impl MicrosoftEndpoints {
let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/'));
Self {
device_code: join("devicecode"),
authorize: join("authorize"),
token: join("token"),
xbl_authenticate: join("xbl"),
xsts_authorize: join("xsts"),
@@ -66,9 +74,10 @@ impl MicrosoftEndpoints {
/// 所有端点(调试/诊断用)。
#[must_use]
pub fn all(&self) -> [&str; 8] {
pub fn all(&self) -> [&str; 9] {
[
&self.device_code,
&self.authorize,
&self.token,
&self.xbl_authenticate,
&self.xsts_authorize,
@@ -95,11 +104,15 @@ mod tests {
let endpoints = MicrosoftEndpoints::consumers();
assert!(endpoints.device_code.contains("login.microsoftonline.com"));
assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token"));
// 授权码流的授权页与设备码流同族同租户(`research/08` §2:只能用 consumers)。
assert!(endpoints
.authorize
.contains("/consumers/oauth2/v2.0/authorize"));
assert!(endpoints
.xbl_authenticate
.contains("user.auth.xboxlive.com"));
assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com"));
// 授权码流的 login.live.com 不在这张表里(本期不实现)。
// 微软 OAuth 两流都不走 HMCL 的 login.live.com 端点族。
assert!(endpoints
.all()
.iter()
@@ -110,6 +123,7 @@ mod tests {
fn all_on_keeps_paths_distinct() {
let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/");
assert_eq!(endpoints.device_code, "http://127.0.0.1:1234/devicecode");
assert_eq!(endpoints.authorize, "http://127.0.0.1:1234/authorize");
assert_eq!(
endpoints.entitlements_license,
"http://127.0.0.1:1234/entitlements/license"
+2 -2
View File
@@ -223,7 +223,7 @@ pub(crate) async fn refresh_tokens(
tokens_from(&reply, &endpoints.token)
}
fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
pub(crate) fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
let access_token = reply
.text("access_token")
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?;
@@ -237,7 +237,7 @@ fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
}
/// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。
fn oauth_error(reply: &Reply, url: &str) -> AuthError {
pub(crate) fn oauth_error(reply: &Reply, url: &str) -> AuthError {
let Some(code) = reply.text("error") else {
return AuthError::network(
url.split('?').next().unwrap_or(url),
+50 -3
View File
@@ -1,25 +1,35 @@
//! 微软轨:device-code 全流程 + 会话/刷新语义(`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5)。
//! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义
//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。
//!
//! 分四块,各管一件事:
//! 分五块,各管一件事:
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token);
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
//!
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
//!
//! # client id 从哪来
//!
//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么——
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
pub mod authcode;
pub mod callback;
pub mod chain;
pub mod endpoints;
pub mod flow;
pub mod session;
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
pub use callback::CallbackServer;
pub use endpoints::MicrosoftEndpoints;
pub use flow::{DeviceCodePrompt, PollPolicy};
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
@@ -45,6 +55,7 @@ pub struct MicrosoftAuth {
client_id: String,
endpoints: MicrosoftEndpoints,
poll: PollPolicy,
authcode: AuthCodePolicy,
journal: Option<AuthJournal>,
}
@@ -62,6 +73,7 @@ impl MicrosoftAuth {
client_id: client_id.into(),
endpoints: MicrosoftEndpoints::consumers(),
poll: PollPolicy::default(),
authcode: AuthCodePolicy::default(),
journal: None,
})
}
@@ -131,6 +143,13 @@ impl MicrosoftAuth {
self
}
/// 换授权码流参数(回调超时 / 固定端口 / 绑定重试 / 固定 state;测试与 CLI 用)。
#[must_use]
pub fn with_authcode_policy(mut self, policy: AuthCodePolicy) -> Self {
self.authcode = policy;
self
}
/// 当前端点表。
#[must_use]
pub fn endpoints(&self) -> &MicrosoftEndpoints {
@@ -177,6 +196,34 @@ impl MicrosoftAuth {
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await
}
/// 授权码流登录(浏览器 + 本地回调,`research/08` §3 方案 A,GUI 的默认入口)。
///
/// `present` 在端口绑定之后、等待回调之前被调用:宿主在这一刻拉起系统默认浏览器
/// (core 不做 UI,也不假设有 GUI——CLI 可以只把 URL 打到 stderr)。
/// 失败时与 [`Self::device_code_login`] 一样往失败日志追加一条结构化记录(M38)。
///
/// # Errors
///
/// 端口绑不上/网络失败、等回调超时、用户在授权页取消、OAuth 拒绝,以及整链的
/// [`AuthError`](含白名单未批时 `login_with_xbox` 的 403 诊断)。
pub async fn authcode_login<F>(&self, present: F) -> AuthResult<MicrosoftSession>
where
F: FnOnce(&AuthCodePrompt),
{
let result = authcode::login(
&self.client,
&self.endpoints,
&self.client_id,
self.authcode.clone(),
present,
)
.await;
if let Err(error) = &result {
self.record_failure("authcode_login", error);
}
result
}
/// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。
///
/// # Errors
+6 -3
View File
@@ -1,4 +1,4 @@
//! 账户体系:三轨(离线 UUID / 微软 device-code / authlib-injector)、凭据存储边界。
//! 账户体系:三轨(离线 UUID / 微软 device-code 与授权码 / authlib-injector)、凭据存储边界。
//!
//! 设计定稿:`DevDocs/PlanDoc/07-accounts.md`(决策 D9/Q21),协议细节来自
//! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。
@@ -19,7 +19,8 @@
//! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象);
//! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`);
//! - [`offline`]:HMCL 兼容离线 UUID + 启动占位;
//! - [`microsoft`]:device-code 全链(XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定;
//! - [`microsoft`]:微软两条登录流(device-code / 授权码+本地回调)+ 共用后链
//! (XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定;
//! - [`authlib`]:标准 Yggdrasil 协议(authenticate/refresh/validate/invalidate,账户侧);
//! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期);
//! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38);
@@ -47,6 +48,8 @@ pub use account::{AccountId, AccountKind, AccountMeta, Credentials};
pub use body::{BodyKind, ResponseDetail};
pub use error::{AuthError, AuthResult};
pub use journal::{AuthFailureEvent, AuthJournal};
pub use microsoft::{MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason};
pub use microsoft::{
AuthCodePolicy, AuthCodePrompt, MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason,
};
pub use secret::Secret;
pub use store::{AccountStore, AuthPaths};
+64 -8
View File
@@ -186,13 +186,24 @@ pub fn dedupe(runtimes: Vec<JavaRuntime>) -> Vec<JavaRuntime> {
kept
}
/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。
/// 去重键:剥掉 Windows 的 `\\?\` 前缀、分隔符一律折成 `/`、大小写只在 Windows 折叠。
///
/// 前两步与平台无关:同一条 `bin/java[.exe]` 用 `/` 还是 `\` 拼出来都是同一个文件,
/// 键必须相同——`JAVA_HOME` 的写法与扫盘拼出来的路径常在这点上不一致,键不齐就重复列。
/// 大小写折叠**只能**跟着平台走:NTFS 不区分大小写,而 POSIX 上 `Java` 与 `java` 是两个
/// 目录,折了会把两份真实安装误并成一个。
///
/// 代价(已知且有意的取舍):POSIX 上文件名里的字面反斜杠也会被当分隔符。JDK 安装路径带
/// 反斜杠属于病态情况,换来的是「同一条 java 的两种拼法永远同键」这条平台无关的不变量。
#[must_use]
pub fn path_key(path: &Path) -> String {
let text = path.display().to_string();
let text = text.strip_prefix(r"\\?\").unwrap_or(&text).to_string();
let text = text
.strip_prefix(r"\\?\")
.unwrap_or(&text)
.replace('\\', "/");
if cfg!(windows) {
text.replace('/', "\\").to_ascii_lowercase()
text.to_ascii_lowercase()
} else {
text
}
@@ -236,11 +247,56 @@ mod tests {
}
#[test]
fn path_key_normalises_separators() {
let left = path_key(std::path::Path::new("C:/a/b/java.exe"));
let right = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
assert_eq!(left, right);
assert!(!path_key(std::path::Path::new(r"\\?\C:\a\java.exe")).starts_with(r"\\"));
fn path_key_normalises_separators_on_every_platform() {
// 平台无关的不变量:同一条 `bin/java` 用 `/` 还是 `\` 拼出来,键必须相同。
// (M49:原来这里只在 Windows 上成立,Linux 上 `\` 原样留着 → 断言挂。)
let forward = path_key(std::path::Path::new("C:/a/b/java.exe"));
let backward = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
assert_eq!(forward, backward);
assert!(
!forward.contains('\\'),
"键里不该残留反斜杠(与平台无关):{forward}"
);
assert!(
!forward.starts_with(r"\\"),
"`\\\\?\\` 前缀不进键:{forward}"
);
}
#[test]
fn one_install_normalises_to_one_key_whatever_the_spelling() {
// 回归钉子:`JAVA_HOME` 与扫盘常对同一个 java 给出不同写法,四种拼法必须同键。
let spellings = [
"C:/Program Files/Java/jdk-21/bin/java.exe",
r"C:\Program Files\Java\jdk-21\bin\java.exe",
r"\\?\C:\Program Files\Java\jdk-21\bin\java.exe",
r"C:\Program Files\Java\jdk-21/bin/java.exe",
];
let key = path_key(std::path::Path::new(spellings[0]));
for spelling in spellings {
assert_eq!(
path_key(std::path::Path::new(spelling)),
key,
"同一安装的两种写法必须同键:{spelling}"
);
}
}
#[test]
fn path_key_folds_case_only_where_the_filesystem_does() {
// 大小写折叠跟平台走,两侧都钉住:NTFS 不区分大小写 → 折;
// POSIX 上 `Java` 与 `java` 是两个目录 → 不折(折了会把两份真实安装误并)。
let upper = path_key(std::path::Path::new(
"C:/Program Files/Java/jdk-21/bin/java.exe",
));
let lower = path_key(std::path::Path::new(
"c:/program files/java/jdk-21/bin/java.exe",
));
if cfg!(windows) {
assert_eq!(upper, lower, "Windows:大小写不同的同一路径应同键");
} else {
assert_ne!(upper, lower, "POSIX:大小写是有效区别,不应同键");
}
}
#[test]
+9
View File
@@ -81,6 +81,15 @@ impl RunningProcess {
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
process.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// 与 Windows 的 CREATE_NEW_PROCESS_GROUP 对称:子进程自成一个进程组,
// `tree_kill` 的 `kill -- -<pgid>` 才能覆盖整棵子树。`process_group` 是
// std 1.64+ 的**安全** API,不需要 pre_exec / unsafe(旧注释说“unsafe 被挡”已过时)。
// 参数 0 = `pgid = 子进程 pid`。
process.process_group(0);
}
let mut child = process.spawn().map_err(|source| LaunchError::Spawn {
program: command.program.clone(),
+100 -21
View File
@@ -6,27 +6,39 @@
//! - Windows:`taskkill /T /F /PID`(`/T` 递归子树)。P0 不引入 `windows-sys`,因此没有
//! Job Object——Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)留 P1,替换点就在本文件
//! (tower 已批准,见 `launch-接口约定.md` §7-1)。
//! - POSIX:`kill -TERM -<pid>`(命不中是常态,因为我们没有 `setsid`——`unsafe` pre_exec 被
//! workspace `unsafe_code = deny` 挡住)→ 宽限 → `kill -KILL -<pid>`,再退到单 pid。
//! - POSIX:子进程由 [`crate::launch::process`] 用 `CommandExt::process_group(0)` 放进**自己的**
//! 进程组(与 Windows 的 `CREATE_NEW_PROCESS_GROUP` 对称,std 1.64+ 的安全 API),因此
//! `kill -TERM -- -<pgid>` 一次覆盖整棵子树 → 宽限 → `kill -KILL -- -<pgid>`,再补一发单 pid。
//!
//! 两个平台都先尝试“温和”,宽限期后再“强制”。
//!
//! # 两条来自 CI 的硬约束
//!
//! 1. **组杀必须先验组**([`group_kill_target`]):目标进程必须是它自己进程组的**组长**,
//! 且该组不能是我们所在的组。否则一次 `kill -- -<pgid>` 会把测试进程(CI 容器里全体进程
//! 常常共用 pgid 1)一起带走——M48 之前 `test (ubuntu-latest)` 就是这么被 SIGTERM 打断的。
//! 验不过就只发单 pid。
//! 2. **`--` 不能省**:procps-ng 的 `kill` 会把裸 `-<pgid>` 当成又一个信号选项吞掉,**退出码 0
//! 却什么都没发**(procps-ng 4.0.2 实测)。旧实现把这种“假成功”当组杀成功直接返回,
//! 于是单 pid 兜底永远不执行,`stop()` 只能干等超时——`stop_kills_process_tree` 与
//! `pipeline_stop_records_killed_exit` 两条红灯的真正死因。
//!
//! ⚠️ 外部命令的退出码**不能**用来判断“进程是否已被结束”:温和阶段成功关掉进程后,
//! 再发 `taskkill /F` 会得到 128(“没有这个运行实例”)。所以 [`crate::launch::process`]
//! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。
use std::io;
use std::process::Command;
use std::thread;
use std::time::Duration;
/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。
pub fn terminate(pid: u32) -> io::Result<()> {
check_pid(pid)?;
imp::signal(pid, false)
}
/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。
pub fn force_kill(pid: u32) -> io::Result<()> {
check_pid(pid)?;
imp::signal(pid, true)
}
@@ -34,17 +46,70 @@ pub fn force_kill(pid: u32) -> io::Result<()> {
///
/// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用
/// [`crate::launch::process::RunningProcess::stop`]。
pub fn kill_tree(pid: u32, grace: Duration) -> io::Result<()> {
if pid == 0 {
return Err(io::Error::other("无效的 pid 0"));
}
pub fn kill_tree(pid: u32, grace: std::time::Duration) -> io::Result<()> {
check_pid(pid)?;
let _ = terminate(pid);
if !grace.is_zero() {
thread::sleep(grace);
std::thread::sleep(grace);
}
force_kill(pid)
}
/// `pid` 0 意味着“当前进程组”(POSIX `kill` 的 `-0`)——绝不能顺手发出去。
fn check_pid(pid: u32) -> io::Result<()> {
if pid == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"无效的 pid 0:拒绝向当前进程组发信号",
));
}
Ok(())
}
/// 允许组杀的进程组号;`None` = 只能发单 pid。
///
/// 三重校验,任一不过就退回单 pid:
/// 1. 目标必须是它自己进程组的组长(`pgid == pid`)——否则 `-<pgid>` 指的不是“它的”子树;
/// 2. 必须能读到我们自己所在的进程组;
/// 3. 目标组不能就是我们所在的组——否则信号会打到自己人身上。
#[cfg(unix)]
pub fn group_kill_target(pid: u32) -> Option<u32> {
if pid == 0 {
return None;
}
let target = pgid_of(pid)?;
if target != pid {
return None;
}
let own = pgid_of(std::process::id())?;
(target != own).then_some(target)
}
/// 读 `pid` 所在进程组号。Linux 走 `/proc`,其它 POSIX 用 `ps`;读不到就是 `None`(不发组杀)。
#[cfg(target_os = "linux")]
fn pgid_of(pid: u32) -> Option<u32> {
// `/proc/<pid>/stat`: `pid (comm) state ppid pgrp ...`,`comm` 可能含空格和括号,
// 所以从**最后一个** `)` 之后开始数字段。
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let tail = &stat[stat.rfind(')')? + 1..];
let mut fields = tail.split_whitespace();
fields.next()?; // state
fields.next()?; // ppid
fields.next()?.parse().ok() // pgrp
}
#[cfg(all(unix, not(target_os = "linux")))]
fn pgid_of(pid: u32) -> Option<u32> {
let output = Command::new("ps")
.args(["-o", "pgid=", "-p", &pid.to_string()])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}
#[cfg(windows)]
mod imp {
use super::*;
@@ -79,28 +144,42 @@ mod imp {
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
let flag = if force { "-KILL" } else { "-TERM" };
// 先按进程组(`-<pid>`);没有 setsid 时通常失败,再退到单 pid。
if run_kill(flag, &format!("-{pid}"))?.is_some() {
return Ok(());
let mut delivered = false;
// 验过的独立进程组:一次覆盖组长 + 它的全部后代。
if let Some(pgid) = group_kill_target(pid) {
let group = format!("-{pgid}");
delivered |= run_kill(flag, &["--", &group])?;
}
match run_kill(flag, &pid.to_string())? {
Some(status) if status.success() => Ok(()),
Some(status) => Err(io::Error::other(format!("kill 退出码 {status}"))),
None => Err(io::Error::other("找不到 kill 命令")),
// 单 pid 兜底永远执行:组杀不可用/进程已不在组里时靠它。
let single = pid.to_string();
delivered |= run_kill(flag, &[&single])?;
if delivered {
Ok(())
} else {
// 常见于“进程已经退出”,不值得中断关闭流程;但绝不谎报成功。
Err(io::Error::other(format!(
"kill {flag} {pid} 没有送达(进程可能已退出)"
)))
}
}
fn run_kill(flag: &str, target: &str) -> io::Result<Option<std::process::ExitStatus>> {
/// 发一发 `kill <flag> <args...>`,返回是否送达。
///
/// `kill` 不存在时给**明确**错误而不是静默失败:否则调用方只会看到“进程仍在运行”,
/// 排查方向会被带偏(M48 之前的红灯就是这么来的)。
fn run_kill(flag: &str, args: &[&str]) -> io::Result<bool> {
match Command::new("kill")
.arg(flag)
.arg(target)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(status) if status.success() => Ok(Some(status)),
Ok(_) => Ok(None),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
Ok(status) => Ok(status.success()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Err(io::Error::new(
io::ErrorKind::NotFound,
"找不到 kill 命令:POSIX 进程树回收依赖 procps(Linux)或系统自带 kill(macOS/BSD)",
)),
Err(err) => Err(err),
}
}
+2
View File
@@ -4,6 +4,7 @@
//! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量;
//! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理;
//! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr);
//! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断);
//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38);
//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义;
//! - `authlib_api`:Yggdrasil 四动作的报文级对接。
@@ -12,6 +13,7 @@ mod support;
mod auth_failure_journal;
mod authlib_api;
mod microsoft_authcode;
mod microsoft_diagnostics;
mod microsoft_flow;
mod microsoft_refresh;
@@ -0,0 +1,295 @@
//! 授权码流(`research/08` §3 方案 A):本地回调服务器 + 授权 URL + code 换链。
//!
//! 覆盖验收口径:授权码换到 MS token → XBL → XSTS 全通;白名单未批前
//! `login_with_xbox` 的 403 只要求**诊断文案正确**(M38 版),不绕过。
use std::time::Duration;
use serde_json::json;
use unlucky_core::auth::microsoft::{
AuthCodePolicy, AuthCodePrompt, CallbackServer, MicrosoftAuth, MicrosoftEndpoints,
};
use unlucky_core::auth::AuthError;
use crate::support::{full_routes, response_of, AuthMock, MockReply};
const CLIENT_ID: &str = "test-client-id";
fn policy(state: &str, timeout_ms: u64) -> AuthCodePolicy {
AuthCodePolicy {
callback_timeout: Duration::from_millis(timeout_ms),
bind_attempts: 3,
port: None,
state: Some(state.to_string()),
}
}
/// 模拟浏览器:把 `code` 送到本地回调地址(真发一次 HTTP,走 localhost 解析)。
async fn deliver(redirect_uri: &str, query: &str) {
let url = format!("{redirect_uri}?{query}");
// 回调服务器可能在 accept 中,给一拍让绑定先落地。
tokio::time::sleep(Duration::from_millis(10)).await;
let response = reqwest::get(&url).await.expect("浏览器回调");
assert_eq!(response.status(), 200, "回调页应回 200");
let page = response.text().await.expect("回调页正文");
assert!(page.contains("登录完成"), "回调页应是给用户看的完成页");
}
#[tokio::test]
async fn authcode_login_completes_the_chain() {
let mock = AuthMock::start(full_routes()).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_authcode_policy(policy("state-abc", 5_000));
let mut prompted: Option<AuthCodePrompt> = None;
let session = auth
.authcode_login(|prompt| {
prompted = Some(prompt.clone());
let redirect = prompt.redirect_uri.clone();
tokio::spawn(
async move { deliver(&redirect, "code=the-auth-code&state=state-abc").await },
);
})
.await
.expect("授权码登录成功");
// 1. 宿主拿到的是「授权 URL + 本地回调地址」,两者端口必须一致。
let prompt = prompted.expect("必须回调展示授权 URL");
let redirect = prompt.redirect_uri.clone();
assert!(redirect.starts_with("http://localhost:"), "{redirect}");
assert!(redirect.ends_with("/auth"), "{redirect}");
assert!(prompt.authorization_url.contains("prompt=select_account"));
// 端点表在测试里整体指向 mock,所以授权页的路径是 `/authorize`(租户口径见向量单测)。
assert!(
prompt.authorization_url.starts_with(&mock.base())
&& prompt
.authorization_url
.contains("/authorize?client_id=test-client-id"),
"{}",
prompt.authorization_url
);
assert!(prompt.authorization_url.contains("state=state-abc"));
assert_eq!(prompt.timeout, Duration::from_millis(5_000));
// 2. code 换 token 的表单:grant_type + 原样重定向 URI(URI 严格一致那条坑)。
let token_bodies = mock.bodies_on("/token");
assert_eq!(token_bodies.len(), 1, "只该换一次 token");
let body = &token_bodies[0];
assert!(body.contains("grant_type=authorization_code"), "{body}");
assert!(body.contains("code=the-auth-code"), "{body}");
assert!(
body.contains("redirect_uri=http%3A%2F%2Flocalhost%3A") && !body.contains(&redirect),
"redirect_uri 必须是编码后的同一字面量:{body}"
);
assert!(
body.contains("scope=XboxLive.signin+offline_access")
|| body.contains("scope=XboxLive.signin%20offline_access"),
"{body}"
);
// 授权 URL 里的 redirect_uri 与 token 请求里的必须是同一个端口。
let encoded = redirect.replace(':', "%3A").replace('/', "%2F");
assert!(
prompt.authorization_url.contains(&encoded),
"{}",
prompt.authorization_url
);
assert!(body.contains(&encoded), "{body}");
// 3. 后链与会话:与设备码流同一套字段语义。
assert_eq!(session.profile_name, "Steve");
assert_eq!(session.access_token.expose(), "mc-access-token-value");
assert_eq!(session.refresh_token.expose(), "live-refresh-token-value");
assert!(!format!("{session:?}").contains("mc-access-token-value"));
assert_eq!(mock.bodies_on("/xbl").len(), 1);
assert_eq!(mock.bodies_on("/xsts").len(), 1);
mock.stop();
}
#[tokio::test]
async fn callback_server_ignores_unrelated_requests_and_bad_state() {
let server = CallbackServer::bind(None, 1, "state-ok")
.await
.expect("绑定");
let port = server.port();
let browser = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let base = format!("http://localhost:{port}");
// favicon / 浏览器预热:404 但绝不能结束等待。
let favicon = reqwest::get(format!("{base}/favicon.ico"))
.await
.expect("favicon");
assert_eq!(favicon.status(), 404);
// state 不匹配(旧标签页的迟到回调):400,继续等。
let stale = reqwest::get(format!("{base}/auth?code=stale-code&state=state-old"))
.await
.expect("迟到的回调");
assert_eq!(stale.status(), 400);
let good = reqwest::get(format!("{base}/auth?code=good-code&state=state-ok"))
.await
.expect("真回调");
assert_eq!(good.status(), 200);
});
let code = server
.wait_for_code(Duration::from_secs(5))
.await
.expect("应拿到 code");
assert_eq!(code, "good-code");
browser.await.expect("模拟浏览器任务");
}
#[tokio::test]
async fn foreign_state_cannot_cancel_the_login() {
// P2 回归:回调先校验 state,再处理 error(RFC 6749 §4.1.2.1 / RFC 9207)。
// 修之前 `GET /auth?error=access_denied`(state 随便给或不给)就能把登录判成 Canceled。
let server = CallbackServer::bind(None, 1, "state-ok")
.await
.expect("绑定");
let port = server.port();
let browser = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let base = format!("http://localhost:{port}/auth");
// 1. 连 state 都不给:与本次登录无关 → 400,继续等。
let bare = reqwest::get(format!("{base}?error=access_denied"))
.await
.expect("伪造取消");
assert_eq!(bare.status(), 400);
// 2. state 明显不匹配:同样忽略,绝不当成用户取消。
let foreign = reqwest::get(format!("{base}?error=access_denied&state=not-our-state"))
.await
.expect("伪造取消带错 state");
assert_eq!(foreign.status(), 400);
// 3. 真正带对 state 的回调仍然能完成登录。
let good = reqwest::get(format!("{base}?code=good-code&state=state-ok"))
.await
.expect("真回调");
assert_eq!(good.status(), 200);
});
let code = server
.wait_for_code(Duration::from_secs(5))
.await
.expect("伪造的 access_denied 不能中止登录");
assert_eq!(code, "good-code");
browser.await.expect("模拟浏览器任务");
}
#[tokio::test]
async fn access_denied_with_our_state_is_still_canceled() {
// 另一半语义:用户真的在授权页点了取消(带本机 state)→ 仍然是 Canceled。
let server = CallbackServer::bind(None, 1, "state-ok")
.await
.expect("绑定");
let port = server.port();
let browser = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let url = format!("http://localhost:{port}/auth?error=access_denied&state=state-ok");
let _ = reqwest::get(url).await;
});
let error = server
.wait_for_code(Duration::from_secs(5))
.await
.expect_err("本机 state 的取消要生效");
assert_eq!(error, AuthError::Canceled);
browser.await.expect("模拟浏览器任务");
}
#[tokio::test]
async fn callback_timeout_is_reported() {
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_authcode_policy(policy("state-abc", 30));
let error = auth
.authcode_login(|_prompt| {})
.await
.expect_err("没人回调就该超时");
assert_eq!(error.code(), "timeout");
assert!(
error.message().contains("浏览器回调"),
"{}",
error.message()
);
}
#[tokio::test]
async fn access_denied_on_the_callback_page_is_canceled() {
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_authcode_policy(policy("state-abc", 5_000));
let error = auth
.authcode_login(|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
let url = format!("{redirect}?error=access_denied&state=state-abc");
let _ = reqwest::get(&url).await;
});
})
.await
.expect_err("用户取消");
assert_eq!(error, AuthError::Canceled);
}
#[tokio::test]
async fn busy_port_retries_then_reports_which_port() {
// 先占住一个端口,再让回调服务器去绑同一个:必须失败并说清是哪个端口。
let occupied = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("占端口");
let port = occupied.local_addr().expect("地址").port();
let error = CallbackServer::bind(Some(port), 2, "s")
.await
.expect_err("端口已被占");
assert_eq!(error.code(), "network");
assert!(
error.message().contains(&port.to_string()),
"文案要指出端口:{}",
error.message()
);
assert!(error.message().contains("重试 2 次"), "{}", error.message());
// 同一台机器上换个端口就能绑上(重试机制不是把错误吞掉)。
let ok = CallbackServer::bind(None, 1, "s").await.expect("内核分配");
assert_ne!(ok.port(), port);
}
#[tokio::test]
async fn login_with_xbox_403_keeps_the_m38_diagnostic() {
// 白名单未批的真机状态:授权码换到 token、XBL/XSTS 全通,最后一步 403。
let mut routes = full_routes();
let (_, login) = routes
.iter_mut()
.find(|(path, _)| *path == "/login_with_xbox")
.expect("路由表里有 login_with_xbox");
*login = vec![MockReply::json_status(
403,
json!({ "path": "/authentication/login_with_xbox", "errorType": "FORBIDDEN",
"error": "Invalid app registration" }),
)];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_authcode_policy(policy("state-abc", 5_000));
let error = auth
.authcode_login(|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move { deliver(&redirect, "code=c&state=state-abc").await });
})
.await
.expect_err("403 必须冒出来");
let detail = response_of(&error);
assert_eq!(detail.status, 403);
assert_eq!(detail.detail_code(), "login_with_xbox_403");
// 「授权码这条流本身是通的」——这正是验收止步的位置。
assert_eq!(mock.bodies_on("/token").len(), 1);
assert_eq!(mock.bodies_on("/xbl").len(), 1);
assert_eq!(mock.bodies_on("/xsts").len(), 1);
let message = error.message();
assert!(message.contains("Minecraft 登录被拒"), "{message}");
assert!(message.contains("Invalid app registration"), "{message}");
mock.stop();
}
+8 -5
View File
@@ -96,17 +96,20 @@ fn scanned_java_is_still_used_when_nothing_else_matches() {
#[test]
fn dedupe_collapses_one_install_found_twice() {
// `JAVA_HOME` 指向 Program Files 里的 JDK → 同一个 java 不应列两次。
// 第二条的「另一种写法」随平台给:Windows 上大小写和分隔符都可能不一致(NTFS 不区分
// 大小写),POSIX 上大小写是有效区别,只能靠分隔符拼出同一路径的第二种写法。
let duplicate = if cfg!(windows) {
"c:/program files/java/jdk-21/bin/java.exe"
} else {
"C:\\Program Files\\Java\\jdk-21/bin/java.exe"
};
let candidates = discover::dedupe(vec![
runtime(
"C:/Program Files/Java/jdk-21/bin/java.exe",
21,
JavaOrigin::JavaHome,
),
runtime(
"c:/program files/java/jdk-21/bin/java.exe",
21,
JavaOrigin::System,
),
runtime(duplicate, 21, JavaOrigin::System),
]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].origin, JavaOrigin::JavaHome);
+1
View File
@@ -13,3 +13,4 @@ mod process_smoke;
mod pump_buffer;
mod session_events;
mod sink_cases;
mod tree_kill_cases;
+40
View File
@@ -111,6 +111,46 @@ fn stop_kills_process_tree() {
assert!(!child.is_running());
}
#[cfg(target_os = "linux")]
#[test]
fn spawn_puts_child_in_its_own_process_group() {
/// `/proc/<pid>/stat` 里的 `pgrp` 字段。
fn pgid(pid: u32) -> u32 {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).expect("读 /proc stat");
let tail = &stat[stat.rfind(')').expect("stat 应有 comm 括号") + 1..];
tail.split_whitespace()
.nth(2)
.expect("stat 应有 pgrp 字段")
.parse()
.expect("pgrp 应是数字")
}
let mut child = RunningProcess::spawn(
&shell_command("sleep 30"),
None,
Arc::new(NullSink),
config(),
)
.expect("spawn 应成功");
let pid = child.pid();
let own_pgid = pgid(std::process::id());
assert_eq!(
pgid(pid),
pid,
"子进程应是自己的进程组组长(process_group(0))"
);
assert_ne!(pgid(pid), own_pgid, "子进程不能挂在测试进程的进程组上");
assert_eq!(
unlucky_core::launch::tree_kill::group_kill_target(pid),
Some(pid),
"独立组的组长才是合法的组杀目标"
);
child.stop().expect("停止应成功");
let _ = child.wait();
}
#[test]
fn spawn_reports_missing_program() {
let mut command = shell_command("echo hi");
+201
View File
@@ -0,0 +1,201 @@
//! 进程树回收的安全钉子:组杀只打“目标自己的独立进程组”,绝不命中我们所在的组。
//!
//! 这些用例钉的是 M48 的两条根因:
//! - 子进程必须自成一个进程组(`process_group(0)`),否则 `kill -- -<pgid>` 没有可用的组;
//! - 组杀前必须验组([`tree_kill::group_kill_target`]),否则 CI 容器里“全体进程 pgid=1”
//! 的那种环境会被一发组杀连测试进程一起带走。
//!
//! `/proc` 只有 Linux 有,进程组相关的用例都用 `#[cfg(target_os = "linux")]` 门住;
//! `pid == 0` 的守卫与平台无关,各平台都跑。
use std::time::Duration;
use unlucky_core::launch::tree_kill;
/// `pid` 0 在 POSIX `kill` 里表示“当前进程组”——绝不能顺手发出去。
#[test]
fn pid_zero_is_rejected() {
assert!(tree_kill::terminate(0).is_err(), "terminate(0) 必须拒绝");
assert!(tree_kill::force_kill(0).is_err(), "force_kill(0) 必须拒绝");
assert!(
tree_kill::kill_tree(0, Duration::ZERO).is_err(),
"kill_tree(0) 必须拒绝"
);
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use std::process::{Child, Command, Stdio};
use unlucky_core::launch::tree_kill::group_kill_target;
/// `/proc/<pid>/stat` 里 `)` 之后的字段(state ppid pgrp ...)。
fn stat_tail(pid: u32) -> Option<Vec<String>> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let tail = stat.get(stat.rfind(')')? + 1..)?;
Some(tail.split_whitespace().map(str::to_string).collect())
}
fn pgid(pid: u32) -> Option<u32> {
stat_tail(pid)?.get(2)?.parse().ok()
}
/// 还活着(僵尸不算:已经死了只是没人 wait)。
fn running(pid: u32) -> bool {
matches!(stat_tail(pid).as_deref().and_then(|fields| fields.first().map(String::as_str)), Some(state) if state != "Z")
}
fn wait_gone(pid: u32, budget: Duration) -> bool {
let deadline = std::time::Instant::now() + budget;
while std::time::Instant::now() < deadline {
if !running(pid) {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
!running(pid)
}
/// 组长的子进程(`process_group(0)`):自带一个组。
fn spawn_grouped(script: &str) -> Child {
use std::os::unix::process::CommandExt;
Command::new("/bin/sh")
.arg("-c")
.arg(script)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn()
.expect("spawn 应成功")
}
/// 和测试进程同组的子进程(不调 `process_group`):组长不是它。
fn spawn_in_our_group(script: &str) -> Child {
Command::new("/bin/sh")
.arg("-c")
.arg(script)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn 应成功")
}
/// 测试失败时也别把 `sleep 30` 留在机器上。
struct KillOnDrop(u32);
impl Drop for KillOnDrop {
fn drop(&mut self) {
let _ = tree_kill::force_kill(self.0);
}
}
/// 自己的进程组号:组杀安全的基准。
fn self_pgid() -> u32 {
pgid(std::process::id()).expect("/proc/self/stat 应可读")
}
#[test]
fn group_kill_target_accepts_only_a_verified_own_group() {
let own_pgid = self_pgid();
// 我们自己所在的组永远不是合法目标(哪怕我们正好是组长)。
assert_eq!(
group_kill_target(std::process::id()),
None,
"自己的进程号绝不能成为组杀目标"
);
assert_eq!(
group_kill_target(own_pgid),
None,
"自己的进程组绝不能成为组杀目标"
);
assert_eq!(group_kill_target(0), None, "pid 0 不是合法目标");
// 同组的子进程:它不是组长,`-<pid>` 指向的不是“它的”组。
let mut child = spawn_in_our_group("sleep 30");
let pid = child.id();
let _guard = KillOnDrop(pid);
assert_eq!(pgid(pid), Some(own_pgid), "同组子进程应继承我们的 pgid");
assert_eq!(
group_kill_target(pid),
None,
"非组长子进程不能走组杀(`-<pid>` 会打到别人的组)"
);
// 独立组的子进程:合法的组杀目标。
let mut grouped = spawn_grouped("sleep 30");
let grouped_pid = grouped.id();
let _grouped_guard = KillOnDrop(grouped_pid);
assert_eq!(pgid(grouped_pid), Some(grouped_pid), "子进程应是组长");
assert_eq!(
group_kill_target(grouped_pid),
Some(grouped_pid),
"独立组的组长可以组杀"
);
let _ = child.kill();
let _ = child.wait();
let _ = grouped.kill();
let _ = grouped.wait();
}
#[test]
fn group_kill_reaps_grandchildren_without_touching_our_group() {
let mut child = spawn_grouped("sleep 30 & sleep 30");
let pid = child.id();
let _guard = KillOnDrop(pid);
std::thread::sleep(Duration::from_millis(300));
let children_of = || -> Vec<u32> {
std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children"))
.unwrap_or_default()
.split_whitespace()
.filter_map(|value| value.parse().ok())
.collect()
};
let grandchildren = children_of();
assert!(
!grandchildren.is_empty(),
"shell 应当先 fork 出一个 sleep 子进程:{grandchildren:?}"
);
for grandchild in &grandchildren {
assert_eq!(pgid(*grandchild), Some(pid), "后代必须落在子进程自己的组里");
}
tree_kill::terminate(pid).expect("组杀应送达");
assert!(wait_gone(pid, Duration::from_secs(3)), "组长应被组杀带走");
for grandchild in &grandchildren {
assert!(
wait_gone(*grandchild, Duration::from_secs(3)),
"组杀应覆盖后代 {grandchild}"
);
}
// 组杀没有溢出:我们自己还在原来的组里,而且没有被信号带走。
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
assert_ne!(pid, self_pgid(), "目标组和我们的组必须不同");
let _ = child.wait();
}
#[test]
fn terminate_still_kills_a_child_that_shares_our_group() {
let own_pgid = self_pgid();
let mut child = spawn_in_our_group("sleep 30");
let pid = child.id();
let _guard = KillOnDrop(pid);
assert_eq!(pgid(pid), Some(own_pgid));
tree_kill::terminate(pid).expect("单 pid 兜底应送达");
assert!(
wait_gone(pid, Duration::from_secs(3)),
"同组子进程也必须能被单 pid TERM 干掉"
);
// 走到这里本身就说明“组杀没有命中自身进程组”——否则测试进程已被 TERM。
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
let _ = child.wait();
}
}
+267
View File
@@ -0,0 +1,267 @@
# 打包与发布工程(packaging/)
单一真相源:**单文件产物矩阵 + 发布契约**。`.github/workflows/release.yml` 按本文实现,
`packaging/make_artifacts.py` 是本地与 CI **共用**的打包脚本(同一份代码 → 同一套目录布局)。
本目录只放发布工程资产(脚本 / 说明 / 发布说明模板),不参与任何 crate 的编译。
## 1. 产物矩阵
| 槽位 | 产物文件名 | 形态 | 归档内容 |
|------|-----------|------|---------|
| `windows-x64` | `unlucky-launcher-<ver>-windows-x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 |
| `linux-x64` | `unlucky-launcher-<ver>-linux-x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md` |
| `macos-arm64` | `unlucky-launcher-<ver>-macos-arm64.tar.gz` | 单二进制归档 | 同上 |
| `macos-x64` | `unlucky-launcher-<ver>-macos-x64.tar.gz` | 单二进制归档 | 同上 |
| 全平台 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
约定细节:
- **"单文件"指可执行文件本身零运行时依赖**(无 WebView、无 JRE、无 sidecar 资源目录、无 DLL 旁挂)。
归档里额外放三份合规文本是许可义务要求,不是运行时依赖;Windows 因为"一个 exe 装不下文本",
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
- 归档解压后是**一个顶层目录** `unlucky-launcher-<ver>-<槽位>/`,用户不会解压出一堆散文件。
- `<ver>` 不带前导 `v`(tag 是 `v0.1.0`,文件名是 `0.1.0`)——由脚本统一裁剪。
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
同源构建的 sha256 不受打包时刻影响。
## 2. 版本来源(唯一真相源)
版本号只有一个来源:**`Cargo.toml` 的 `[workspace.package] version`**。
- `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件),
四个 workspace member 版本不一致时直接失败。
- 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验,
**不一致即失败**——避免出现 tag 说 0.2.0、产物里写 0.1.0 的发布事故。
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
## 3. 怎么构建
### 本地(当前平台)
```bash
cargo build --release --locked # 产出 target/release/{unlucky-launcher,ull}
python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt
python packaging/make_artifacts.py --version v0.1.0 # 顺带校验版本一致性
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
```
脚本按宿主自动判定槽位(Windows x64 / Linux x64 / macOS arm64 / macOS x64),
其它组合直接报错退出——不在矩阵里的平台不出"凑合能跑"的产物。
### CI(三平台矩阵)
`.github/workflows/release.yml`:
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布(验证矩阵仍编得过)。
`inputs.prerelease` **只在 tag ref 上 dispatch 才可达**——Actions 页面的 "Run workflow" 只能选分支,
要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。分支 ref 的 dispatch 与 push 一样被
`release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以「dispatch 只构建不发布」
与「dispatch 能关 pre-release」是两条互不冲突的规则。
- 矩阵:`windows-latest`(MSVC + `-C target-feature=+crt-static`)、`ubuntu-22.04`(glibc 2.35 地板)、
`macos-latest`(arm64)、`macos-15-intel`(Intel x64;`macos-13` 已下线,`macos-26-intel` 为更新替位)。
- 每个槽位 `cargo build --release --locked` → `make_artifacts.py` → 归档内容自检 → 上传 artifact。
- `release` job:下载全部 artifact → 重算一份全量 `SHA256SUMS.txt` → 用 `packaging/release-notes.md`
模板渲染支持矩阵 → `gh release create`(**pre-release 默认开**:首建时 `push` 触发按 pre-release,`workflow_dispatch` 可关)。
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
+ `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run,
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
`workflow_dispatch` 显式给了输入才断言——`prerelease=true` → `--prerelease`,`prerelease=false` →
`--prerelease=false`。要改标记两条路等价且互不打架:① 走流水线
`gh workflow run release.yml --ref v0.1.0 -f prerelease=false`(会落到 edit 分支显式断言)
② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑不碰该标记,
不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。
Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两者都属"标准工具链"(D21),
但发布产物要**不依赖 VC++ 可再发行包**,`+crt-static` 把 CRT 静态链进 exe,Win10+ 的 UCRT 本身是
系统组件,于是 exe 真正单文件可拷走。GNU 交叉产物在 CI 上还要拉 MinGW 链接器,收益为零。
## 4. 每平台运行时依赖
### Windows x64(Win10/11)
| 依赖 | 来源 | 说明 |
|------|------|------|
| `KERNEL32` / `USER32` / `GDI32` / `ADVAPI32` / `SHELL32` / `ole32` / `WS2_32` … | 系统 | Win10+ 一律自带 |
| `ucrtbase` / `api-ms-win-crt-*`(UCRT) | 系统 | Win10+ 内置,**不需要** VC++ 可再发行包 |
| MSVC 运行时(`VCRUNTIME140` / `MSVCP140`) | **无** | `+crt-static` 静态链接,故不需要可再发行包 |
| OpenGL(`opengl32`) | 系统 | 只在 GL 渲染路径用;缺失走软渲染 |
本机(GNU 工具链)实测导入表证据见 §7。
### Linux x64
| 依赖 | 说明 |
|------|------|
| glibc ≥ 2.35 | 由 `ubuntu-22.04` 构建决定;更老的发行版请自行重建或等 musl 版 |
| `libxkbcommon.so.0` | winit 键盘处理 |
| `libfontconfig.so.1` + `libfreetype.so.6` | 文本渲染 |
| `libGL.so.1` / `libEGL.so.1` | femtovg GL 渲染;缺失走软渲染 |
| `libxcb.so.1` / `libX11.so.6` | X11 后端(Wayland 会话下也需 XWayland 相关库) |
| `libwayland-client.so.0` | Wayland 后端 |
Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。
### macOS 12+
| 依赖 | 说明 |
|------|------|
| 系统框架(Cocoa / Metal / IOKit / Security …) | 由操作系统提供,无第三方动态库 |
| `libSystem` | 系统 |
macOS 产物是**裸二进制**而非 `.app` bundle:符合单文件承诺,但没有 Dock 图标、Finder 双击不生效,
从终端启动(README 里给了 `xattr -d com.apple.quarantine` 的 Gatekeeper 解法)。
签名/公证(需 Apple Developer 账号)与 `.app` 打包是后续工作,不做假承诺。
## 5. 软渲染兜底(无 GPU / 老驱动 / 远程桌面)
Slint 默认 `renderer-femtovg`(GL)。两条兜底路径,效果等价,任选其一:
```bash
unlucky-launcher --software-renderer # 启动参数(本仓库解析后设 SLINT_BACKEND)
SLINT_BACKEND=winit-software unlucky-launcher # 环境变量(Slint 原生开关)
```
- 参数解析在 `crates/app/src/main.rs` 的 `apply_renderer_flag()`,只认完全相等的 `--software-renderer`。
- 软渲染是 CPU 光栅化,无 GPU 也能出图;代价是动画与大窗口重绘更慢(Metro 纯色界面影响有限)。
- 验收/无交互桌面场景另有进程内截图通道:`--screenshot <png>`(见 `crates/app/src/screenshot.rs`),
它同样能在软渲染下工作,是 CI 与远程机唯一可靠的"真出图"证据。
- 发布冒烟(本机实跑,见 §7)就是用 `--software-renderer --screenshot` 组合证明"无 GPU 也能起来"。
## 6. Cargo 体积优化(release profile)
`Cargo.toml`:
```toml
[profile.release]
opt-level = "z" # 体积优先(启动器不是算力密集;UI 走 Slint 渲染,热路径不在 Rust 侧)
lto = "fat" # 全量 LTO:跨 crate 内联+死代码消除,比 thin 再小一截(实测见 §7)
codegen-units = 1 # 单 CGU,配合 LTO 拿最大优化面
panic = "abort" # 去掉 unwind 表
strip = "symbols" # 剥符号表(GNU/MSVC 路径都生效)
```
取舍:`lto = "fat"` + `codegen-units = 1` 让 release 链接明显变慢(本机全量 release 构建 10 分钟量级,
见 §7.1),换来的是体积与启动时间。变慢的根源在 `codegen-units = 1`:整个 crate 只切出一个 CGU,
LTO 也就只有一个线程可跑(`fat` 本身只管"跨 crate 全量参与",不等于单线程)。
本地开发迭代走 `cargo build`(dev profile)不受影响,但 **`ci.yml` 每次 push/PR 都跑
`cargo build --workspace --release`**,会实打实吃这份 fat LTO 的链接时间——CI 慢的一部分是 release
档构建,不是测试本身。
`panic = "abort"` 意味着 panic 不带 backtrace 栈展开——UI 层已用 `Result` 承接错误,
`unwrap` 只出现在测试里,这是可接受的取舍。
## 7. 实测(本机,不是估算)
构建环境:rustc 1.99.1 stable(host `x86_64-pc-windows-gnu`,MinGW 链接器),
`cargo build --release --locked --bin unlucky-launcher --bin ull`,12 核机器(同一时刻另有别的 worker
在做全量 debug 构建,属于偏悲观的真实负载)。产物:`target/release/unlucky-launcher.exe`(GUI,交付物)
与 `target/release/ull.exe`(CLI)。
### 7.1 体积(实测字节数,不是估算)
| release profile | `unlucky-launcher.exe` | `ull.exe` | 冷构建耗时(本机 12 核) |
|-----------------|-----------------------|-----------|-----------|
| `lto = "thin"`(改前) | 16,366,080 B = 15.61 MiB | 7,572,992 B = 7.22 MiB | 11m52s |
| `lto = "fat"`(改后,本仓库采用) | **14,595,584 B = 13.92 MiB** | **6,157,824 B = 5.87 MiB** | 9m27s(增量,依赖已编好) |
`fat` 相对 `thin` 的实测差:GUI **−1,770,496 B(−10.8%)**,CLI **−1,415,168 B(−18.7%)**。
一次全量 LTO 把跨 crate 的死代码与重复实例化一起消掉,收益远超常见印象里的"1~2%";
代价是 release 链接更慢——慢在 `codegen-units = 1`(单 CGU → LTO 单线程),不是 `fat` 本身,
且只影响发布/验收构建,日常 `cargo build` 走 dev profile 不受影响。
体积构成(按依赖树判断):Slint 运行时(femtovg + software 双渲染器 + winit/accesskit/托盘)+ rustls/reqwest
+ rig/rmcp/axum 是主要来源;`ull` 明显更小是 D18 纪律的直接结果(CLI 零 UI 框架依赖)。
### 7.2 冒烟(真跑,2026-10-31 夜间 + 11-01 早,本机 Win11 26100)
| 命令 | 实测结果 |
|------|---------|
| `ull.exe --version` | 输出 `ull 0.1.0(unlucky-cli)` + `os: Windows 11 · 10.0.26100(x86_64)· 满足最低要求`,exit 0 —— 版本与 workspace `0.1.0` 一致 |
| `unlucky-launcher.exe --software-renderer --screenshot … --data-dir …` | exit 0,产出 1180×760 PNG(3,588,298 B);看图确认 Metro 实例页(侧栏/搜索/空态/AI 助手面板)正常渲染 → **软渲染路径真的能出图**,不依赖 GL |
| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 目前不解析 `--version`,会直接开窗口。已作为 finding 上报,见 §10-1 |
### 7.3 打包验证(本机 Windows 槽位真跑)
```
smoke: ull.exe --version -> 0.1.0 (ok)
artifact: unlucky-launcher-0.1.0-windows-x64.exe 13.92 MiB sha256=d4954c62…c3570
sums: SHA256SUMS.txt
$ sha256sum -c SHA256SUMS.txt
unlucky-launcher-0.1.0-windows-x64.exe: OK
```
版本守卫也实测过:`--version v9.9.9` 时脚本以 `tag/version mismatch: requested 9.9.9, Cargo.toml says 0.1.0` 失败退出(exit 1)。
UNIX 归档的可复现性单独取证(本机 Windows 槽位只走裸 exe 分支,tar 路径要另测):
reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → **两次 sha256 完全一致**
`a99d6e9e727cd7e390434c566a15978451f340858fa89836e2f63790bbc392f8`;成员 `mtime=0`、
`uid=gid=0`、`uname/gname` 为空、`mode` 0755(二进制)/ 0644(文本),gzip header 的 mtime 字段是
`00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。
即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。
### 7.4 运行时依赖证据(PE 导入表)
`objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带:
`KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/
uxtheme/uiautomationcore/opengl32/ws2_32/bcrypt/bcryptprimitives/ntdll` + 一组 `api-ms-win-crt-*`(UCRT API set)。
**没有** `VCRUNTIME140.dll` / `MSVCP140.dll` 这类 MSVC 可再发行包依赖,也没有 `libwinpthread-1.dll` /
`libgcc_s_*.dll` 之类 MinGW 运行时 —— 即"拷走一个 exe 就能跑"(Win10+ 自带 UCRT)。
CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `dumpbin -dependents` 把这条变成硬门禁,
两侧都 fail、没有降级分支:**出现 VCRUNTIME/MSVCP/ucrtbase 就 fail**,**找不到 dumpbin 同样 fail**
(`windows-latest` 自带 VS 工具链,基本找得到;"找不到就跳过"等于让门禁在最需要它的时候失效)。
总之不允许"悄悄要用户装可再发行包"。
注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。
## 8. 发行合规
- `LICENSE`(Apache-2.0 全文)与 `NOTICE`(含 Slint Royalty-Free 2.0 义务说明)随包分发,
三份文本在 UNIX 归档内、在 Windows 侧作为并列 Release 资产。
- Slint 的 RF 2.0 义务:关于页保留 `AboutSlint` 部件(已在 UI 内),不得移除;
选 RF 分支即不触发 GPL 分支义务(见 `deny.toml` 的许可证白名单注释)。
- `README.md`(英文主版)随包分发;中文版 `README.zh-CN.md` 留在仓库不进包(避免包体噪音)。
- 依赖许可门禁不变:`deny.toml` 白名单(Apache-2.0 / MIT / BSD / Zlib / BSL-1.0 / ISC / CC0 / Unlicense /
Unicode-3.0 / CDLA-Permissive-2.0 / Slint-RF-2.0)。发布任务不新增任何依赖。
- 发布产物不含游戏本体、不含 JRE、不含任何账号数据;`ull`/GUI 的数据目录在用户侧生成。
## 9. 支持矩阵(对外口径,与 release-notes 模板逐行一致)
| 平台 | 级别 | 运行时要求 |
|------|------|-----------|
| Windows 10/11 x64 | 一等 | 无(UCRT 系统自带) |
| macOS 12+(arm64 / x64) | 一等 | 无第三方库;未签名需去 quarantine |
| Linux x64 X11 | 一等 | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb |
| Linux x64 Wayland | 支持 | 上述 + wayland-client |
| Windows 8.1/8/7 | 不支持 | 基线已上调(D21) |
| Android Termux + X11 | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 |
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版,Termux 行同样标注
experimental / not part of this release matrix)——改一边必须同步另一边,否则标题里的"一致"就是假话。
## 10. 已知边界 / 后续
1. **GUI 无 `--version`**:`unlucky-launcher.exe` 目前只解析 `--software-renderer` / `--screenshot*` /
`--data-dir` / `--mock`,`--version` 会当成普通参数忽略并直接开窗口。版本自证目前靠:
①产物名里的 `<ver>` ②`ull --version`(同 workspace 版本)③tag 交叉校验。
加一个 `--version` 分支属于 `crates/app` 范围,已作为 finding 上报,不在本 mission 内改。
2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号),
当前以 SHA256SUMS + Release 说明作为完整性参考。
3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。
4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但 Slint 在 musl 上需额外验证,未纳入本矩阵。
5. **`ull` CLI 未随包**:本次矩阵只发 GUI 单文件(用户核心卖点是启动器本体);headless 用户可从
源码 `cargo build --release --bin ull`,或在后续把 `ull` 加进同一矩阵(脚本已按槽位参数化,改动很小)。
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行
`warning: linker stderr: corrupt .drectve at end of def file`,来自 MinGW `ld` 对 `.drectve` 段的
老式抱怨,不影响产物(体积/导入表/冒烟均已实测正常);CI 的 MSVC 槽位不会出现这条。
## 11. 文件清单
| 文件 | 作用 | 行数上限 |
|------|------|---------|
| `packaging/README.md` | 本文:矩阵/依赖/版本/合规契约 | ≤400 |
| `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 |
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
| `.github/workflows/release.yml` | tag 触发 → 矩阵构建 → Release | ≤400 |
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Build the single-file release artifacts for the current platform.
Release matrix (see `packaging/README.md` for the full contract):
Windows x64 unlucky-launcher-<ver>-windows-x64.exe (bare exe, no zip)
Linux x64 unlucky-launcher-<ver>-linux-x64.tar.gz
macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz
macOS x64 unlucky-launcher-<ver>-macos-x64.tar.gz
all SHA256SUMS.txt
`--version` is taken from the cargo workspace so a tag can never drift away from
`Cargo.toml`; pass `--version` to cross-check a tag against it (mismatch is fatal).
The archives carry the launcher binary plus LICENSE / NOTICE / README.md, which is
what the license obligations ask for; Windows ships the bare `.exe` (the single-file
selling point) and the same three documents are attached to the Release next to it.
Usage:
cargo build --release --locked
python3 packaging/make_artifacts.py [--version v0.1.0] [--out dist] [--no-smoke]
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import json
import os
import platform
import shutil
import subprocess
import sys
import tarfile
from pathlib import Path
from typing import NoReturn
LAUNCHER_BIN = "unlucky-launcher"
CLI_BIN = "ull"
# 随包分发的合规文本(仓库根目录);英文主版 README 是对外分发口径。
COMPLIANCE_DOCS = ("LICENSE", "NOTICE", "README.md")
SUMS_NAME = "SHA256SUMS.txt"
REPO_ROOT = Path(__file__).resolve().parent.parent
TARGET_DIR = REPO_ROOT / "target" / "release"
def fail(message: str) -> NoReturn:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
def workspace_version() -> str:
"""Read `[workspace.package] version` through cargo itself (single source of truth)."""
try:
raw = subprocess.run(
["cargo", "metadata", "--no-deps", "--format-version", "1"],
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
# Windows 默认 GBK:cargo 的 package description 是中文,不显式指定会解码失败。
encoding="utf-8",
errors="replace",
).stdout
except (OSError, subprocess.CalledProcessError) as exc:
fail(f"cargo metadata failed: {exc}")
packages = json.loads(raw)["packages"]
versions = {pkg["version"] for pkg in packages}
if len(versions) != 1:
fail(f"workspace members disagree on version: {sorted(versions)}")
return versions.pop()
def platform_tag() -> str:
"""Map the build host to the artifact matrix slot (hard-fail on unsupported ones)."""
os_name = sys.platform
machine = platform.machine().lower()
if os_name.startswith("win") and machine in ("amd64", "x86_64"):
return "windows-x64"
if os_name.startswith("linux") and machine in ("x86_64", "amd64"):
return "linux-x64"
if os_name == "darwin" and machine in ("arm64", "aarch64"):
return "macos-arm64"
if os_name == "darwin" and machine in ("x86_64", "amd64"):
return "macos-x64"
fail(f"unsupported build host {os_name}/{machine}; matrix is windows-x64 / linux-x64 / macos-arm64 / macos-x64")
def binary(name: str) -> Path:
path = TARGET_DIR / (f"{name}.exe" if sys.platform.startswith("win") else name)
if not path.is_file():
fail(f"missing {path}; run `cargo build --release --locked` first")
return path
def smoke_version(cli: Path, version: str) -> None:
"""`ull --version` must report the workspace version (guards tag/version drift)."""
proc = subprocess.run(
[str(cli), "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
out = (proc.stdout or "") + (proc.stderr or "")
if proc.returncode != 0:
fail(f"`{cli.name} --version` exited {proc.returncode}: {out.strip()}")
if version not in out:
fail(f"`{cli.name} --version` does not report {version}: {out.strip()}")
print(f"smoke: {cli.name} --version -> {version} (ok)")
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_sums(out_dir: Path, assets: list[Path]) -> Path:
sums = out_dir / SUMS_NAME
lines = [f"{sha256(path)} {path.name}" for path in sorted(assets)]
sums.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
return sums
def stage_archive(out_dir: Path, tag: str, version: str, launcher: Path) -> Path:
"""tar.gz with one top-level dir holding the binary + compliance docs."""
stem = f"unlucky-launcher-{version}-{tag}"
stage = out_dir / stem
if stage.exists():
shutil.rmtree(stage)
stage.mkdir(parents=True)
shutil.copy2(launcher, stage / launcher.name)
for doc in COMPLIANCE_DOCS:
source = REPO_ROOT / doc
if not source.is_file():
fail(f"compliance file {source} is missing")
shutil.copy2(source, stage / doc)
# tar 内的 mtime/uid/mode 与 gzip 头的 mtime 全部归零 → 同源构建的 sha256 与打包时刻无关。
archive = out_dir / f"{stem}.tar.gz"
with archive.open("wb") as raw, gzip.GzipFile(
fileobj=raw, mode="wb", compresslevel=9, mtime=0
) as gz, tarfile.open(fileobj=gz, mode="w") as tar:
for entry in sorted(stage.iterdir()):
info = tar.gettarinfo(str(entry), arcname=f"{stem}/{entry.name}")
info.mtime = 0
info.uid = info.gid = 0
info.uname = info.gname = ""
info.mode = 0o755 if entry.name == launcher.name else 0o644
with entry.open("rb") as handle:
tar.addfile(info, handle)
shutil.rmtree(stage)
return archive
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", help="expected version (a leading 'v' is stripped); cross-checks Cargo.toml")
parser.add_argument("--out", default="dist", help="output directory (default: dist/)")
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
args = parser.parse_args()
version = workspace_version()
if args.version:
expected = args.version[1:] if args.version.startswith("v") else args.version
if expected != version:
fail(f"tag/version mismatch: requested {expected}, Cargo.toml says {version}")
tag = platform_tag()
launcher = binary(LAUNCHER_BIN)
cli = binary(CLI_BIN)
if not args.no_smoke:
smoke_version(cli, version)
out_dir = Path(args.out)
if not out_dir.is_absolute():
out_dir = REPO_ROOT / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
if tag == "windows-x64":
asset = out_dir / f"unlucky-launcher-{version}-{tag}.exe"
shutil.copy2(launcher, asset)
else:
asset = stage_archive(out_dir, tag, version, launcher)
sums = write_sums(out_dir, [asset])
size_mb = asset.stat().st_size / (1024 * 1024)
print(f"artifact: {asset.name} {size_mb:.2f} MiB sha256={sha256(asset)}")
print(f"sums: {sums.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+92
View File
@@ -0,0 +1,92 @@
# Unlucky Launcher {{VERSION}}
Rust-native **single-file** Minecraft launcher: Slint + Metro UI, built-in AI agent
(chat / config edits / crash diagnosis) and an MCP server other agent tools can attach to.
No WebView, no bundled runtime, no JRE download needed to start the launcher itself.
## Downloads
| Platform | Asset | Notes |
| --- | --- | --- |
| Windows 10/11 x64 | `unlucky-launcher-{{VERSION}}-windows-x64.exe` | single exe, double-click to run |
| Linux x64 (X11/Wayland) | `unlucky-launcher-{{VERSION}}-linux-x64.tar.gz` | single binary inside, no installer |
| macOS 12+ Apple Silicon | `unlucky-launcher-{{VERSION}}-macos-arm64.tar.gz` | single binary inside, no installer |
| macOS 12+ Intel | `unlucky-launcher-{{VERSION}}-macos-x64.tar.gz` | single binary inside, no installer |
`LICENSE`, `NOTICE` and `README.md` ship in every archive; on Windows they are attached to
this release next to the `.exe` (a lone exe cannot carry them), so the license terms travel
with the download on every platform.
## Support matrix
| Platform | Level | Runtime requirements |
| --- | --- | --- |
| Windows 10/11 x64 | first class | nothing beyond the OS (UCRT is part of Win10+) |
| macOS 12+ (arm64 / x64) | first class | nothing; unsigned build, see Gatekeeper note |
| Linux x64, X11 | first class | `libxkbcommon`, `fontconfig`, `libGL` (or software renderer), `libxcb` |
| Linux x64, Wayland | supported | `wayland-client`, `libxkbcommon`, plus the X11 set for XWayland apps |
| Windows 8.1/8/7 | unsupported | baseline was raised to Win10 (decision D21) |
| Android — Termux + X11 | experimental, not promised | CLI only (build from source), GUI unverified; not part of this release matrix |
## Install
**Windows** — put the `.exe` anywhere and run it. Portable data lives next to the exe when a
`portable.marker` file is present, otherwise under `%APPDATA%`.
**Linux**
```bash
tar -xzf unlucky-launcher-{{VERSION}}-linux-x64.tar.gz
cd unlucky-launcher-{{VERSION}}-linux-x64
chmod +x unlucky-launcher && ./unlucky-launcher
```
**macOS**
```bash
tar -xzf unlucky-launcher-{{VERSION}}-macos-arm64.tar.gz
cd unlucky-launcher-{{VERSION}}-macos-arm64
xattr -d com.apple.quarantine unlucky-launcher # unsigned build, Gatekeeper quarantine
chmod +x unlucky-launcher && ./unlucky-launcher
```
## No GPU? Software renderer
The UI prefers OpenGL (femtovg) and falls back to Slint's CPU renderer when no usable GL
driver is present. Force it on any machine with:
```bash
unlucky-launcher --software-renderer # or: SLINT_BACKEND=winit-software unlucky-launcher
```
## Verify the download
```bash
# Linux / macOS
sha256sum -c SHA256SUMS.txt --ignore-missing
# Windows (PowerShell)
Get-FileHash .\unlucky-launcher-{{VERSION}}-windows-x64.exe -Algorithm SHA256
```
## Checksums
```
{{SUMS}}
```
## Known limitations of this build
- Microsoft (MSA) account login depends on a Mojang app-id whitelist that is still pending;
until it is granted, use offline accounts or authlib-injector (the 403 is reported with a
readable diagnostic instead of a silent failure).
- Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn
on first launch. Checksums above are the integrity reference for now.
- macOS ships the bare binary (single-file promise), not a `.app` bundle: no Dock icon or
Finder double-click — launch it from a terminal.
## License
Apache-2.0. Third-party notices (including Slint's Royalty-Free 2.0 track) are in `NOTICE`.
Unlucky Launcher is not affiliated with Mojang or Microsoft; it does not ship game files.
Made with [Slint](https://slint.dev) — Slint Royalty-Free 2.0 track, unmodified.