Compare commits

...
17 Commits
Author SHA1 Message Date
AskaEth 37ccaaf23f Merge branch 'feat/p3-fix-game-java-discover-cfg-gating-jav'
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release / build (macos-x64) (push) Waiting to run
Release / build (macos-arm64) (push) Waiting to run
Release / build (linux-x64) (push) Waiting to run
Release / build (windows-x64) (push) Waiting to run
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
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'
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
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
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
46 changed files with 3757 additions and 199 deletions

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
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=$(command -v dumpbin || true)
if [ -z "$DUMPBIN" ]; then
DUMPBIN=$(ls -d "C:/Program Files/Microsoft Visual Studio/"*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe 2>/dev/null | head -n1 || true)
fi
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
exit 1
fi
"$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
+1 -1
View File
@@ -82,7 +82,7 @@ all = { level = "deny", priority = -1 }
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
lto = "thin" lto = "fat"
codegen-units = 1 codegen-units = 1
panic = "abort" panic = "abort"
strip = "symbols" strip = "symbols"
+2
View File
@@ -2,6 +2,8 @@
**English** · [简体中文](README.zh-CN.md) **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 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 / (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. configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP.
+2
View File
@@ -2,6 +2,8 @@
[English](README.md) · **简体中文** [English](README.md) · **简体中文**
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断), Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。 融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
+8
View File
@@ -56,6 +56,14 @@ pub(crate) fn wire_pages(weak: Weak<Inner>) {
this.sync(); 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(); let weak = weak.clone();
page.on_msa_cancel(move || { page.on_msa_cancel(move || {
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn active_account_name(state: &AppState) -> String {
.unwrap_or_else(|| "未登录".to_string()) .unwrap_or_else(|| "未登录".to_string())
} }
/// 设备码登录面板。 /// 微软登录面板。
pub fn device(state: &AppState) -> UiDeviceCode { pub fn device(state: &AppState) -> UiDeviceCode {
let view = &state.device_login; let view = &state.device_login;
UiDeviceCode { UiDeviceCode {
+16
View File
@@ -35,6 +35,22 @@ fn accounts_and_device_login_render() {
assert_eq!(device.phase_label, "等待确认"); 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] #[test]
fn install_progress_renders_labels_and_terminal_flags() { fn install_progress_renders_labels_and_terminal_flags() {
let mut state = AppState::new(); let mut state = AppState::new();
+48 -2
View File
@@ -1,4 +1,4 @@
//! 账户页 DTO:三条轨道的统一抽象 + 微软设备码登录的可见状态。 //! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。
//! //!
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。 //! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层—— //! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
@@ -65,7 +65,7 @@ pub struct AccountView {
pub active: bool, pub active: bool,
} }
/// 设备码登录阶段(UI 据此切换面板)。 /// 微软登录阶段(UI 据此切换面板;两条流共用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeviceLoginPhase { pub enum DeviceLoginPhase {
/// 没有登录在进行 /// 没有登录在进行
@@ -160,6 +160,33 @@ impl DeviceLoginView {
} }
} }
/// 授权码流:本地回调已就绪、浏览器已拉起(`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 { pub fn failed(message: impl Into<String>) -> Self {
Self { Self {
@@ -212,6 +239,25 @@ mod tests {
} }
} }
#[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] #[test]
fn succeeded_and_failed_clear_the_code() { fn succeeded_and_failed_clear_the_code() {
let ok = DeviceLoginView::succeeded("Steve"); let ok = DeviceLoginView::succeeded("Steve");
+5 -3
View File
@@ -65,7 +65,7 @@ pub enum CoreEvent {
DiagnosisReady(DiagnosisReport), DiagnosisReady(DiagnosisReport),
/// 账户列表刷新 /// 账户列表刷新
AccountsChanged(Vec<AccountView>), AccountsChanged(Vec<AccountView>),
/// 设备码登录状态刷新 /// 微软登录状态刷新
DeviceLoginChanged(DeviceLoginView), DeviceLoginChanged(DeviceLoginView),
/// Java 页快照(发行版/回退链/运行时) /// Java 页快照(发行版/回退链/运行时)
JavaChanged(JavaPageView), JavaChanged(JavaPageView),
@@ -129,9 +129,11 @@ pub enum AppCommand {
RemoveAccount(String), RemoveAccount(String),
/// 切换当前启动账户 /// 切换当前启动账户
SwitchAccount(String), SwitchAccount(String),
/// 开始微软设备码登录 /// 开始微软设备码登录(CLI/无浏览器场景、备用通道)
StartDeviceLogin, StartDeviceLogin,
/// 取消/关掉设备码登录面板(已发出的请求会在收尾时丢弃结果) /// 开始微软授权码登录(拉起系统默认浏览器 + 本地回调,GUI 的默认入口)
StartBrowserLogin,
/// 取消/关掉微软登录面板(已发出的请求会在收尾时丢弃结果)
CancelDeviceLogin, CancelDeviceLogin,
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`) /// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
InstallJava(String), InstallJava(String),
+1 -1
View File
@@ -31,7 +31,7 @@ impl InstallStage {
match self { match self {
Self::Planning => "解析版本", Self::Planning => "解析版本",
Self::Downloading => "下载文件", Self::Downloading => "下载文件",
Self::Installing => "写入版本", Self::Installing => "生成版本",
Self::Registering => "登记实例", Self::Registering => "登记实例",
Self::Done => "已完成", Self::Done => "已完成",
Self::Failed => "失败", Self::Failed => "失败",
+89 -52
View File
@@ -1,24 +1,29 @@
//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软设备码登录。 //! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软登录(两条流)。
//! //!
//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现, //! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现,
//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。设备码本身不是凭据,可以直接展示。 //! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。
//! //!
//! 取消语义:设备码轮询跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时 //! 两条登录流:授权码([`start_browser_login`],GUI 默认:拉起系统浏览器 + 本地回调,
//! `research/08` §3 方案 A)与设备码([`start_login`],无浏览器/CLI 场景)。除了「怎么拿到
//! MS access_token」之外完全共用:同一个取消槽位、同一段账户入库、同一个刷新链。
//!
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。 //! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use unlucky_core::auth::account::Credentials; use unlucky_core::auth::account::Credentials;
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth}; use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession};
use unlucky_core::auth::offline::offline_meta; use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::AccountStore; use unlucky_core::auth::{AccountStore, AuthResult};
use unlucky_core::download::blocking::block_on; use unlucky_core::download::blocking::block_on;
use crate::dto::{AccountKind, AccountView, DeviceLoginView}; use crate::dto::{AccountKind, AccountView, DeviceLoginView};
use super::env::HostEnv; use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender}; use super::job::{spawn, HostTask, JobSender};
use super::opener;
/// 账户列表(公开字段)。 /// 账户列表(公开字段)。
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> { pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
@@ -103,7 +108,7 @@ pub fn remove(env: &HostEnv, key: &str) -> Result<String, String> {
Ok(meta.display_name().to_string()) Ok(meta.display_name().to_string())
} }
/// 设备码登录任务句柄(取消标志 + 账户名回执)。 /// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct LoginJob { pub struct LoginJob {
/// 用户点了取消(线程收尾时据此丢弃结果) /// 用户点了取消(线程收尾时据此丢弃结果)
@@ -132,16 +137,9 @@ impl LoginJob {
/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。 /// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-login", move || { spawn("msa-login", move || {
let auth = match MicrosoftAuth::from_env() { let auth = match microsoft_auth(&env) {
Ok(auth) => auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( Ok(auth) => auth,
env.layout.logs(), Err(message) => return fail(&tx, message),
))),
Err(error) => {
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(
error.to_string(),
))));
return;
}
}; };
let tx_prompt = tx.clone(); let tx_prompt = tx.clone();
let result = block_on(auth.device_code_login(|prompt| { let result = block_on(auth.device_code_login(|prompt| {
@@ -152,45 +150,84 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
&format!("{}#{}", prompt.verification_uri, prompt.user_code), &format!("{}#{}", prompt.verification_uri, prompt.user_code),
)))); ))));
})); }));
if job.is_cancelled() { finish(&env, &tx, &job, result);
let _ = tx.send(HostTask::Login(Box::default()));
return;
}
match result {
Ok(session) => {
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());
let saved = store
.insert(meta, Credentials::Microsoft(session))
.and_then(|()| store.save());
match saved {
Ok(()) => {
let _ =
tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name))));
let _ = tx.send(HostTask::Accounts(accounts(&env, None)));
}
Err(error) => {
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(
format!("账户入库失败:{error}"),
))));
}
}
}
Err(error) => {
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(
error.to_string(),
))));
}
}
}); });
} }
/// 起一次授权码登录(后台线程:绑本地端口 → 拉起系统默认浏览器 → 等回调 → 换链)。
///
/// 与设备码流共用同一个 [`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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+10
View File
@@ -109,6 +109,16 @@ impl CoreHost {
})); }));
accounts::start_login(self.env.clone(), self.tx.clone(), job); 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 => { AppCommand::CancelDeviceLogin => {
if let Some(job) = &self.login { if let Some(job) = &self.login {
job.cancel(); job.cancel();
+127 -47
View File
@@ -1,12 +1,14 @@
//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线(原版 + Fabric/Quilt)→ 登记实例。 //! 安装:新建实例向导 / 下载页「安装」→ core 安装管线 → 登记实例。
//! //!
//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。 //! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。
//! 差异:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA), //! Forge/NeoForge 多一段 installer processors([`super::install_processors`]):
//! `ProcessorPlan::build → 依赖库下载 → 选 Java → 串行 java -cp`,**跑完才写版本 JSON**。
//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。 //! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
//! //!
//! 加载器范围:原版 + Fabric/Quilt(纯库层)。Forge/NeoForge 要外调 java 跑 installer //! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge(四家,与 CLI 对齐)。OptiFine/LiteLoader
//! processors(M35 的执行器在 CLI 侧),GUI 这一轮**显式拒装并说清去哪装**, //! 两家 core 侧都还没有安装管线,仍在 GUI 里显式拒装并说清去哪装。
//! 而不是装出一个启动必崩的骨架。 //! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -27,6 +29,8 @@ use crate::dto::{CreateRequest, InstallStage, LoaderKind};
use crate::toast::{Toast, ToastKind}; use crate::toast::{Toast, ToastKind};
use super::env::HostEnv; use super::env::HostEnv;
use super::install_journal::Journal;
use super::install_processors::{self, StageInput};
use super::job::{spawn, HostTask, JobSender}; use super::job::{spawn, HostTask, JobSender};
use super::meter::{sink_for, Meter}; use super::meter::{sink_for, Meter};
@@ -36,6 +40,26 @@ pub const CONCURRENCY: usize = 16;
/// 用户取消的稳定文案(阶段边界生效)。 /// 用户取消的稳定文案(阶段边界生效)。
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)"; 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`)。 /// 加载器徽标(`原版` / `Fabric 0.16.5`)。
pub fn loader_label(kind: LoaderKind, version: &str) -> String { pub fn loader_label(kind: LoaderKind, version: &str) -> String {
if kind == LoaderKind::Vanilla || version.trim().is_empty() { if kind == LoaderKind::Vanilla || version.trim().is_empty() {
@@ -45,20 +69,26 @@ pub fn loader_label(kind: LoaderKind, version: &str) -> String {
} }
} }
/// GUI 侧支持的加载器(Forge/NeoForge 需要 installer processors)。 /// GUI 侧支持的加载器(五选:原版 + 四家加载器,与 CLI 的 `--loader` 词表一致)。
///
/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝,
/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。
pub fn supported(kind: LoaderKind) -> bool { pub fn supported(kind: LoaderKind) -> bool {
matches!( matches!(
kind, kind,
LoaderKind::Vanilla | LoaderKind::Fabric | LoaderKind::Quilt LoaderKind::Vanilla
| LoaderKind::Fabric
| LoaderKind::Quilt
| LoaderKind::Forge
| LoaderKind::NeoForge
) )
} }
/// 不支持的加载器 → 给用户的可执行建议。 /// 不支持的加载器 → 给用户的可执行建议。
pub fn unsupported_reason(kind: LoaderKind) -> String { pub fn unsupported_reason(kind: LoaderKind) -> String {
format!( format!(
"{} 需要 installer processors(外调 java 改 jar),GUI 这一轮还没接入;请先在终端跑 `ull install <版本> --loader {}`,装完回实例页刷新即可启动。", "{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge);请先在实例页导入已有版本,或换成受支持的加载器。",
kind.label(), kind.label()
kind.as_str()
) )
} }
@@ -66,13 +96,28 @@ pub fn unsupported_reason(kind: LoaderKind) -> String {
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) { pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
spawn("install", move || { spawn("install", move || {
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request))); let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
let result = run(&env, &tx, &request, &meter, &cancel); 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 let mut progress = meter
.lock() .lock()
.map(|guard| guard.progress.clone()) .map(|guard| guard.progress.clone())
.unwrap_or_else(|_| Meter::new(&request).progress); .unwrap_or_else(|_| Meter::new(&request).progress);
match result { match result {
Ok(name) => { Ok(name) => {
journal.line(&format!("完成:实例 {name} 已登记"));
progress.stage = InstallStage::Done; progress.stage = InstallStage::Done;
progress.files_done = progress.files_total; progress.files_done = progress.files_total;
progress.message = format!("已安装 {name}({})", progress.loader); progress.message = format!("已安装 {name}({})", progress.loader);
@@ -88,15 +133,24 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
}))); })));
} }
Err(reason) => { Err(reason) => {
progress.stage = if reason == CANCELLED { let cancelled = reason == CANCELLED;
progress.stage = if cancelled {
InstallStage::Cancelled InstallStage::Cancelled
} else { } else {
InstallStage::Failed InstallStage::Failed
}; };
progress.message.clone_from(&reason); // 失败文案带上日志路径:面板只有一行、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 { let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress), progress: Box::new(progress),
result: Err(reason), result: Err(text),
}); });
} }
} }
@@ -104,13 +158,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
} }
/// 真安装(同步跑完,调用方在后台线程里)。 /// 真安装(同步跑完,调用方在后台线程里)。
fn run( fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
env: &HostEnv,
tx: &JobSender,
request: &CreateRequest,
meter: &Arc<std::sync::Mutex<Meter>>,
cancel: &Arc<AtomicBool>,
) -> Result<String, String> {
if request.version.trim().is_empty() { if request.version.trim().is_empty() {
return Err("没有选择版本".to_string()); return Err("没有选择版本".to_string());
} }
@@ -118,26 +166,31 @@ fn run(
return Err(unsupported_reason(request.loader)); return Err(unsupported_reason(request.loader));
} }
let name = request.instance_name(); let name = request.instance_name();
let env = job.env;
let engine = super::engine::engine(env); let engine = super::engine::engine(env);
{ {
let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(InstallStage::Planning, "正在解析版本清单…"); guard.stage(InstallStage::Planning, "正在解析版本清单…");
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone()))); let _ = job
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
} }
if cancel.load(Ordering::SeqCst) { if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string()); return Err(CANCELLED.to_string());
} }
let version_id = if request.loader == LoaderKind::Vanilla { let version_id = if request.loader == LoaderKind::Vanilla {
install_vanilla(env, &engine, request, tx, meter, cancel)? install_vanilla(job, &engine, request)?
} else { } else {
install_loader(env, &engine, request, tx, meter, cancel)? install_loader(job, &engine, request)?
}; };
{ {
let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(InstallStage::Registering, "正在登记实例…"); guard.stage(InstallStage::Registering, "正在登记实例…");
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone()))); let _ = job
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
} }
let created_at = let created_at =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()); unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
@@ -153,17 +206,15 @@ fn run(
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。 /// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
fn install_vanilla( fn install_vanilla(
env: &HostEnv, job: &Job<'_>,
engine: &DownloadEngine, engine: &DownloadEngine,
request: &CreateRequest, request: &CreateRequest,
tx: &JobSender,
meter: &Arc<std::sync::Mutex<Meter>>,
cancel: &Arc<AtomicBool>,
) -> Result<String, String> { ) -> Result<String, String> {
let env = job.env;
let version = request.version.trim(); let version = request.version.trim();
let meta_cache = env.layout.meta_cache(); let meta_cache = env.layout.meta_cache();
let now = now_ms(); let now = now_ms();
let sink = sink_for(tx.clone(), Arc::clone(meter)); let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
block_on(async { block_on(async {
let source = RemoteSource::new(engine, &env.game, &meta_cache, now); let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
let resolved = source.resolve(version).await.map_err(install_error)?; let resolved = source.resolve(version).await.map_err(install_error)?;
@@ -197,7 +248,7 @@ fn install_vanilla(
} }
let (bytes_total, _) = plan.total_bytes(); let (bytes_total, _) = plan.total_bytes();
{ {
let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; 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.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
guard.progress.bytes_total = bytes_total; guard.progress.bytes_total = bytes_total;
guard.stage( guard.stage(
@@ -205,7 +256,7 @@ fn install_vanilla(
&format!("开始下载 {} 个文件…", plan.file_count()), &format!("开始下载 {} 个文件…", plan.file_count()),
); );
} }
if cancel.load(Ordering::SeqCst) { if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string()); return Err(CANCELLED.to_string());
} }
let report = execute(engine, &plan, CONCURRENCY, Some(sink)) let report = execute(engine, &plan, CONCURRENCY, Some(sink))
@@ -216,15 +267,13 @@ fn install_vanilla(
}) })
} }
/// 加载器安装(原版前置 → 加载器库 → 版本 JSON 落盘)。 /// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
fn install_loader( fn install_loader(
env: &HostEnv, job: &Job<'_>,
engine: &DownloadEngine, engine: &DownloadEngine,
request: &CreateRequest, request: &CreateRequest,
tx: &JobSender,
meter: &Arc<std::sync::Mutex<Meter>>,
cancel: &Arc<AtomicBool>,
) -> Result<String, String> { ) -> Result<String, String> {
let env = job.env;
let version = request.version.trim(); let version = request.version.trim();
let kind = CoreLoaderKind::parse(request.loader.as_str()) let kind = CoreLoaderKind::parse(request.loader.as_str())
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?; .ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
@@ -237,6 +286,12 @@ fn install_loader(
.await .await
}) })
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
job.journal.line(&format!(
"加载器解析:{} {} → {}",
release.kind.label(),
release.loader_version,
release.version_id
));
let plan = LoaderInstallPlan::build(LoaderPlanInput { let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &env.game, layout: &env.game,
@@ -247,22 +302,47 @@ fn install_loader(
if plan.vanilla_install_needed { if plan.vanilla_install_needed {
let mut vanilla = request.clone(); let mut vanilla = request.clone();
vanilla.loader = LoaderKind::Vanilla; vanilla.loader = LoaderKind::Vanilla;
install_vanilla(env, engine, &vanilla, tx, meter, cancel)?; 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 = meter.lock().map_err(|_| "进度锁失效".to_string())?; let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.progress.files_total += u64::try_from(plan.files().len()).unwrap_or(u64::MAX);
let (bytes, _) = plan.install.total_bytes();
guard.progress.bytes_total = guard.progress.bytes_total.max(bytes);
guard.stage( guard.stage(
InstallStage::Downloading, InstallStage::Downloading,
&format!("开始下载加载器库({} 个文件)…", plan.files().len()), &format!("开始下载加载器库({} 个文件)…", plan.files().len()),
); );
} }
if cancel.load(Ordering::SeqCst) { if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string()); return Err(CANCELLED.to_string());
} }
let sink = sink_for(tx.clone(), Arc::clone(meter)); let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
let report = block_on(async { let report = block_on(async {
execute(engine, &plan.install, CONCURRENCY, Some(sink)) execute(engine, &plan.install, CONCURRENCY, Some(sink))
.await .await
+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, "依赖库要计入文件总数");
}
+165 -33
View File
@@ -1,24 +1,170 @@
//! host/install.rs 的单测(拆文件守 400 行上限)。 //! host/install.rs 的单测(拆文件守 400 行上限)。
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus}; use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
use super::{run, supported, unsupported_reason}; use super::{loader_label, run, supported, unsupported_reason, Job};
use crate::dto::{CreateRequest, LoaderKind}; use crate::dto::{CreateRequest, LoaderKind};
use crate::host::env::HostEnv; use crate::host::env::HostEnv;
use crate::host::install_journal::Journal;
use crate::host::job::{channel, JobSender};
use crate::host::meter::Meter; 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] #[test]
fn loader_support_matrix_is_explicit() { fn loader_support_matrix_is_explicit() {
assert!(supported(LoaderKind::Vanilla)); // M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。
assert!(supported(LoaderKind::Fabric)); for kind in [
assert!(supported(LoaderKind::Quilt)); LoaderKind::Vanilla,
assert!(!supported(LoaderKind::Forge)); LoaderKind::Fabric,
assert!(!supported(LoaderKind::NeoForge)); LoaderKind::Quilt,
assert!(unsupported_reason(LoaderKind::Forge).contains("ull install")); 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] #[test]
@@ -57,30 +203,16 @@ fn meter_counts_files_and_skips_skipped_bytes() {
#[test] #[test]
fn empty_version_is_rejected_before_network() { fn empty_version_is_rejected_before_network() {
let dir = std::env::temp_dir().join(format!("ull-host-install-{}", std::process::id())); let request = request(LoaderKind::Vanilla, "");
let _ = std::fs::remove_dir_all(&dir); let fixture = Fixture::new(&request);
let env = HostEnv::at_root(&dir); let err = run(&fixture.job(), &request).unwrap_err();
let (tx, _rx) = super::super::job::channel();
let request = CreateRequest {
version: String::new(),
loader: LoaderKind::Vanilla,
loader_version: String::new(),
name: String::new(),
};
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
let cancel = Arc::new(AtomicBool::new(false));
let err = run(&env, &tx, &request, &meter, &cancel).unwrap_err();
assert!(err.contains("没有选择版本"), "{err}"); assert!(err.contains("没有选择版本"), "{err}");
}
let forge = CreateRequest { #[test]
version: "1.20.1".to_string(), fn unsupported_loader_is_rejected_before_network() {
loader: LoaderKind::Forge, let request = request(LoaderKind::OptiFine, "1.20.1");
..request let fixture = Fixture::new(&request);
}; let err = run(&fixture.job(), &request).unwrap_err();
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&forge))); assert!(err.contains("还没有接入"), "{err}");
let cancel = Arc::new(AtomicBool::new(false));
assert!(run(&env, &tx, &forge, &meter, &cancel)
.unwrap_err()
.contains("installer processors"));
std::fs::remove_dir_all(&dir).ok();
} }
+10
View File
@@ -37,6 +37,16 @@ impl Meter {
self.previous = None; 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) { pub(super) fn note(&mut self, outcome: &FileOutcome) {
self.progress.files_done += 1; self.progress.files_done += 1;
+3
View File
@@ -20,11 +20,14 @@ mod game;
mod handle; mod handle;
mod identity; mod identity;
mod install; mod install;
mod install_journal;
mod install_processors;
mod java; mod java;
mod job; mod job;
mod launch; mod launch;
mod logs; mod logs;
mod meter; mod meter;
mod opener;
mod settings; mod settings;
mod versions; mod versions;
+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/");
}
}
+18 -1
View File
@@ -24,6 +24,8 @@ pub struct ShotOptions {
pub wizard: bool, pub wizard: bool,
/// 发起一次真实安装并停 `dwell_ms` 后取图(中间态) /// 发起一次真实安装并停 `dwell_ms` 后取图(中间态)
pub install: Option<String>, pub install: Option<String>,
/// 安装走哪个加载器(`--screenshot-loader forge`;缺省 = 原版)
pub loader: Option<String>,
/// 发起一次真实启动并停 `dwell_ms` 后取图(中间态) /// 发起一次真实启动并停 `dwell_ms` 后取图(中间态)
pub launch: Option<String>, pub launch: Option<String>,
/// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片) /// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片)
@@ -58,6 +60,7 @@ pub fn shot_options() -> Option<ShotOptions> {
}, },
wizard: flag("--screenshot-wizard"), wizard: flag("--screenshot-wizard"),
install: value_of("--screenshot-install"), install: value_of("--screenshot-install"),
loader: value_of("--screenshot-loader"),
launch: value_of("--screenshot-launch"), launch: value_of("--screenshot-launch"),
session: value_of("--screenshot-session"), session: value_of("--screenshot-session"),
dwell_ms: value_of("--screenshot-dwell") dwell_ms: value_of("--screenshot-dwell")
@@ -92,7 +95,7 @@ pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::Platform
} }
if let Some(version) = &options.install { if let Some(version) = &options.install {
app.dispatch_and_run( app.dispatch_and_run(
AppCommand::InstallVersion(version.clone()), install_command(version, options.loader.as_deref()),
options.dwell_ms, options.dwell_ms,
); );
} }
@@ -115,6 +118,20 @@ pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::Platform
Ok(()) 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` 是否存在。 /// `--flag` 是否存在。
pub fn flag(name: &str) -> bool { pub fn flag(name: &str) -> bool {
std::env::args().skip(1).any(|arg| arg == name) std::env::args().skip(1).any(|arg| arg == name)
+1 -1
View File
@@ -113,7 +113,7 @@ pub struct AppState {
pub download_query: String, pub download_query: String,
/// 账户列表(三轨统一视图;凭据不在其中) /// 账户列表(三轨统一视图;凭据不在其中)
pub accounts: Vec<AccountView>, pub accounts: Vec<AccountView>,
/// 微软设备码登录状态 /// 微软登录状态
pub device_login: DeviceLoginView, pub device_login: DeviceLoginView,
/// Java 页快照(发行版 / 回退链 / 运行时) /// Java 页快照(发行版 / 回退链 / 运行时)
pub java: JavaPageView, pub java: JavaPageView,
+1 -1
View File
@@ -238,7 +238,7 @@ export struct UiAccount {
active: bool, active: bool,
} }
/// 设备码登录面板(用户码/链接不是凭据,可直接展示)。 /// 微软登录面板(用户码 / 授权链接都不是凭据,可直接展示)。
export struct UiDeviceCode { export struct UiDeviceCode {
visible: bool, visible: bool,
busy: bool, busy: bool,
+1
View File
@@ -45,6 +45,7 @@ export global PageState {
callback account-remove(string); callback account-remove(string);
callback account-switch(string); callback account-switch(string);
callback msa-begin(); callback msa-begin();
callback msa-browser-begin();
callback msa-cancel(); callback msa-cancel();
// ── Java ────────────────────────────────────────────────── // ── Java ──────────────────────────────────────────────────
+12 -5
View File
@@ -1,4 +1,4 @@
// 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软设备码登录 + 删除/切换。 // 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软登录(授权码浏览器 / 设备码)+ 删除/切换。
// //
// 凭据红线:这里能看到的只有「名字 / 轨道 / 公开 UUID / 登录名 / 创建时间」—— // 凭据红线:这里能看到的只有「名字 / 轨道 / 公开 UUID / 登录名 / 创建时间」——
// token 与密码永远不进 UI(Rust 侧的 AccountView 不含这些字段)。 // token 与密码永远不进 UI(Rust 侧的 AccountView 不含这些字段)。
@@ -72,7 +72,7 @@ export component AccountSection inherits Rectangle {
background: transparent; background: transparent;
VerticalLayout { VerticalLayout {
// 设备码登录面板(Waiting 时显示用户码 + 链接;失败显示原因) // 微软登录面板(设备码显示用户码;授权码显示可复制的授权链接;失败显示原因)
if PageState.device.visible : Rectangle { if PageState.device.visible : Rectangle {
height: PageState.device.busy ? 96px : 84px; height: PageState.device.busy ? 96px : 84px;
background: PageState.device.phase_label == "失败" ? Theme.state-danger-soft : Theme.accent-soft; background: PageState.device.phase_label == "失败" ? Theme.state-danger-soft : Theme.accent-soft;
@@ -148,15 +148,22 @@ export component AccountSection inherits Rectangle {
placeholder: "玩家名(离线 UUID 自动生成)"; placeholder: "玩家名(离线 UUID 自动生成)";
} }
MetroButton { MetroButton {
kind: ButtonKind.accent; kind: ButtonKind.standard;
text: "添加离线"; text: "添加离线";
button-height: 28px; button-height: 28px;
enabled: PageState.offline-name != ""; enabled: PageState.offline-name != "";
clicked => { PageState.account-add-offline(PageState.offline-name); } clicked => { PageState.account-add-offline(PageState.offline-name); }
} }
MetroButton { MetroButton {
kind: ButtonKind.standard; kind: ButtonKind.accent;
text: "微软登录(设备码)"; text: "微软登录";
button-height: 28px;
enabled: !PageState.device.busy;
clicked => { PageState.msa-browser-begin(); }
}
MetroButton {
kind: ButtonKind.subtle;
text: "设备码登录";
button-height: 28px; button-height: 28px;
enabled: !PageState.device.busy; enabled: !PageState.device.busy;
clicked => { PageState.msa-begin(); } clicked => { PageState.msa-begin(); }
+2 -2
View File
@@ -196,8 +196,8 @@ export component NewInstanceWizard inherits Rectangle {
LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } } 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: 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: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } }
LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors:本轮请用 ull install"; picked => { PageState.wizard-select-loader(3); } } 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:本轮请用 ull install"; picked => { PageState.wizard-select-loader(4); } } LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(4); } }
} }
// ③ 名字 // ③ 名字
+1 -1
View File
@@ -60,7 +60,7 @@ impl std::fmt::Display for AccountId {
pub enum AccountKind { pub enum AccountKind {
/// 离线(本地生成的 UUID,不联网)。 /// 离线(本地生成的 UUID,不联网)。
Offline, Offline,
/// 微软(device-code)。 /// 微软(device-code / 授权码浏览器)。
Microsoft, Microsoft,
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。 /// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
AuthlibInjector, 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 的端点族区分)。 //! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。
//! //!
//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」): //! 两条流**共用同一个端点族**(`research/08` §2 硬性约束 1:租户只能用 `consumers`):
//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`; //! - 设备码流:`devicecode` + `token`;
//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。 //! - 授权码流:`authorize`(浏览器打开)+ 同一个 `token`(`grant_type=authorization_code`)。
//!
//! 都走 `login.microsoftonline.com/consumers/oauth2/v2.0/*`:用 `common` 或 AAD 租户 id
//! 拿 `XboxLive.signin` 会直接被拒(`analyse/04` §4 的「第一个坑」)。
//! //!
//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链, //! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链,
//! 生产默认走 [`MicrosoftEndpoints::consumers`]。 //! 生产默认走 [`MicrosoftEndpoints::consumers`]。
@@ -12,7 +15,9 @@
pub struct MicrosoftEndpoints { pub struct MicrosoftEndpoints {
/// 设备码申请(`POST`,表单 `client_id` + `scope`)。 /// 设备码申请(`POST`,表单 `client_id` + `scope`)。
pub device_code: String, pub device_code: String,
/// 换 token / 刷新 token(`POST`,表单)。 /// 授权码流的授权页(`GET`,浏览器打开;`research/08` §3 方案 A)。
pub authorize: String,
/// 换 token / 刷新 token / 授权码换 token(`POST`,表单)。
pub token: String, pub token: String,
/// Xbox Live 用户认证(`POST` JSON)。 /// Xbox Live 用户认证(`POST` JSON)。
pub xbl_authenticate: String, pub xbl_authenticate: String,
@@ -35,6 +40,8 @@ impl MicrosoftEndpoints {
Self { Self {
device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode" device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
.to_string(), .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(), token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(),
xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(), xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(),
xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".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('/')); let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/'));
Self { Self {
device_code: join("devicecode"), device_code: join("devicecode"),
authorize: join("authorize"),
token: join("token"), token: join("token"),
xbl_authenticate: join("xbl"), xbl_authenticate: join("xbl"),
xsts_authorize: join("xsts"), xsts_authorize: join("xsts"),
@@ -66,9 +74,10 @@ impl MicrosoftEndpoints {
/// 所有端点(调试/诊断用)。 /// 所有端点(调试/诊断用)。
#[must_use] #[must_use]
pub fn all(&self) -> [&str; 8] { pub fn all(&self) -> [&str; 9] {
[ [
&self.device_code, &self.device_code,
&self.authorize,
&self.token, &self.token,
&self.xbl_authenticate, &self.xbl_authenticate,
&self.xsts_authorize, &self.xsts_authorize,
@@ -95,11 +104,15 @@ mod tests {
let endpoints = MicrosoftEndpoints::consumers(); let endpoints = MicrosoftEndpoints::consumers();
assert!(endpoints.device_code.contains("login.microsoftonline.com")); assert!(endpoints.device_code.contains("login.microsoftonline.com"));
assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token")); assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token"));
// 授权码流的授权页与设备码流同族同租户(`research/08` §2:只能用 consumers)。
assert!(endpoints
.authorize
.contains("/consumers/oauth2/v2.0/authorize"));
assert!(endpoints assert!(endpoints
.xbl_authenticate .xbl_authenticate
.contains("user.auth.xboxlive.com")); .contains("user.auth.xboxlive.com"));
assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com")); assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com"));
// 授权码流的 login.live.com 不在这张表里(本期不实现)。 // 微软 OAuth 两流都不走 HMCL 的 login.live.com 端点族。
assert!(endpoints assert!(endpoints
.all() .all()
.iter() .iter()
@@ -110,6 +123,7 @@ mod tests {
fn all_on_keeps_paths_distinct() { fn all_on_keeps_paths_distinct() {
let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/"); 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.device_code, "http://127.0.0.1:1234/devicecode");
assert_eq!(endpoints.authorize, "http://127.0.0.1:1234/authorize");
assert_eq!( assert_eq!(
endpoints.entitlements_license, endpoints.entitlements_license,
"http://127.0.0.1:1234/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) 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 let access_token = reply
.text("access_token") .text("access_token")
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 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 只在两个字段都非空时处理,我们收紧)。 /// 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 { let Some(code) = reply.text("error") else {
return AuthError::network( return AuthError::network(
url.split('?').next().unwrap_or(url), 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`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测); //! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock); //! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层); //! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token);
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。 //! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
//! //!
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
//!
//! # client id 从哪来 //! # client id 从哪来
//! //!
//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的 //! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由 //! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 //! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么—— //! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么——
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。 //! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
pub mod authcode;
pub mod callback;
pub mod chain; pub mod chain;
pub mod endpoints; pub mod endpoints;
pub mod flow; pub mod flow;
pub mod session; pub mod session;
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
pub use callback::CallbackServer;
pub use endpoints::MicrosoftEndpoints; pub use endpoints::MicrosoftEndpoints;
pub use flow::{DeviceCodePrompt, PollPolicy}; pub use flow::{DeviceCodePrompt, PollPolicy};
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA}; pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
@@ -45,6 +55,7 @@ pub struct MicrosoftAuth {
client_id: String, client_id: String,
endpoints: MicrosoftEndpoints, endpoints: MicrosoftEndpoints,
poll: PollPolicy, poll: PollPolicy,
authcode: AuthCodePolicy,
journal: Option<AuthJournal>, journal: Option<AuthJournal>,
} }
@@ -62,6 +73,7 @@ impl MicrosoftAuth {
client_id: client_id.into(), client_id: client_id.into(),
endpoints: MicrosoftEndpoints::consumers(), endpoints: MicrosoftEndpoints::consumers(),
poll: PollPolicy::default(), poll: PollPolicy::default(),
authcode: AuthCodePolicy::default(),
journal: None, journal: None,
}) })
} }
@@ -131,6 +143,13 @@ impl MicrosoftAuth {
self self
} }
/// 换授权码流参数(回调超时 / 固定端口 / 绑定重试 / 固定 state;测试与 CLI 用)。
#[must_use]
pub fn with_authcode_policy(mut self, policy: AuthCodePolicy) -> Self {
self.authcode = policy;
self
}
/// 当前端点表。 /// 当前端点表。
#[must_use] #[must_use]
pub fn endpoints(&self) -> &MicrosoftEndpoints { pub fn endpoints(&self) -> &MicrosoftEndpoints {
@@ -177,6 +196,34 @@ impl MicrosoftAuth {
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await 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**)。 /// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。
/// ///
/// # Errors /// # 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/PlanDoc/07-accounts.md`(决策 D9/Q21),协议细节来自
//! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。 //! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。
@@ -19,7 +19,8 @@
//! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象); //! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象);
//! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`); //! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`);
//! - [`offline`]:HMCL 兼容离线 UUID + 启动占位; //! - [`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,账户侧); //! - [`authlib`]:标准 Yggdrasil 协议(authenticate/refresh/validate/invalidate,账户侧);
//! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期); //! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期);
//! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38); //! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38);
@@ -47,6 +48,8 @@ pub use account::{AccountId, AccountKind, AccountMeta, Credentials};
pub use body::{BodyKind, ResponseDetail}; pub use body::{BodyKind, ResponseDetail};
pub use error::{AuthError, AuthResult}; pub use error::{AuthError, AuthResult};
pub use journal::{AuthFailureEvent, AuthJournal}; 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 secret::Secret;
pub use store::{AccountStore, AuthPaths}; pub use store::{AccountStore, AuthPaths};
+64 -8
View File
@@ -186,13 +186,24 @@ pub fn dedupe(runtimes: Vec<JavaRuntime>) -> Vec<JavaRuntime> {
kept kept
} }
/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。 /// 去重键:剥掉 Windows 的 `\\?\` 前缀、分隔符一律折成 `/`、大小写只在 Windows 折叠。
///
/// 前两步与平台无关:同一条 `bin/java[.exe]` 用 `/` 还是 `\` 拼出来都是同一个文件,
/// 键必须相同——`JAVA_HOME` 的写法与扫盘拼出来的路径常在这点上不一致,键不齐就重复列。
/// 大小写折叠**只能**跟着平台走:NTFS 不区分大小写,而 POSIX 上 `Java` 与 `java` 是两个
/// 目录,折了会把两份真实安装误并成一个。
///
/// 代价(已知且有意的取舍):POSIX 上文件名里的字面反斜杠也会被当分隔符。JDK 安装路径带
/// 反斜杠属于病态情况,换来的是「同一条 java 的两种拼法永远同键」这条平台无关的不变量。
#[must_use] #[must_use]
pub fn path_key(path: &Path) -> String { pub fn path_key(path: &Path) -> String {
let text = path.display().to_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) { if cfg!(windows) {
text.replace('/', "\\").to_ascii_lowercase() text.to_ascii_lowercase()
} else { } else {
text text
} }
@@ -236,11 +247,56 @@ mod tests {
} }
#[test] #[test]
fn path_key_normalises_separators() { fn path_key_normalises_separators_on_every_platform() {
let left = path_key(std::path::Path::new("C:/a/b/java.exe")); // 平台无关的不变量:同一条 `bin/java` 用 `/` 还是 `\` 拼出来,键必须相同。
let right = path_key(std::path::Path::new("C:\\a\\b\\java.exe")); // (M49:原来这里只在 Windows 上成立,Linux 上 `\` 原样留着 → 断言挂。)
assert_eq!(left, right); let forward = path_key(std::path::Path::new("C:/a/b/java.exe"));
assert!(!path_key(std::path::Path::new(r"\\?\C:\a\java.exe")).starts_with(r"\\")); 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] #[test]
+9
View File
@@ -81,6 +81,15 @@ impl RunningProcess {
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
process.creation_flags(CREATE_NEW_PROCESS_GROUP); 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 { let mut child = process.spawn().map_err(|source| LaunchError::Spawn {
program: command.program.clone(), program: command.program.clone(),
+100 -21
View File
@@ -6,27 +6,39 @@
//! - Windows:`taskkill /T /F /PID`(`/T` 递归子树)。P0 不引入 `windows-sys`,因此没有 //! - Windows:`taskkill /T /F /PID`(`/T` 递归子树)。P0 不引入 `windows-sys`,因此没有
//! Job Object——Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)留 P1,替换点就在本文件 //! Job Object——Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)留 P1,替换点就在本文件
//! (tower 已批准,见 `launch-接口约定.md` §7-1)。 //! (tower 已批准,见 `launch-接口约定.md` §7-1)。
//! - POSIX:`kill -TERM -<pid>`(命不中是常态,因为我们没有 `setsid`——`unsafe` pre_exec 被 //! - POSIX:子进程由 [`crate::launch::process`] 用 `CommandExt::process_group(0)` 放进**自己的**
//! workspace `unsafe_code = deny` 挡住)→ 宽限 → `kill -KILL -<pid>`,再退到单 pid。 //! 进程组(与 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`] //! 再发 `taskkill /F` 会得到 128(“没有这个运行实例”)。所以 [`crate::launch::process`]
//! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。 //! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。
use std::io; use std::io;
use std::process::Command; use std::process::Command;
use std::thread;
use std::time::Duration;
/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。 /// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。
pub fn terminate(pid: u32) -> io::Result<()> { pub fn terminate(pid: u32) -> io::Result<()> {
check_pid(pid)?;
imp::signal(pid, false) imp::signal(pid, false)
} }
/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。 /// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。
pub fn force_kill(pid: u32) -> io::Result<()> { pub fn force_kill(pid: u32) -> io::Result<()> {
check_pid(pid)?;
imp::signal(pid, true) imp::signal(pid, true)
} }
@@ -34,17 +46,70 @@ pub fn force_kill(pid: u32) -> io::Result<()> {
/// ///
/// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用 /// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用
/// [`crate::launch::process::RunningProcess::stop`]。 /// [`crate::launch::process::RunningProcess::stop`]。
pub fn kill_tree(pid: u32, grace: Duration) -> io::Result<()> { pub fn kill_tree(pid: u32, grace: std::time::Duration) -> io::Result<()> {
if pid == 0 { check_pid(pid)?;
return Err(io::Error::other("无效的 pid 0"));
}
let _ = terminate(pid); let _ = terminate(pid);
if !grace.is_zero() { if !grace.is_zero() {
thread::sleep(grace); std::thread::sleep(grace);
} }
force_kill(pid) 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)] #[cfg(windows)]
mod imp { mod imp {
use super::*; use super::*;
@@ -79,28 +144,42 @@ mod imp {
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> { pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
let flag = if force { "-KILL" } else { "-TERM" }; let flag = if force { "-KILL" } else { "-TERM" };
// 先按进程组(`-<pid>`);没有 setsid 时通常失败,再退到单 pid。 let mut delivered = false;
if run_kill(flag, &format!("-{pid}"))?.is_some() { // 验过的独立进程组:一次覆盖组长 + 它的全部后代。
return Ok(()); if let Some(pgid) = group_kill_target(pid) {
let group = format!("-{pgid}");
delivered |= run_kill(flag, &["--", &group])?;
} }
match run_kill(flag, &pid.to_string())? { // 单 pid 兜底永远执行:组杀不可用/进程已不在组里时靠它。
Some(status) if status.success() => Ok(()), let single = pid.to_string();
Some(status) => Err(io::Error::other(format!("kill 退出码 {status}"))), delivered |= run_kill(flag, &[&single])?;
None => Err(io::Error::other("找不到 kill 命令")), 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") match Command::new("kill")
.arg(flag) .arg(flag)
.arg(target) .args(args)
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.status() .status()
{ {
Ok(status) if status.success() => Ok(Some(status)), Ok(status) => Ok(status.success()),
Ok(_) => Ok(None), Err(err) if err.kind() == io::ErrorKind::NotFound => Err(io::Error::new(
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), io::ErrorKind::NotFound,
"找不到 kill 命令:POSIX 进程树回收依赖 procps(Linux)或系统自带 kill(macOS/BSD)",
)),
Err(err) => Err(err), Err(err) => Err(err),
} }
} }
+2
View File
@@ -4,6 +4,7 @@
//! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量; //! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量;
//! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理; //! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理;
//! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr); //! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr);
//! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断);
//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38); //! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38);
//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义; //! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义;
//! - `authlib_api`:Yggdrasil 四动作的报文级对接。 //! - `authlib_api`:Yggdrasil 四动作的报文级对接。
@@ -12,6 +13,7 @@ mod support;
mod auth_failure_journal; mod auth_failure_journal;
mod authlib_api; mod authlib_api;
mod microsoft_authcode;
mod microsoft_diagnostics; mod microsoft_diagnostics;
mod microsoft_flow; mod microsoft_flow;
mod microsoft_refresh; 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] #[test]
fn dedupe_collapses_one_install_found_twice() { fn dedupe_collapses_one_install_found_twice() {
// `JAVA_HOME` 指向 Program Files 里的 JDK → 同一个 java 不应列两次。 // `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![ let candidates = discover::dedupe(vec![
runtime( runtime(
"C:/Program Files/Java/jdk-21/bin/java.exe", "C:/Program Files/Java/jdk-21/bin/java.exe",
21, 21,
JavaOrigin::JavaHome, JavaOrigin::JavaHome,
), ),
runtime( runtime(duplicate, 21, JavaOrigin::System),
"c:/program files/java/jdk-21/bin/java.exe",
21,
JavaOrigin::System,
),
]); ]);
assert_eq!(candidates.len(), 1); assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].origin, JavaOrigin::JavaHome); assert_eq!(candidates[0].origin, JavaOrigin::JavaHome);
+1
View File
@@ -13,3 +13,4 @@ mod process_smoke;
mod pump_buffer; mod pump_buffer;
mod session_events; mod session_events;
mod sink_cases; mod sink_cases;
mod tree_kill_cases;
+40
View File
@@ -111,6 +111,46 @@ fn stop_kills_process_tree() {
assert!(!child.is_running()); 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] #[test]
fn spawn_reports_missing_program() { fn spawn_reports_missing_program() {
let mut command = shell_command("echo hi"); 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.