release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run

本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
AskaEth committed 2026-11-11 21:41:38 +08:00
commit a5d1dec495
1580 files changed
+320938

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
# 换行策略:仓库内一律 LF(rustfmt 设 newline_style = "Unix",CI 在 Linux 上跑 fmt --check)。
# eol=lf 对工作区同样生效 —— Windows 克隆也不会被转换成 CRLF,避免「本地 fmt 过、CI fmt 挂」。
* text=auto eol=lf
# 明确为文本(显式列出,防止被误判成二进制)
*.rs text eol=lf
*.toml text eol=lf
*.md text eol=lf
*.slint text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
Cargo.lock text eol=lf
# 二进制:不做换行转换、不做文本 diff
*.png binary
*.ico binary
*.icns binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.zip binary
*.7z binary
*.gz binary
*.tar binary
*.exe binary
*.dll binary
*.lib binary
*.pdb binary
+336
View File
@@ -0,0 +1,336 @@
name: CI
# 主干门禁:fmt / clippy / test / build 双平台矩阵。
# 架构守门(core 零 UI、文件 ≤400 行、依赖许可)见 guards.yml。
# 平台基线 Win10+(标准工具链产物),平台矩阵见 AGENTS.md §7。
#
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树,源码原样保留)。
# 所以本文件里所有 `--workspace` 都**不再**覆盖 `unlucky-app`:CI 不再编 slint 1.18 + usvg 0.48
# 那棵依赖树(本机单路冷编 ≈14 分钟;并行超配时表现为 `rustc` 退 1 且零诊断,
# 见 DevDocs/00-踩坑记录-p2.md T7)。历史实现只作源码参考,**没有构建方式**(见 README:
# 它的 manifest 整份是 workspace 继承写法,离开 workspace 后连解析都过不了)。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
fmt:
name: fmt (rustfmt --check)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all --check
clippy:
name: clippy (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
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
with:
components: clippy
- uses: Swatinem/rust-cache@v2
# `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 workspace exclude),
# 所以这条命令不会拉起 slint / usvg / i-slint-*。
- run: cargo clippy --workspace --all-targets -- -D warnings
test:
name: test (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖
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
# ---- 下载页选项区的四条像素守门(M127):先出图,再把路径喂给 `--lib` ----
# 之前这四条靠开发者本机手动设 `ULL_DL_*_SHOT`(不设就 SKIP),而本文件里 grep 这些
# 变量零命中 ⇒ **CI 里一次都没跑过**。空跑的守门长期等于没有,所以这两步是它们的第一处
# 常驻调用点。(另有一道保险:变量缺失且 `CI` 已设置时,守门自己会失败而不是跳过。
# 于是将来谁把下面出图那步重构掉,构建会红。)
#
# **为什么只在 Windows**:断言里有几条直接量墨迹(行高 44、版本号墨迹条数、图标药丸
# 40×40),这些数字按本仓字体栈(Segoe UI / Consolas)标定;Linux/macOS runner 上
# 这套字体不存在,回退字体的字高与行距一变就是假红。同一条限制也写在
# `crates/app-iced/src/pages/download/tests/options_shot/feed.rs` 的模块文档里
# (非 Windows 喂了图会当场失败),不靠「碰巧只配了一处」。
- name: 出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows)
if: runner.os == 'Windows'
shell: bash
run: |
set -euo pipefail
shots="$RUNNER_TEMP/dl-shots"
mkdir -p "$shots"
render() { # $1=宽 $2=文件名
cargo run -q -p unlucky-app-iced --example pages_demo -- --screen=download \
--width="$1" --height=760 --screenshot="$shots/$2" \
--screenshot-theme=light --software-renderer
}
# ready700:**不设** ULL_DL_DEMO(缺省就是 ready,还没选版本)
render 700 ready700.png
export ULL_DL_DEMO=options-fabric # 其余三张都是「选中版本 + 选项区展开」
render 700 chips700.png
render 736 compact736.png
render 1180 wide1180.png
for shot in ready700 chips700 compact736 wide1180; do
test -s "$shots/$shot.png" || { echo "::error::$shot.png 没出图"; exit 1; }
done
ls -l "$shots"
{
echo "ULL_DL_READY_SHOT=$shots/ready700.png"
echo "ULL_DL_CHIPS_SHOT=$shots/chips700.png"
echo "ULL_DL_COMPACT_SHOT=$shots/compact736.png"
echo "ULL_DL_OPTIONS_SHOT=$shots/wide1180.png"
} >> "$GITHUB_ENV"
# 单独跑一遍只为**取证**:日志里那行 `test result: ok. 4 passed` 是「守门真跑了」的
# 直接证据(紧接着的 `--workspace` 那步会再跑一遍,热的,代价可忽略)。
- name: 四条像素守门(已喂图,仅 Windows)
if: runner.os == 'Windows'
run: cargo test -p unlucky-app-iced --lib options_shot
# 断言红的时候要先看到那张图(否则「像素不对」只有一句话,没有现场)。
- name: 上传喂进守门的四张图(失败也传)
if: always() && runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: dl-shots
path: ${{ runner.temp }}/dl-shots/
if-no-files-found: warn
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
#
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403),
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件
# 事各自对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到;
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,
# 注解是匿名侧唯一能取到的失败正文;
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
# 只给码、零诊断」三种情形分开——这正是 M144 没定的那一半。
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
if: runner.os == 'Linux'
run: |
log="$RUNNER_TEMP/cargo-test.log"
# 默认 shell 带 `-e` + `pipefail`:管道里 cargo 一失败就会在 tee 之后立刻退出,
# 下面三行永远跑不到。所以这段先关 `-e`,自己取 `PIPESTATUS[0]`(cargo 的码,
# 不是 tee 的码)。
set +e
cargo test --workspace --all-targets --no-fail-fast 2>&1 | tee "$log"
code=${PIPESTATUS[0]}
set -e
echo "cargo exit=$code"
if [ "$code" -ne 0 ]; then
# `%` 是 workflow command 的保留字符,要转成 `%25`,否则注解正文会被截断。
grep -E '^(error(\[E[0-9]+\])?:|failures:|test result: FAILED|thread .* panicked at| *--> )' "$log" \
| head -40 | sed 's/%/%25/g; s/^/::error::/' || true
fi
exit "$code"
- name: cargo test --workspace --all-targets(windows,失败升注解)
if: runner.os == 'Windows'
shell: pwsh
env:
# M171 跨卷守卫:runner 到底有没有第二块可写卷没有核实手段,所以不赌 —— 显式声明豁免。
# 豁免是惰性的:真探到第二块卷时它不参与判断(见 install_xdev_cases 的真值表用例)。
ULL_XDEV_TEST_WAIVE: "1"
run: |
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
# 不会把它改掉,所以它是 cargo 自己的码。
$log = Join-Path $env:RUNNER_TEMP 'cargo-test.log'
cargo test --workspace --all-targets --no-fail-fast 2>&1 | Tee-Object -FilePath $log
$code = $LASTEXITCODE
Write-Host "cargo exit=$code"
if ($code -ne 0) {
# 正则不锚 `error:` 那一支:pwsh 把原生 stderr 折成 ErrorRecord 后行首可能多一截前缀,
# 锚死会漏行;`%` 是 workflow command 的保留字符,转成 `%25`。
Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' |
Select-Object -First 40 |
ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" }
}
exit $code
build:
name: build release (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖
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
# 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档
# (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。
- run: cargo build --workspace --release
# M196:PR 阶段就拦住「产物退回 CUI(3)」。判据是产物 PE 头的 `Subsystem` 字段,不是源码文本——
# 这个 bug 的原始形态正是「源码写了、打在另一棵树上」,任何只看 `grep windows_subsystem` 的守卫
# 都抓不住它。发布侧同一份脚本再跑一次(`release-build.yml`),两处判据同一个真源。
- name: PE 子系统门禁(GUI 产物必须 WINDOWS_GUI/2,CLI 产物必须 CONSOLE/3)
if: runner.os == 'Windows'
shell: bash
run: |
set -euo pipefail
PY=$(command -v python3 || command -v python)
"$PY" packaging/check_pe_subsystem.py \
--gui target/release/unlucky-launcher-iced.exe \
--cli target/release/ull.exe
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
# 启动 GUI 进程,让软件渲染器把当前场景渲进内存像素缓冲并编码成 PNG,再对**像素本身**
# 断言(不是只看退出码:窗口起来了但一帧空白同样是「退出码 0」)。
# 诚实边界:证明「Linux 上真能跑出非空白的一帧」,**不**证明视觉正确——视觉靠人眼看 artifact。
# M118(D26):这里原本绑死 Slint 的 `Window::take_snapshot`,打的是 Slint 版 bin
# `unlucky-launcher`;`crates/app` 退出 workspace 后那个 bin 不再存在,改用 iced 版
# `unlucky-launcher-iced` 自带的 `--screenshot` 通道(`crates/app-iced/src/shot/`)。
# 命令行参数刻意不动:iced 的 `cli.rs` 就是照 Slint 版的口径写的,`--data-dir` 也仍被接受(忽略)。
runtime-smoke:
name: runtime smoke (ubuntu x64, xvfb)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: 无头 X 与图形栈依赖(xvfb + 软渲染 + 字体)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
xvfb xauth \
libxkbcommon-dev libxkbcommon-x11-0 libfontconfig1-dev \
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev \
libx11-6 libxcursor1 libxrandr2 libxi6 \
fonts-dejavu-core fonts-liberation2
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# 用 debug 而不是 release:本 job 验的是「运行时渲染通路能通」,不是优化后二进制的行为;
# release 的 lto="fat" + codegen-units=1 会把这一步拖到十几分钟却不带来额外证据。
# M118:bin 从 Slint 版 `unlucky-launcher` 换成 iced 版(见上方 job 注释)。
- run: cargo build --bin unlucky-launcher-iced --locked
- name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败)
run: |
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
mkdir -p "$RUNNER_TEMP/smoke/data"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
target/debug/unlucky-launcher-iced --software-renderer \
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
--data-dir "$RUNNER_TEMP/smoke/data"
- name: 断言 PNG 存在且非空白(尺寸 / 唯一色数 / 亮度σ / 强边缘占比)
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
- name: 上传截图与数据目录(失败也传,便于复盘)
if: always()
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-screenshot
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
# macOS 槽位探针(M62):**默认不在 PR 上跑**,只有手动 dispatch 才触发。
# 之所以是探针而不是常驻槽位:官方 macOS runner 处于 LaunchDaemon 上下文,没有登录的
# WindowServer 会话,GUI 的 winit backend 创建窗口大概率直接失败;而 worker 禁 push,
# 本机也没有 macOS,这条路径**尚无任何实测证据**——不拿未验证的 job 卡住主干门禁。
# 首跑结论决定去留:绿 → 去掉 if/continue-on-error 升为常驻槽位;红 → 按 D26 归入 iced
# 重写后再规划,并把错误原文贴进 progress 文档(不静默删 job、不吞失败原因)。
# M118:bin 与 Linux 那条一样,从 Slint 版换成 iced 版(`unlucky-launcher-iced`)。
runtime-smoke-macos:
name: runtime smoke (macos arm64, probe)
if: github.event_name == 'workflow_dispatch'
runs-on: macos-latest
timeout-minutes: 30
# 探针阶段保留证据但不算流水线结论:log/artifact 里失败原因照常可见,
# 解除条件 = 该槽位在真实 runner 上首跑通过并把本行与上面那条 if 一起删掉。
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --bin unlucky-launcher-iced --locked
- name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论)
run: |
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
mkdir -p "$RUNNER_TEMP/smoke/data"
target/debug/unlucky-launcher-iced --software-renderer \
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
--data-dir "$RUNNER_TEMP/smoke/data"
- name: 断言 PNG 存在且非空白
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
- name: 上传截图与数据目录(失败也传,便于复盘)
if: always()
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-screenshot-macos
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
# M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 /
# 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。
#
# 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` +
# `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable`
# 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿
# (r1 复审 P2-4)。
#
# 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在
# `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test
# update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。
#
# 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与
# panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。
update-e2e:
name: update-e2e (windows x64)
runs-on: windows-latest
timeout-minutes: 45
env:
# 同 test 作业:跨卷 e2e 守卫在没有第二块卷的 runner 上需要显式豁免,否则会判红。
ULL_XDEV_TEST_WAIVE: "1"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: update-e2e
run: >
cargo test --release -p unlucky-cli --features update-test-source
--test update_e2e_test --test update_e2e_tamper_test
+133
View File
@@ -0,0 +1,133 @@
name: Dev Guards
# 日常线门禁(M133)。
#
# 为什么需要这个文件:本仓日常开发线是 `dev`,日常合并**不走 PR**(见 DevDocs/AGENTS.md §8 双仓纪律:
# Gitea `dev` = 日常线)。而 ci.yml 与 guards.yml 只挂在 `push: branches: [main]` + `pull_request` 上
# ⇒ fmt / clippy / 文件行数≤400 / 三条零依赖守门在**日常线上一次都没跑过**。第一个可见后果:
# `packaging/README.md` 涨到 401 行、超全仓红线无人拦,直到文档普查才被发现(M131 才修回 398)。
# 「门禁存在但日常线上永不运行」= 空跑的门禁,长期等于没有。
#
# 口径:**只补「快、且坏了会静默腐化」的那几道**,且不复制守门脚本:
# - `guards` 作业直接 `uses: ./.github/workflows/guards.yml` —— 六道守门(core-no-ui /
# presenter-no-ui / design-zero-dep / unsafe-scope / file-size / licenses)跑的是与 main 同一份脚本,
# dev 与 main 的判据永远同源。main / PR / dispatch 的触发与作业一个字没动。
# - `fmt` / `clippy` 在本文件里各写一份:ci.yml 的作业背后挂着 build / test / runtime-smoke 整条矩阵,
# 整份复用等于把 dev 的成本翻倍;这两条命令与 ci.yml 逐字一致,方便对照与同步。
#
# 刻意**不上** dev 的(判断依据见 M133 报告,这里只记结论):
# - 全量 `cargo test`:`clippy --all-targets` 已经把测试**代码**类型检查过一遍(「测试编译不过了」
# 这类静默腐化会被它抓到),而「断言暂时红了」在施工中间态是常态、不是腐化;test 是 2 个 OS ×
# 全量编译+运行,留给 main 与发布前的最终门更划算。
# - build release / runtime-smoke:它们是「能不能发」的门,dev 每次 push 跑一遍换不到新信息。
#
# 成本控制(dev push 很频繁,这是硬要求):
# 1. `concurrency` 取消**同一个 ref** 上的旧运行:连推三次只留最后一次,被取消的旧运行基本不计费。
# 2. clippy 只在「这次推送碰过 Rust 侧文件」时跑:只改文档(*.md / LICENSE / NOTICE / .gitignore)
# 的推送跳过编译。判据**保守**——非 push、base 不可解析(force-push / 首次推送)、diff 取不到、
# 出现任何不在白名单里的路径,一律照跑:宁可多跑一次,不许假绿。
# 刻意**不**用 `on.push.paths` 过滤整个工作流:file-size 正是要在「只改文档」的推送里跑的那道门
# (README 401 行就是这么漏的),整条过滤掉等于自废武功。
on:
push:
branches: [dev]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
concurrency:
group: dev-guards-${{ github.ref }}
cancel-in-progress: true
jobs:
# 六道守门原样来自 guards.yml(那边加了 `workflow_call:` 触发,纯新增)。本文件不重写它们的脚本:
# unsafe-scope 的词法器一旦有第二份副本,就会出现「dev 绿、main 红」的双真相。
guards:
name: 架构与合规守门(同 main 口径)
uses: ./.github/workflows/guards.yml
# GPU 证据门禁(M253 r1 P2-1):与 main **同一份** workflow,理由同上 —— 判据只能有一份。
# dev 上 `evidence` 照样显式跳过(无 GPU、无产物,见该文件的 probe job),所以实际拿到的是
# `selftest` 那一半:19 条向量 + 三个退出码 0/1/2 + 接线自检,成本就是一次 python 自测。
# 不另加「只跑自测」的开关:那会给这道门禁多开一个「可以合法关掉真判据」的旋钮。
gpu-evidence:
name: GPU 证据门禁(同 main 口径;无 GPU 只跑自测)
uses: ./.github/workflows/gpu-evidence.yml
fmt:
name: fmt (rustfmt --check)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all --check
# 只跑 ubuntu:clippy 的判据是类型层 lint,跨平台差异极少(Windows 专属行为由 main/PR 上的
# windows-latest 槽位与 test 里的像素守门兜);两个 OS 都上会把 dev 的编译成本直接翻倍。
clippy:
name: clippy (ubuntu)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# 要 diff 出「这次推了哪些文件」就得有 base commit;浅 clone 会让下面的判据退化成
# 「一律照跑」(保守,但不再省成本),所以这里取全历史。
fetch-depth: 0
- name: 本次推送是否碰过 Rust 侧文件(只碰文档则跳过编译)
id: scope
env:
EVENT: ${{ github.event_name }}
BEFORE: ${{ github.event.before }}
SHA: ${{ github.sha }}
run: |
set -uo pipefail
run=yes
note="照跑:默认(未进入可判定的 push 分支)"
if [ "$EVENT" != "push" ]; then
note="照跑:非 push 事件(手动 dispatch)没有可信 diff"
elif [ -z "${BEFORE:-}" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
note="照跑:base 为首次推送(before 全 0)"
elif ! git rev-parse --verify -q "${BEFORE}^{commit}" >/dev/null 2>&1; then
note="照跑:base commit 不可解析(force-push / 历史被改写)"
else
changed=$(git diff --name-only "$BEFORE" "$SHA" 2>/dev/null || true)
if [ -z "$changed" ]; then
note="照跑:diff 为空(可能是增量不可得)"
else
# 白名单判定:只有「全部改动都落在这些非 Rust 路径上」才跳过编译。
# 反向写法(黑名单 .rs)更省事,但漏一个路径就等于漏一次编译检查。
others=$(printf '%s\n' "$changed" \
| grep -vE '\.md$' \
| grep -vxE 'LICENSE|NOTICE|\.gitignore' || true)
if [ -z "$others" ]; then
run=no
note="跳过:本次只碰文档类路径 —— $(printf '%s ' $changed)"
else
note="照跑:本次改动了非文档路径 —— $(printf '%s ' $others)"
fi
fi
fi
echo "$note"
echo "run=$run" >> "$GITHUB_OUTPUT"
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
if: steps.scope.outputs.run == 'yes'
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
if: steps.scope.outputs.run == 'yes'
with:
components: clippy
- uses: Swatinem/rust-cache@v2
if: steps.scope.outputs.run == 'yes'
# 与 ci.yml 的 clippy 作业逐字一致(`--workspace` 不含已退役的 `unlucky-app`,见 M118)。
- name: cargo clippy --workspace --all-targets -D warnings
if: steps.scope.outputs.run == 'yes'
run: cargo clippy --workspace --all-targets -- -D warnings
+233
View File
@@ -0,0 +1,233 @@
name: GPU evidence
# GPU 截图证据门禁(M253:把 M249 的 `packaging/check_gpu_shot.py` 接进 CI)。
#
# **为什么新开一个文件,不并进 guards.yml**:guards.yml 已是 399 行、红线 400(`file-size` 那道门
# 就扫 `*.yml`),挂一个 job 必越线;`release-build.yml` 正好 400 行、动它得先拆。这与
# `release-guard.yml` 当初单独成文的理由逐字相同(那份文件头:「那份文件已经 398 行(400 行红线),
# 再挂一个 job 必越线」)。
#
# 口径(塔定,M253 Context 原文):**CI runner 无 GPU 时必须显式 skip 并写明原因,不得静默 pass**。
# 落到这份文件的三条硬规矩 —— 它们由 `packaging/tests/gpu_shot_ci_selftest.py` 机器校验
# (33 条「改绿」变异逐条重放、10 类封闭清单),不是靠这几行注释自律;那份脚本的 docstring 写清它**保证什么、
# 保证不了什么**(r1 复审实测:上一版 19 条变异漏 11 条,所以这里不敢再写「不可能」):
# ① `selftest` job **无条件**跑 `--self-test`:它证明「尺子合格」,不需要 GPU、也不需要真产物;
# 同一 job 再用真 CLI 走一遍三个退出码(0 过 / 1 判据不过 / 2 用法错)。
# ② 真实产物判据只在 `probe` job 探到**工作区里真有可用产物**时才跑。
# ③ **跳过与通过分开写**:跳过的做法是让 `evidence` job 的 `if:` 不成立 —— 它在 Actions 里显示
# **灰色 skipped**,不是一个绿色的 ✓;同时 `probe` 在 job summary 里写明缺什么、为什么,
# 并打 `::warning::` 说明「本次没有判过任何真实产物」。
#
# 载体依赖:整个 `gpu-evidence.yml` 被删掉/替换后,**没有任何东西会跑这个自检** —— 守门人住在被守对象自己的文件里(`guards.yml` 已经 399/400 行,塞不进去)。
# 所以改这道门禁时请连自检一起想:自检只认「上面那张封闭清单」,不宣称「不可能被改成总是绿」。
#
# 判据本身(六条 + `--expect-refuse` 三条)与能证/不能证:`packaging/docs/gpu-evidence.md`。
# 改这份文件先跑 `python3 packaging/tests/gpu_shot_ci_selftest.py`。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
shot_log:
description: 'stderr 日志路径(需带 ULL_GPU_LOG=info,例 DevDocs/artifacts/gpu-probe/a1-gpu.stderr.log);留空则按下面的约定路径判'
required: false
default: ''
shot_png:
description: '同一运行的 PNG 路径;负例(expect_refuse=true)可留空'
required: false
default: ''
expect_refuse:
description: '按负例口径判(--expect-refuse:要求 via=refuse 且一张图都没出)'
type: boolean
default: false
# 日常线 `dev` 复用**同一份**判据与接线(r1 P2-1):`dev-guards.yml` 里
# `uses: ./.github/workflows/gpu-evidence.yml`。理由与 guards.yml 的 `workflow_call:` 逐字相同:
# 判据只能有一份,两份各自演进迟早出现「dev 绿、main 红」的双真相;而 `dev-guards.yml` 文件头
# 自己写着「空跑的门禁等于没有」。dev 上 `evidence` 照样显式跳过(无 GPU、无产物),所以 dev 拿到的
# 是 `selftest` 那一半:19 条向量 + 三个退出码 + 接线自检,成本就是一次 python 自测。
workflow_call:
jobs:
selftest:
name: GPU 判据自测(不需 GPU;跳过 ≠ 已验证)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 判据实现自测(19 条向量,数据在 packaging/tests/gpu_shot_vectors.json)
id: vectors
run: python3 packaging/check_gpu_shot.py --self-test
# 自测走的是判据**函数**,退出码是 `main()` 的那一行。这里用真 CLI 把三个码都走一遍:
# 少了这一步,「退出码永远 0」这种回归在 CI 上照样全绿(门禁自己变成假绿)。
- name: 退出码语义(0 过 / 1 判据不过 / 2 用法错)
id: exitcodes
run: |
set -uo pipefail
work="$RUNNER_TEMP/gpu-shot-exitcodes"
mkdir -p "$work"
# 用自测脚手架现造一份能过的一对(同一份 synth_log/synth_png,避免第二套手工拼装)
python3 - "$work" <<'PY'
import sys
from pathlib import Path
sys.path[:0] = ["packaging", "packaging/tests"]
from gpu_shot_selftest import SELFTEST_SIZE, synth_log, synth_png
root = Path(sys.argv[1])
(root / "pass.stderr.log").write_text(synth_log(size=SELFTEST_SIZE), encoding="utf-8")
(root / "pass.png").write_bytes(synth_png("content", SELFTEST_SIZE))
(root / "red.stderr.log").write_text(synth_log(renderer="tiny-skia"), encoding="utf-8")
PY
expect() { # expect <期望退出码> <说明> <命令...>
want="$1"; note="$2"; shift 2
set +e
"$@" > "$work/out.txt" 2>&1
got=$?
set -e
cat "$work/out.txt"
if [ "$got" -ne "$want" ]; then
echo "::error::退出码语义不对:$note 期望 $want,实际 $got。判据脚本的 0/1/2 是整条门禁的接口,变了就是门禁变了。"
exit 1
fi
echo "OK: $note → exit=$got"
}
expect 0 "正例(合成日志 + 合成 PNG,六条判据全过)" \
python3 packaging/check_gpu_shot.py "$work/pass.stderr.log" "$work/pass.png"
expect 1 "CPU 档日志(renderer=tiny-skia,判据点名判红)" \
python3 packaging/check_gpu_shot.py "$work/red.stderr.log" "$work/pass.png"
expect 2 "用法错(一个位置参数都没给)" \
python3 packaging/check_gpu_shot.py
- name: 接线自检(33 条「改绿」变异必须重放为红;覆盖边界见脚本 docstring)
id: wiring
run: python3 packaging/tests/gpu_shot_ci_selftest.py
probe:
name: GPU 证据 — 探测可用产物与硬件适配器
runs-on: ubuntu-latest
outputs:
available: ${{ steps.detect.outputs.available }}
log: ${{ steps.detect.outputs.log }}
png: ${{ steps.detect.outputs.png }}
reason: ${{ steps.detect.outputs.reason }}
steps:
- uses: actions/checkout@v4
- name: 探测样例产物与硬件适配器(只探测、不判定)
id: detect
env:
SHOT_LOG: ${{ github.event.inputs.shot_log }}
SHOT_PNG: ${{ github.event.inputs.shot_png }}
EXPECT_REFUSE: ${{ github.event.inputs.expect_refuse }}
EVENT: ${{ github.event_name }}
run: |
set -uo pipefail
default_dir=DevDocs/artifacts/gpu-probe
default_stem=gpu-shot
log_path=""
png_path=""
available=false
reason=""
if [ -n "${SHOT_LOG:-}" ]; then
# ① dispatch 显式点了名:**给了却找不到 = 红**。若「明确说要判、东西不在」能退化成跳过,
# 那又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip。
if [ ! -f "${SHOT_LOG}" ]; then
echo "::error::dispatch 指定了 shot_log=${SHOT_LOG},但工作区里没这个文件 —— 显式要求判定的输入缺失按红处理(不跳过)"
exit 1
fi
if [ -n "${SHOT_PNG:-}" ] && [ ! -f "${SHOT_PNG}" ]; then
echo "::error::dispatch 指定了 shot_png=${SHOT_PNG},但工作区里没这个文件 —— 同上,按红处理"
exit 1
fi
if [ "${EXPECT_REFUSE:-false}" != "true" ] && [ -z "${SHOT_PNG:-}" ]; then
echo "::error::正例(expect_refuse=false)必须给 shot_png;只有负例可以省略 PNG"
exit 1
fi
log_path="${SHOT_LOG}"
png_path="${SHOT_PNG:-}"
available=true
reason="dispatch 显式指定产物(${log_path})"
elif [ -f "${default_dir}/${default_stem}.stderr.log" ] && [ -f "${default_dir}/${default_stem}.png" ]; then
# ② 约定路径:一次取证 = 一份干净的 stderr + 同一次运行的那张图
# (`--diff`/拼接会骗过判据,见 gpu-evidence.md §5 第 1/7 条)。
log_path="${default_dir}/${default_stem}.stderr.log"
png_path="${default_dir}/${default_stem}.png"
available=true
reason="工作区里有约定产物(${default_dir}/${default_stem}.*)"
else
# ③ 什么都没有 —— 这是 GitHub 托管 runner 的常态:托管 runner 没有 GPU,
# 而样例产物也不在仓库里(`DevDocs/` 被 .gitignore 忽略,图不进仓库)。
# 必须显式跳过 + 写明原因,不许静默 pass。
if compgen -G '/dev/dri/renderD*' >/dev/null; then
adapter='本机有硬件适配器(/dev/dri/renderD*)'
elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
adapter='本机有硬件适配器(nvidia-smi -L 有输出)'
else
adapter='无硬件适配器(/dev/dri 下没有 renderD*,也没有可用的 nvidia-smi)'
fi
reason="${adapter},且工作区里没有可用的样例产物(约定路径要求 stderr 日志与同次运行的 PNG 同时存在;DevDocs/ 被 .gitignore 忽略,图不进仓库;event=${EVENT})"
fi
{
echo "available=${available}"
echo "log=${log_path}"
echo "png=${png_path}"
echo "reason=${reason}"
} >> "$GITHUB_OUTPUT"
if [ "${available}" = "true" ]; then
echo "GPU 证据:${reason} —— 交给 evidence job 跑真实产物判据(0 过 / 1 判据不过)"
else
echo "GPU 证据:${reason},跳过真实产物判据(判据实现自测已过:那证明「尺子合格」,不证明「这张图是 GPU 出的」)"
# warning 而不是 notice:这一趟运行**看起来是绿的**,必须在 UI 上留一条黄条说清它没判过。
echo "::warning::GPU 证据:跳过(${reason})—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」"
fi
{
echo "## GPU 证据(M253)"
echo
if [ "${available}" = "true" ]; then
echo "- 结论:**跑真实产物判据**(${reason})"
echo "- 产物:${log_path}"
if [ -n "${png_path}" ]; then echo "- PNG:${png_path}"; else echo "- PNG:无(负例口径 ${EXPECT_REFUSE:-false})"; fi
echo "- 判定看 \`evidence\` job 的退出码(0 过 / 1 判据不过 / 2 用法错);这里是「有东西可判」,不是「判过了」。"
else
echo "- 结论:**⏭ 跳过(未验证)** —— ${reason}"
echo "- 「这张图真是 GPU 档出的」这件事,**本次没有判过**;跳过不等于通过。"
echo "- 已经验过的是尺子本身:\`--self-test\`(19 条向量)与三个退出码 0/1/2(\`selftest\` job)。"
echo "- 想让它变成真判定,只有两条**在这个 CI 运行里真能执行**的路:"
echo " ① \`workflow_dispatch\` 填 \`shot_log\` / \`shot_png\`,且两个文件必须**已被 checkout 下来**"
echo " —— checkout 只拿已跟踪文件,所以产物得先提交进被 dispatch 的那个 ref;"
echo " ② 把一对产物提交到 \`${default_dir}/${default_stem}.stderr.log\` + \`${default_dir}/${default_stem}.png\`"
echo " 再对那个 ref 跑一次(\`${default_dir}\` 在 .gitignore 里,要用 \`git add -f\`;"
echo " 或改放到任一已跟踪目录,用 ① 的输入指过去)。"
echo " 就地往 workspace 里拷文件(不提交)在 CI 上做不到:那次运行 checkout 的树里没有 DevDocs/。"
fi
} >> "$GITHUB_STEP_SUMMARY"
evidence:
name: GPU 证据 — 真实产物判据
needs: [selftest, probe]
# 第 ③ 条规矩就在这一行:没有可用产物 ⇒ 整个 job 不运行,UI 上是灰色 skipped,
# 不是一个绿色 ✓。这正是「跳过」与「通过」分开写的地方(r1 复审实核过这条 if 的语义)。
if: needs.probe.outputs.available == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 判定(0 过 / 1 判据不过 / 2 用法错;退出码即结论,不许被 || true 吞掉)
id: judge
env:
LOG: ${{ needs.probe.outputs.log }}
PNG: ${{ needs.probe.outputs.png }}
EXPECT_REFUSE: ${{ github.event.inputs.expect_refuse }}
run: |
set -uo pipefail
echo "GPU 证据:判 ${LOG}"
if [ "${EXPECT_REFUSE:-false}" = "true" ]; then
if [ -n "${PNG}" ]; then
python3 packaging/check_gpu_shot.py "$LOG" --expect-refuse "$PNG"
else
python3 packaging/check_gpu_shot.py "$LOG" --expect-refuse
fi
else
python3 packaging/check_gpu_shot.py "$LOG" "$PNG"
fi
+399
View File
@@ -0,0 +1,399 @@
name: Guards
# ⚠ 本文件所有 `run:` 都在 `bash -e {0}` 下跑(GitHub 未指定 shell 时的默认值),脚本里的
# `set -uo pipefail` **关不掉 -e**:裸管道 / 裸赋值 `x=$(… | grep …)` 零命中时会把整步静默杀死,
# 一条 `::error::` 都不打(M142 在 release-build.yml 栽过,M156 在这里补账)。探测一律 `if ! x=$(…)` 或 `$(… || true)`。
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好等于期望集合(D36 / M91;M196 起两处)
#
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件,
# **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转:
# ① core-no-ui / presenter-no-ui / design-zero-dep —— 「manifest 文本 + `cargo tree -p <crate>`」
# 口径,被守的 crate 仍是 workspace 成员,依赖树与排除前逐行一致;
# ② file-size / unsafe-scope —— 走 `git ls-files` / `git grep`,与构建图无关,**仍然覆盖
# `crates/app/**`**(源码还在仓库里,就还得守它的行数与 unsafe 落点);
# ③ licenses —— 唯一走 workspace 依赖图的守卫。排除后 Slint 那棵树不再进图(实测 `cargo deny
# list` 里 `slint*` / `i-slint-*` 为 0 个),这与「不再随任何产物发布」一致;代价是它的许可证
# 不再被本门禁审 —— 真要重新纳入发布,得先把它加回 workspace,门禁随之恢复。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# M133:日常线 `dev` 复用**同一份**守门脚本(`.github/workflows/dev-guards.yml` 里
# `uses: ./.github/workflows/guards.yml`)。这是**纯新增的触发方式**:上面三条与下面全部作业
# 一字未动,main / PR / dispatch 的触发与判据与加这行之前逐字相同。
# 为什么复用而不是复制:判据只能有一份。两份 unsafe 词法器(110 行状态机)各自演进,迟早出现
# 「dev 绿、main 红」的双真相,那时 dev 门禁就变成了装饰品。
workflow_call:
env:
CARGO_TERM_COLOR: always
jobs:
core-no-ui:
name: core 零 UI 守门
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: 依赖表直查(crates/core/Cargo.toml 不得出现 UI 框架)
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 只看非注释行:manifest 里说明「不许出现 slint」的注释本身不该触发守门
if grep -vE '^[[:space:]]*#' crates/core/Cargo.toml | grep -nEi "$forbidden"; then
echo "::error file=crates/core/Cargo.toml::core 不得依赖 UI 框架(见 AGENTS.md 硬约束)"
exit 1
fi
echo "OK: crates/core/Cargo.toml 无 UI 依赖"
- name: 依赖树泄漏检查(cargo tree -p unlucky-core,排除自身行)
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 与下方 presenter / design 的 `if ! tree=$(…)` 对称:cargo tree 自己失败(改名 / 解析失败)时裸赋值会被 -e 静默杀死,只剩 exit code 1 无注解。
if ! tree=$(cargo tree -p unlucky-core --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-core 执行失败 —— crate 是否仍叫 unlucky-core?"
exit 1
fi
echo "$tree"
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
exit 1
fi
echo "OK: unlucky-core 依赖树零 UI"
# presenter 是 Slint 版(crates/app)与 iced 版(crates/app-iced)共用的唯一业务层(D26)。
# 一旦 slint/iced/winit 被拉进来,分层就废了,而编译与单测仍会全绿 —— 所以必须由 CI 守。
# M118 之后 Slint 版已被 workspace `exclude`,但本条**照样守得住**:它查的是 presenter 自己的
# manifest 与 `cargo tree -p unlucky-presenter`,与「谁在消费 presenter」无关;依赖树里一旦出现
# slint/iced/winit 仍会当场红。名单里的 slint 继续留着,就是为了防「把 UI 依赖偷偷塞进宿主层」。
# 与 core-no-ui 同样是「依赖表 + cargo tree」双查,名单保持一致;脚本刻意复制而非抽公共脚本,
# 理由是 CI 守门脚本自身挂掉的排查成本远高于这几行重复。
presenter-no-ui:
name: presenter 零 UI 守门
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: 依赖表直查(crates/presenter/Cargo.toml 的依赖表不得出现 UI 框架)
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 与 core-no-ui 的整文件 grep 不同:本 crate 的 description 合法地写着
# 「Slint 版与 iced 版共用」,整文件 grep 会把它当越线。所以先按表头切出依赖表
# (含 target.*.dependencies 与 dev/build 表),再去掉注释行,只查键名。
# 子表写法 `[dependencies.slint]` / `[target.'cfg(windows)'.dependencies.slint]`
# 是合法的等长写法,且表头本身带着包名,所以表头也要一并落进检查范围——
# 否则「子表 + optional + 非默认 feature」这种组合能同时骗过依赖表与 cargo tree。
awk '
/^[[:space:]]*\[/ {
in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/)
if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0
next
}
in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 }
' crates/presenter/Cargo.toml > /tmp/presenter-deps.txt
cat /tmp/presenter-deps.txt
if grep -iE "$forbidden" /tmp/presenter-deps.txt; then
echo "::error file=crates/presenter/Cargo.toml::presenter 不得依赖 UI 框架(D26)。窗口创建 / 渲染 / 图片解码属于 app 与 app-iced 侧;需要跨前端共用的类型请放进 presenter 的 dto。"
exit 1
fi
echo "OK: crates/presenter/Cargo.toml 依赖表无 UI 依赖"
- name: 依赖树泄漏检查(cargo tree -p unlucky-presenter,排除自身行)
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 注意:cargo tree 命中 UI 框架时**退出码仍是 0**,所以只能抓输出再判断。
if ! tree=$(cargo tree -p unlucky-presenter --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-presenter 执行失败 —— crate 是否仍叫 unlucky-presenter?"
exit 1
fi
echo "$tree"
# 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头):
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
if [ -n "$hits" ]; then
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
printf '%s\n' "$hits" | sed 's/^/ - /'
echo "::error::presenter 是 Slint 版与 iced 版共用的宿主层:把窗口 / 渲染 / 图片解码留在 app 侧,presenter 只持有与框架无关的 DTO、状态 reducer 与 host 装配。"
exit 1
fi
echo "OK: unlucky-presenter 依赖树零 UI"
# unlucky-design 是「纯数据 + 纯函数、零第三方依赖」的设计令牌层,这是 README 明写的卖点
# (连 serde 都不加),但目前只靠人肉守。零依赖的判据比 UI 名单更硬:依赖树只允许有自己一行。
# 注意 `cargo tree --edges normal` 看不到 dev/build 依赖,所以另设一步按表头切依赖表逐个查空。
design-zero-dep:
name: design 零第三方依赖守门
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: 依赖表必须为空(dependencies / dev / build 表一律不许有键,含子表写法)
run: |
set -uo pipefail
# design 承诺的是「零第三方依赖」,不分 normal / dev / build —— 而 cargo tree
# --edges normal 对 dev/build 是瞎的。所以先按表头切出全部依赖表(含
# `[dependencies.serde]` 这类子表写法,表头本身携带包名),再看是否为空。
awk '
/^[[:space:]]*\[/ {
in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/)
if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0
next
}
in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 }
' crates/design/Cargo.toml > /tmp/design-deps.txt
if [ -s /tmp/design-deps.txt ]; then
cat /tmp/design-deps.txt
echo "::error file=crates/design/Cargo.toml::unlucky-design 的依赖表必须为空(normal / dev / build 都算),本次命中:"
sed 's/^/ - /' /tmp/design-deps.txt
echo "::error::design 是纯数据 + 纯函数的设计令牌,README 对外承诺零第三方依赖(连 serde 都不加):色彩 / 排版 / 形状 / 高程 / 动效请不要引入任何 crate,测试与构建辅助依赖同样不许加。序列化与运行时能力放到消费方(app / app-iced / presenter)。"
exit 1
fi
echo "OK: crates/design/Cargo.toml 依赖表全空(含 dev / build)"
- name: 依赖树只允许出现自身(cargo tree -p unlucky-design --edges normal)
run: |
set -uo pipefail
if ! tree=$(cargo tree -p unlucky-design --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-design 执行失败 —— crate 是否仍叫 unlucky-design?"
exit 1
fi
echo "$tree"
# 空行不算,剩下的每一行都必须是自己:正常时整个输出只有一行
# `unlucky-design v0.1.0 (/path/to/crates/design)`。
lines=$(printf '%s\n' "$tree" | grep -cE '[^[:space:]]' || true)
others=$(printf '%s\n' "$tree" | grep -vE '^[[:space:]]*$' | grep -vE '^unlucky-design v' || true)
if [ -n "$others" ] || [ "$lines" -ne 1 ]; then
echo "::error file=crates/design/Cargo.toml::unlucky-design 必须零第三方依赖,本应只有自身一行,实际 $lines 行;越线的包:"
printf '%s\n' "$others" | sed 's/^/ - /'
echo "::error::design 是纯数据 + 纯函数的设计令牌:色彩 / 排版 / 形状 / 高程 / 动效请不要引入 serde、rand、once_cell 等任何 crate。序列化与运行时能力放到消费方(app / app-iced / presenter)。"
exit 1
fi
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
# D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。
# M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件,
# 判据是**集合相等**而不是「黑名单命中」:
# - 任何第三个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
unsafe-scope:
name: unsafe 落点守门(集合必须恰好等于期望)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 全仓显式放行 unsafe 的文件集合 == 期望集合
run: |
set -uo pipefail
EXPECTED=$'crates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs'
# 扫描口径(M91 r3):一个小词法器,而不是正则。
#
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
# `['[', '(']`、`"[2"`、`starts_with("[2")` 这类**不配对括号字面量**会让深度永久跑偏,
# 于是该文件后面的一切(包括一个真的 `#[allow(unsafe_code)]`)都再也不会被扫到——
# 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(r3 复审实证:
# 在它尾部加作用域 allow,r2 门禁 exit 0 假绿)。
# 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 /
# 字符字面量),只在「普通态」里认 `#` 或 `#!` 紧跟 `[` 的属性起点,然后只在这一个
# 属性内部累计到它的 `]` 配平——数组字面量跨行不再参与合并,也就不会带偏。
#
# 放行的两种拼法都算:`allow(...)` 与 `expect(...)`。`expect(unsafe_code)` 同样会
# 放行 unsafe 代码(rustc 实测:`#![expect(unsafe_code)]` + `pub unsafe fn f() {}` 在
# `-D unsafe_code` 下编译通过),所以它必须被同一条判据抓住,不能只认 allow。
#
# 仍然抓不住的(已知边界,别当全覆盖):
# - 落点文件**内部**多开别的 unsafe 能力(本门禁只看「有没有放行属性」,不看 unsafe 块个数);
# - **在别处定义、这里只调用**的宏展开出来的 allow:宏体里直接写出的属性在源码文本里,
# 会被扫到;但「宏定义在别的文件、调用点展开出 allow」时,本文件源码里没有那个属性;
# - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行;
# 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码);
# - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml;
#
# r4 回归用例(属性内的 raw string / 字符字面量曾经是盲区;现在两处共用 step(),
# 它们不再是边界,但下面五条必须一直保持红/绿正确 —— 改这段词法器前先跑它们):
# 红 #[doc = r#"a "b"#] 后跟真 allow
# 红 #[cfg_attr(docsrs, doc = r##"a " b "##)] 后跟真 allow
# 红 #[cfg_attr(docsrs, my_macro(39 as char))] 一类含字符字面量/转义的属性后跟真 allow
# 具体形态:#![cfg_attr(docsrs, my_macro('\''))] 后跟真 allow
# 红 以上三者的 CRLF 版本
# 绿 #[doc = r#"x" allow(unsafe_code) is banned "y"#](纯文档,不得误报)
scan() {
awk -v path="$1" '
# ---- 唯一的状态机:主循环与属性内**共用**同一个 step() ----
# 为什么必须复用:r2 是「属性外一套状态机、属性内另写一套」,两套各自演进,
# r3 的属性内就只剩「遇 " 跳到下一个 "」,于是 raw string / 字符字面量在属性内
# 完全不认识 —— `#[doc = r#"a "b"#]` 之后的一切对门禁失明(假绿),
# `#[doc = r#"x" allow(unsafe_code) "y"#]` 这种纯文档被误报(误红)。
# 现在两处只认这一个 step(),属性解析只多一层「[ ] 深度决定何时离开属性」。
#
# 状态:0 普通 / 1 行注释 / 2 块注释(可嵌套)/ 3 字符串 / 4 raw 字符串 / 5 字符字面量。
# 返回值 = 本次消费到的位置(`//`、`/*`、`r#"`、raw 收尾会一次吃掉多个字符);
# g_plain=1 表示该位置处在「普通态代码」里(注释/字符串内容一律 0,
# 所以它们既不会被当成属性、也不会参与属性内的 [ ] 计数与文本匹配)。
function step(s, n, i, c, nx, j, h, ok) {
c = substr(s, i, 1)
g_plain = 0
if (g_state == 1) { if (c == "\n") g_state = 0; return i }
if (g_state == 2) {
if (c == "/" && substr(s, i + 1, 1) == "*") { g_depth++; return i + 1 }
if (c == "*" && substr(s, i + 1, 1) == "/") { g_depth--; if (g_depth <= 0) g_state = 0; return i + 1 }
return i
}
if (g_state == 3) {
if (c == "\\") return i + 1
if (c == "\"" || c == "\n") g_state = 0
return i
}
if (g_state == 4) {
if (c == "\"") {
ok = 1
for (j = 1; j <= g_hashes; j++) if (substr(s, i + j, 1) != "#") ok = 0
if (ok) { g_state = 0; return i + g_hashes }
}
return i
}
if (g_state == 5) {
if (c == "\\") return i + 1
if (c == g_q || c == "\n") g_state = 0
return i
}
if (c == "/" && substr(s, i + 1, 1) == "/") { g_state = 1; return i + 1 }
if (c == "/" && substr(s, i + 1, 1) == "*") { g_state = 2; g_depth = 1; return i + 1 }
if (c == "\"") { g_state = 3; return i }
if (c == g_q) {
nx = substr(s, i + 1, 1)
if (nx == "\\" || substr(s, i + 2, 1) == g_q) { g_state = 5; return i }
g_plain = 1
return i
}
if (c == "r" || c == "b") {
j = i + 1
if (c == "b") {
if (substr(s, j, 1) == "\"") { g_state = 3; return j }
if (substr(s, j, 1) == g_q) { g_state = 5; return j }
if (substr(s, j, 1) != "r") { g_plain = 1; return i }
j++
}
h = 0
while (substr(s, j, 1) == "#") { h++; j++ }
if (substr(s, j, 1) == "\"") { g_hashes = h; g_state = 4; return j }
g_plain = 1
return i
}
g_plain = 1
return i
}
function lex( s, line, n, i, j, k, ch, adepth, attr) {
s = ""
while ((getline line < path) > 0) s = s line "\n"
close(path)
n = length(s)
g_state = 0; g_depth = 0; g_hashes = 0; g_found = 0
for (i = 1; i <= n; i++) {
i = step(s, n, i)
if (!g_plain || substr(s, i, 1) != "#") continue
j = i + 1
if (substr(s, j, 1) == "!") j++
if (substr(s, j, 1) != "[") continue
# 属性:**复用 step()**,只多一层「[ ] 深度决定何时离开属性」。
# 属性里的字符串 / raw 字符串 / 字符字面量因此与属性外同一套识别。
attr = ""; adepth = 0; k = j
while (k <= n) {
k = step(s, n, k)
if (g_plain) {
ch = substr(s, k, 1)
if (ch == "[") adepth++
else if (ch == "]") adepth--
attr = attr ch
if (adepth <= 0) break
}
k++
}
if (attr ~ /(allow|expect)\([^]]*unsafe_code/) g_found = 1
i = k
}
return g_found
}
BEGIN { g_q = "\047"; g_found = 0; exit(lex() ? 0 : 1) }
'
}
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
# `git grep` 零匹配 exit 1 + pipefail → 裸赋值同样会被 -e 静默杀死(见文件头)。
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
if scan "$f"; then printf '%s\n' "$f"; fi
done | LC_ALL=C sort || true)
echo "期望(恰好这两处,集合相等):$EXPECTED"
echo "实际扫到的集合:"
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
if [ "$actual" != "$EXPECTED" ]; then
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
if [ -n "$extra" ]; then
echo "::error::多出来的 unsafe 落点(不在期望集合里):"
printf '%s\n' "$extra" | sed 's/^/ - /'
fi
if [ -n "$(printf '%s\n' "$EXPECTED" | grep -vxF "$actual" || true)" ]; then
echo "::error::期望集合里的落点没被扫到(放行属性被删 / 文件改名搬走?):"
printf '%s\n' "$EXPECTED" | grep -vxF "$actual" | sed 's/^/ - /' || true
fi
echo "::error::unsafe 的 FFI 只允许住在上面的期望集合里(D36 / M196)。新的 unsafe 能力要先立 mission:改守门 + 更新定案,不要顺手放行。"
exit 1
fi
echo "OK: 全仓显式放行 unsafe 的文件恰好等于期望集合"
file-size:
name: 文件行数 ≤400 守门
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 检查单文件行数
run: |
set -uo pipefail
LIMIT=400
failed=0
while IFS= read -r f; do
case "$f" in
LICENSE|*/LICENSE|LICENSE-*|*/LICENSE-*) continue ;;
CHANGELOG*|*/CHANGELOG*) continue ;;
# README 是「对外介绍」,不是可拆分的模块:拆成多份反而更难读,用户令
# (2026-11-05)明确「不用 400 行红线压它,但要保证介绍不臃肿、逻辑完备」。
# 只豁免仓库根的 README*.md;docs/ 下的长文仍按模块拆分。
README.md|README.*.md) continue ;;
esac
n=$(wc -l < "$f")
if [ "$n" -gt "$LIMIT" ]; then
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
failed=$((failed + 1))
fi
# 覆盖范围含脚本(*.py/*.sh):packaging/ 下的构建辅助脚本同样受 400 行约束;
# Cargo.lock 等机器生成文件刻意不在列(否则一生成就超限)
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json' '*.py' '*.sh')
if [ "$failed" -ne 0 ]; then
echo "共 $failed 个文件超限"
exit 1
fi
echo "OK: 全部受检文件 ≤$LIMIT 行"
licenses:
name: 依赖许可与来源(cargo-deny)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2
with:
# 只跑许可/禁用/来源三类,advisories 由后续安全轮次单独接管
# M118:cargo-deny 走 **workspace 依赖图**,`crates/app` 被 exclude 后 Slint 那棵树不再进图
# (实测 `cargo deny list` 里 `slint*` / `i-slint-*` 命中 0 个),所以本门禁不再审 Slint 的
# RF-2.0 许可 —— 与「它不再随任何产物发布」一致。这不是回退:`deny.toml` 里为 Slint 留的
# allow 条目**故意没删**(删它得先确认没有别的路径重新引入),当前等于一条死条目。
# 若将来把 `crates/app` 加回 workspace,本门禁的覆盖会自动恢复。
command: check licenses bans sources
+400
View File
@@ -0,0 +1,400 @@
name: Release / build(可复用)
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
# M145 时本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,改动只有四个矩阵键换成同名入参:
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
# `matrix.rustflags` → `inputs.rustflags`。
# **M164 改了两步**(只这两步):① `Windows ARM64 工具链体检` → `工具链取证`(不再 exit 1,判定权交回编译器;
# 理由见该步上方的长注释);② `cargo build` 步给 arm64 槽位显式备齐 target / `LIB`+`INCLUDE` / arm64 `link.exe`。
# **M184 加了一步**:`linux-x64` 的 xvfb 运行时冒烟(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定)——
# 这一档进了 `PUBLISH_SLOTS`,「发出去的包真能跑」从此由 CI 每轮证明;判据只在 `linux-x64` 槽位上跑(arm64 没有
# 对应的运行时证据,不给它加一条没验证过的门禁)。同一轮把 arm64 的失败收尾注解按槽位分支。
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
# 都是构建产物的实参:前者只影响日志着色,后者决定 panic 是否打回溯)。
on:
workflow_call:
inputs:
slot:
description: "槽位键,同 plan matrix 的 slot(如 windows-x64 / linux-arm64)"
type: string
required: true
os:
description: "runner 标签,同 plan matrix 的 os(如 windows-latest / windows-11-arm)"
type: string
required: true
build_dir:
description: "产物根,同 plan matrix 的 build_dir(六槽位一律 target/release)"
type: string
required: true
rustflags:
description: "RUSTFLAGS,同 plan matrix 的 rustflags(两个 Windows 槽位为 -C target-feature=+crt-static,其余空)"
type: string
required: false
default: ""
# (M149)微软登录用的 Azure 公共客户端 id(公共客户端 id 随二进制一起分发,不按高密级处理,但**不进 git
# 文件**:真值放仓库 Secret,用户在仓库设置里录入)。`required: false` 是刻意的——调用方没传(本地、
# 没有 runner 的镜像站、以及 Secret 尚未录入时)求值为空串,`client_id.rs` 把空白当「没配」,
# 于是非官方构建不烘 id,官方构建才烘。**调用方必须显式传**(`secrets: inherit` 或按名传);
# 没传时这里是空,不会报错。
secrets:
ULL_MSA_CLIENT_ID:
description: "Azure 公共客户端 id,烘进发布二进制(构建期 option_env!);留空 = 不烘"
required: false
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# 与拆分前 build job 拿到的凭据范围一致(可复用工作流的权限只取调用方与本文件的**交集**,故两边都声明 read)。
permissions:
contents: read
jobs:
build:
name: build (${{ inputs.slot }})
runs-on: ${{ inputs.os }}
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
if: runner.os == 'Linux'
shell: bash
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
# (M141/M142 → **M164 判定权移交**)windows-arm64 跑在**原生** `windows-11-arm` 上。本步**只取证,不判定**:
# M141/M142 的版本用 `find` 猜 SDK 的 `um/arm64/kernel32.lib` 在不在,猜缺就 `exit 1` —— 结果 v0.2.1~v0.2.3
# 五轮全红在**这一步**、下面 `cargo build` 一次都没跑过(第 7 步永远 skipped),"arm64 修不好"始终是探针给的
# 结论、不是编译器给的。而它跟自己打的快照自相矛盾:v0.2.3 的注解原文是
# `[arm64sdk] SDK Lib 版本→um 架构: 10.0.22621.0→um:[arm,arm64,x64,x86,] 10.0.26100.0→um:[arm64,x64,x86,]`
# 同一份注解里却报 `kernel32=缺失`(同一个 `find` 模式在本地合成目录树上命中,故这条路本身不可靠)。
# M164 把红绿交回编译器与链接器:本步只打印实况(vswhere 组件查询 / 目录里 .lib **计数** / arm64 link.exe
# 路径),一条 `::error::` 都不写、永不 `exit 1`;红不红由 `cargo build` 与静态 CRT 导入表门禁决定。
# 也不再自动补装:runner 镜像是 Windows 11 Arm64 + Visual Studio 2026(`windows-11-arm` 自 2026-09 切到 VS2026,
# VS 在 `C:\Program Files\Microsoft Visual Studio\18\Enterprise`),其镜像清单
# (actions/runner-images 的 `images/windows/Windows11-VS2026-Arm64-Readme.md`)里 `VC.Tools.ARM64`、
# `Windows11SDK.26100`、`Windows11SDK.22621` 三件都已装好;M142 那次 `setup.exe modify` 反倒返回非 0。
# 注解仍每条一行(job 日志下载要 token,check-run 注解不用;多行会被拼成一行截断)。
- name: Windows ARM64 工具链取证(只打印实况,红绿交给编译器)
if: inputs.slot == 'windows-arm64'
shell: bash
run: |
set +e; set -uo pipefail # -e 由 runner 命令行注入,只能显式关
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; return 0; }
shopt -s nullglob # 计数走 glob 不走 find:find 的管道 + errexit 正是 M141 的死因
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
[ -x "$vswhere" ] || say warning "找不到 vswhere($vswhere):下面会是 0 命中,那不代表工具链缺东西"
# ① 组件:用 vswhere 自己的 `-requires`(权威查询),不拿「某个文件名存在」当替身。
for comp in Microsoft.VisualStudio.Component.VC.Tools.ARM64 \
Microsoft.VisualStudio.Component.Windows11SDK.26100 \
Microsoft.VisualStudio.Component.Windows11SDK.22621; do
hit=""
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -requires "$comp" -property installationPath 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then say notice "组件 $comp: 已装($hit)"
else say warning "组件 $comp: 未装 / 这台 VS 不认这个 id(继续看下面的实际 .lib 计数)"; fi
done
# ② 实况计数:`+crt-static` 要 VC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/*.lib`。
for root in "C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio"; do
hits=( "$root"/*/*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib )
say notice "VC $root → lib/arm64/libcmt.lib ${#hits[@]} 个${hits[0]:+(首个 ${hits[0]})}"
done
for root in "C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"; do
[ -d "$root" ] || { say notice "SDK $root:不存在"; continue; }
for ver in "$root"/*/; do
libs=( "$ver"um/arm64/*.lib )
say notice "SDK $(basename "$ver") → um/arm64/*.lib ${#libs[@]} 个"
done
done
# ③ arm64 链接器候选:宿主 arm64 的优先(原生),x64 托管的次之。下面 cargo 步会显式导出它。
for p in 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
hit=""
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
say notice "link.exe 候选 $p → ${hit:-未命中}"
done
echo "取证完毕:本步不判红;arm64 的结论只看下面的 cargo build / 链接 / 导入表门禁"
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
# (M149)`ULL_MSA_CLIENT_ID` 经 `option_env!` 烘进二进制(`crates/core/src/auth/microsoft/client_id.rs`):
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
# (M164)arm64 槽位在编译前把工具链**显式**备齐,不再依赖「runner 反正自带」这种默契:
# ① `rustup target add aarch64-pc-windows-msvc`:宿主机本来就是 aarch64-pc-windows-msvc,这句在原生
# runner 上是幂等 no-op;它把「缺 std」这类失败挡在编译前(缺 target 时 rustc 只会说找不到 crate std);
# ② MSVC 的 `LIB` / `INCLUDE` 用微软自己的 `vcvarsall.bat arm64` 取(**不手搓路径**:少一个目录就是
# LNK1181),本步 `export` 一次、同时写进 `$GITHUB_ENV` 供下游 dumpbin / 打包步;
# ③ arm64 的 `link.exe` 用 vswhere 定位后显式导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`
# (Hostarm64 原生优先、Hostx64 次之)。**②必须先于③**:只覆盖链接器而不给 LIB,rustc 会在
# `link.exe` 里撞 `LNK1181: cannot open input file 'kernel32.lib'`——那正是我们要定位的错误,不是要制造的错误。
# ①②③ 全是「定位不到就打印原因继续」,不用启发式决定红绿:真缺工具链时让链接器自己报出来。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ inputs.rustflags }}
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
SLOT: ${{ inputs.slot }}
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
echo "host=$(rustc -vV | sed -n 's/^host: //p') slot=$SLOT build_dir=$BUILD_DIR"
if [ "$SLOT" = "windows-arm64" ]; then
rustup target add aarch64-pc-windows-msvc
rustup target list --installed
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
if [ -x "$vswhere" ]; then
# ② MSVC 的 LIB/INCLUDE。**别把 vcvarsall 的路径直接塞给 `cmd //c "..."`**:MSYS 会把参数里的
# `"` 改写成 `\"` 再交给 cmd.exe,而 cmd 的 /c 引号剥离接不住这一层,只会报
# `'\"C:\...\vcvarsall.bat\"' 不是内部或外部命令` —— 结果 LIB/INCLUDE 全空、只剩 `set` 的空输出
# (M164 复审实测)。故本步改为往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat,引号留在
# .bat 自己的 `call "..."` 里(由 cmd 解析,路径带空格也没事),`cmd //c` 只收**不含引号**的
# 相对文件名(先 cd 进去),彻底绕开 MSYS 的引号改写。
tmp=${RUNNER_TEMP:-${TEMP:-/tmp}}
mkdir -p "$tmp"
vcvars=""
# `-find` 是**单值**参数且官方文档给的是反斜杠形态:两种形态各试一次(与下面 dumpbin 步同规矩),
# 只赌一种就等于再写一个「静默退化成 warning」。
for pat in 'VC\Auxiliary\Build\vcvarsall.bat' 'VC/Auxiliary/Build/vcvarsall.bat'; do
vcvars=$("$vswhere" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
[ -n "$vcvars" ] && break
done
dump=""
if [ -n "$vcvars" ]; then
# CRLF 是必须的:cmd 解析只有 LF 的 .bat 会漏行/报错。
{ printf '@echo off\r\n'
printf 'call "%s" arm64 >nul 2>&1\r\n' "${vcvars//\//\\}"
printf 'set\r\n'; } > "$tmp/ull-msvc-arm64.bat"
dump=$(cd "$tmp" && cmd //c ull-msvc-arm64.bat 2>/dev/null | tr -d '\r') || dump=""
echo "wrapper: $tmp/ull-msvc-arm64.bat ← $vcvars(set 输出 ${#dump} 字符)"
fi
for key in LIB INCLUDE; do
value=$(printf '%s\n' "$dump" | sed -n "s/^${key}=//p" | head -n1)
if [ -n "$value" ]; then
export "$key=$value"
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_ENV"
echo "env $key=${value:0:90}…(${#value} 字符)"
else
echo "::warning::[arm64] 没拿到 $key(vcvarsall=${vcvars:-未定位}):留空交给 rustc 自己探测 VS"
fi
done
found=""
for p in 'VC\Tools\MSVC\*\bin\Hostarm64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' \
'VC\Tools\MSVC\*\bin\Hostx64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
[ -n "$found" ] && break
hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
[ -n "$hit" ] && found=${hit//\\//}
done
if [ -n "$found" ]; then
export CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER="$found"
printf 'CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=%s\n' "$found" >> "$GITHUB_ENV"
echo "linker: $found"
else
echo "::warning::[arm64] 没定位到 arm64 link.exe:让 rustc 自己找(真的失败时错误里会带路径)"
fi
else
echo "::warning::[arm64] 没有 vswhere:target 已加,链接器交给 rustc 自己探测"
fi
fi
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
# 产物清单只给 windows-arm64 打:两档已发布的 x64(windows-x64 / linux-x64)这一步零变化,不额外给它们
# 加失败面(linux-x64 的证据在它自己那条 xvfb 运行时冒烟步里)。
if [ "$SLOT" = "windows-arm64" ]; then
ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true
fi
# (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于
# 它进了 PE——资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式
# 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。
# 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见
# `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。
- name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里)
if: runner.os == 'Windows'
shell: bash
env:
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
PY=$(command -v python3 || command -v python)
if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi
# 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
echo "expect-version=$version"
"$PY" packaging/check_pe_version.py "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" --expect-version "$version"
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
EXE: ${{ inputs.build_dir }}/unlucky-launcher-iced.exe
run: |
set -euo pipefail
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
fi
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
for p in "${pats[@]}"; do
for pat in "${p//\//\\}" "$p"; do
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
done
done
fi
if [ -z "$DUMPBIN" ]; then
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
find_pat=()
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio" \
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
fi
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
fi
- name: PE 子系统门禁(GUI=WINDOWS_GUI/2,CLI=CONSOLE/3;判据与理由见 packaging/README.md §15)
if: runner.os == 'Windows'
shell: bash
run: |
PY=$(command -v python3 || command -v python)
"$PY" packaging/check_pe_subsystem.py --gui "${{ inputs.build_dir }}/unlucky-launcher-iced.exe" --cli "${{ inputs.build_dir }}/ull.exe"
# (M184)linux-x64 的**运行时门禁**:这一档已进 PUBLISH_SLOTS,所以「发出去的包真能开窗渲出一帧」得由 CI 每轮证明,
# 不能只靠一次性的本机验收(证据与实测输出见 `packaging/docs/linux-evidence.md`)。
# **只在 linux-x64 槽位上跑**:判据是按 x64 软渲染实测标定的;`linux-arm64` 上这条链一次都没验过(没有 arm64 的
# xvfb 证据),写成无条件的 `runner.os == 'Linux'` 就等于给一个没验证过的槽位加一条会红的判据——正是 M171 那个
# 「豁免行要单独落」的同类事故。宁可这条只在有证据的槽位上真跑。
- name: Linux x64 运行时冒烟(xvfb 真渲一帧 + 主题/后端日志断言)
if: inputs.slot == 'linux-x64'
shell: bash
env:
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends xvfb xauth \
libxkbcommon-x11-0 libx11-6 libxcursor1 libxrandr2 libxi6 libfontconfig1 fonts-dejavu-core
out="$RUNNER_TEMP/linux-smoke"
mkdir -p "$out/data"
log="$out/launcher.log"
# 退出码单独取:`tee` 之后的 `$?` 是 tee 的,管道里 cargo/launcher 的失败会被吞成 0。
set +e
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
"$BUILD_DIR/unlucky-launcher-iced" --software-renderer \
--screenshot "$out/linux-x64-first-frame.png" --data-dir "$out/data" 2>&1 | tee "$log"
code=${PIPESTATUS[0]}
set -e
echo "launcher exit=$code"
# 两条日志断言(两行都是**恒开**的,不依赖 ULL_ACCEPTANCE):
# `[theme] source=…flavor=…follows_system=…` 证明主题是按预期来源定的(截图通道默认 Light),
# `[backend] final=… result=started` 证明渲染后端真的起来了(而不是起来前就崩了)。
grep -q '^\[theme\] source=' "$log" || { echo "::error::缺少 [theme] 行(主题来源没打印)"; exit 1; }
grep -q '^\[backend\] final=.* result=started' "$log" || { echo "::error::缺少 [backend] final=… result=started(渲染后端没起来)"; exit 1; }
python3 packaging/check_screenshot.py "$out/linux-x64-first-frame.png" --json
ls -l "$out"
exit "$code"
- name: 上传 Linux x64 首帧图(失败也传,人眼复核用)
if: always() && inputs.slot == 'linux-x64'
uses: actions/upload-artifact@v4
with:
name: linux-x64-first-frame
path: ${{ runner.temp }}/linux-smoke/
if-no-files-found: warn
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ inputs.slot }}
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
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-${{ inputs.slot }}
path: dist
if-no-files-found: error
retention-days: 14
# (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
# 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。
# M164 起本槽位的红**只可能来自真实构建链路**(体检已降级为取证、永不 exit 1),所以这条注解同时也是
# 「arm64 到底能不能编出来」的答案所在:红了就把上面的编译器/链接器原文当根因,别再回去改体检。
# M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里,
# 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。
- name: arm64 失败收尾注解(红不挡发布)
if: failure() && (inputs.slot == 'windows-arm64' || inputs.slot == 'linux-arm64')
shell: bash
env:
SLOT: ${{ inputs.slot }}
run: |
set -euo pipefail
if [ "$SLOT" = "linux-arm64" ]; then
reason="本槽位只有构建证据、运行时完全未验(没有 ARM64 真机跑过一帧)"
else
reason="失败步骤见本 job 的步骤名(M164 起只会是 cargo build / 链接 / 导入表门禁 / 打包,体检步不再判红)"
fi
echo "::error::[$SLOT] 本槽位已红:$reason。PUBLISH_SLOTS 当前是 windows-x64 + linux-x64 ⇒ 本次 Release 仍照发那两档,$SLOT 的产物不进 Release"
+94
View File
@@ -0,0 +1,94 @@
name: Release guard
# 发布标记语义守门(M163):**push tag 默认创建稳定版**这条不许只靠注释保证。
#
# 判据全部在 `packaging/check_release_semantics.py`,它做四件事:
# ① 标记表:push tag(空串)的 create **不含** --prerelease;dispatch true ⇒ 预发布;
# dispatch false ⇒ create 稳定、edit 显式摘标记;非法取值必须抛。
# ② 真 bash 往返:把 `packaging/release_marker.py --emit-bash` 的产物 source 进 bash,
# 读回 create_mark / edit_mark 两个数组——证明流水线**真跑的那段代码**对,不只是函数对。
# ③ release-publish.yml 的可执行内容里没有 --prerelease 字面量:标记只能从那张表来。
# ④ release.yml 的 dispatch 默认值 == false(读 YAML 结构,不是 grep 字符串)。
# 最后一步 `--self-mutation` 是这条守门自己的变异自测:把「默认值改回 true」「塞回字面量」
# 「表里 push 分支改成预发布」逐项注入临时副本,断言守门**每一项都红**、基线绿。守门自己
# 抓不住回归时它会先红,所以这条断言不会变成装饰品。
#
# 为什么不并进 guards.yml:那份文件已经 398 行(400 行红线),再挂一个 job 必越线。
# 改 `release.yml` 的 prerelease 输入 / 改那段 `gh release` 时,先跑
# `python3 packaging/check_release_semantics.py`(本机可用 `python`,脚本会自己找 bash)。
#
# (M164)同一个 job 还挂**发布名单守卫** `packaging/check_publish_slots.py`:`PUBLISH_SLOTS` 与槽位表/产物
# 必须对得上。PR 阶段跑源码级断言(名单非空、每项都是 `release.yml` 槽位表里的 `mode: ci` 档)——
# 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志;
# `--self-test` 再注入 11 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明 /
# `mode: ci` 却没产物 / 声明的 `linux-x64` 构建红了必须拦住发布 / 提前把 `linux-arm64` 写进名单必须红)证明它会红。
# 发布侧那道「产物 ⇄ 名单」的运行时断言在 `release-publish.yml`(同一份脚本,`--dist`)。
#
# (M169)同一个 job 再挂**测试通道守门** `packaging/check_test_source_off.py`:core/cli/presenter/
# app-iced 各有一条 `update-test-source` feature,打开后 `core::update::job::default_source` 可以被
# 两个环境变量指向任意本地目录——而更新链路的终点是**替换用户盘上的 exe**。它默认关,官方发布产物
# 里不许有它:守门扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、
# `Makefile`/`justfile`/`Taskfile`、各 `Cargo.toml`(`default` 里带上它也红)与 `.cargo/config*.toml`
# (`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`)。**唯一允许**是 `ci.yml` 里名为
# `update-e2e` 的那一步(且必须带 `--test update_e2e_test`,文件名/步骤名/参数任一不符即红)。
# `--self-test` 注入 26 条真实回归 + 6 条防误杀/放行用例(另加基线;条数以 `--self-test` 输出为准),
# 该红的红、该绿的绿。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
release-semantics:
name: 发布标记语义守门(push tag → 稳定版)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 标记表自检(push tag ⇒ 稳定版)
run: python3 packaging/release_marker.py --self-test
- name: 语义断言(标记表 / bash 往返 / 流水线只用表 / dispatch 默认值)
run: python3 packaging/check_release_semantics.py
- name: 变异自测(守门抓不抓得住回归)
run: python3 packaging/check_release_semantics.py --self-mutation
- name: 发布名单守卫自测(声明⇒必须有产物 / 有产物没声明⇒显式警告)
run: python3 packaging/check_publish_slots.py --self-test
- name: 发布名单与矩阵槽位一致(拼错字 / 声明了 probe 档 当场红)
run: python3 packaging/check_publish_slots.py
# M169:`update-test-source` 这条 feature 能让更新源指向任意本地目录,而更新链路的终点是
# 替换用户盘上的 exe。它**默认关**、官方发布产物里不许有它,这条由脚本自动断言(不再靠注释):
# 扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、`Makefile`/`justfile`/
# `Taskfile` 与各 `Cargo.toml`;归属按 `jobs.*.steps[]` 的**列表项**判,落不到具名步骤的行一律红。
# 唯一允许:本仓 `ci.yml` 里名为 `update-e2e` 的那一步(且必须带 `--test update_e2e_test`)。
- name: 测试通道守门自测(注入变异,该红的红、该绿的绿)
run: python3 packaging/check_test_source_off.py --self-test
- name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开)
run: python3 packaging/check_test_source_off.py
# M190:签名脚本与 PE 版本资源门禁自己的自检。前者验 RFC 8032 向量(Ed25519 实现错了就发不出
# 能被验过的签名),后者把「口径表 ⇄ pe_version.rs」「四段式版本号换算」对拍,并造一个没有版本
# 资源的假 PE 断言判据**会红**(否则那道门禁对任何输入都绿,等于没有)。
- name: 清单签名脚本自检(RFC 8032 向量 + 信任表标记解析)
run: python3 packaging/sign_manifest.py --self-test
- name: PE 版本资源判据自检(口径表对拍 + 假 PE 必红)
run: python3 packaging/check_pe_version.py --self-test
# M190 r1 复审 P1-1:`.github/workflows/release-build.yml` 的 sed 替换串里混进过一枚裸 0x01
# (本意是 `\1`)。GitHub 解析这种文件会直接失败 ⇒ **整个 run 起不来**,而所有文本扫描型守卫
# 都看不见它(它们把 workflow 读成文本)。这条用**真 YAML 解析器** parse 全部 workflow,
# 外加一条「C0 控制字符」字节判据(两种失败模式各自独立报 `::error file=…,line=…`)。
# PyYAML 在 GitHub runner 上自带;万一缺失,脚本自己装一次,仍失败则红(门禁不降级)。
- name: workflow YAML 自检(注入 0x01 / 坏结构必须红)
run: python3 packaging/check_workflows_yaml.py --self-test
- name: workflow 全部可被真 YAML 解析器 parse(无裸控制字符)
run: python3 packaging/check_workflows_yaml.py
# M190 r3:`update-test-key` 是**信任锚的开关**(把公开的测试公钥并进信任表)。它只许由
# `crates/presenter` 的 `[dev-dependencies]` 打开;进了发布构建就等于「谁都能签出官方更新」。
# 判据:core 的 `Cargo.toml` 只许自己的声明、presenter 只许 `[dev-dependencies]` 段内、
# 其余(workflow / packaging 脚本 / 根 Cargo.toml / cargo 配置)出现即红。
- name: 测试公钥守门自检(注入 workflow / dependencies / 根 Cargo.toml 三处变异)
run: python3 packaging/check_test_key_off.py --self-test
- name: 测试公钥不进发布构建(只许 presenter 的 dev-dependencies)
run: python3 packaging/check_test_key_off.py
+221
View File
@@ -0,0 +1,221 @@
name: Release / publish(可复用)
# Release 流水线的**发布侧**(M145 拆分:`release.yml` 顶到 400 行红线)。本文件 = 拆分前 `release.yml` 的 `release`
# job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、
# `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、
# tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。
# **M164 新增一步**(其余仍照旧):下载 artifact 之后、裁剪之前的「发布名单守卫」`packaging/check_publish_slots.py`
# —— 原来那份「名单内少一档就 fail」的内联循环搬进那个脚本,并补上反方向(有产物没声明 ⇒ `::warning::` 未发布)。
# 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为):
# ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。
# 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。
# ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个
# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`)。**三种取值各自创建什么**(M163)由
# `packaging/release_marker.py` 一张表定义:空串 ⇒ 新建稳定版、edit 不碰标记;`true` ⇒ 新建成预发布;
# `false` ⇒ 新建稳定版、edit 显式摘掉标记。本文件把那两个数组 `source` 出来用,自己**不写** case;
# `packaging/check_release_semantics.py` 断言「本文件的可执行内容里没有 --prerelease 字面量」。
# 为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是
# **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是
# 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。
# 取 event 上的字符串形态既保住三态,又不需要任何类型转换。
# ③ `PUBLISH_SLOTS` / `runs-on` / `permissions` 的**内容**未动,只是被调方必须自己声明(被调方不继承调用方的
# workflow 级 `env`;权限取调用方与本文件的交集 —— 两边都声明 `contents: write` 才拿得到写权)。
on:
workflow_call:
secrets:
PUBLISH_TOKEN:
description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)"
required: true
# (M190 r1 复审 P1-2)**必须在这里声明**:可复用工作流只拿得到自己声明过的 secret,调用方
# `release.yml` 按名传了这一个,若被调方不声明,它要么在调用处报错、要么静默求值为空串——
# 两种结局都是「用户已把钥录进 Secret,发布仍在签名步红,而错误文案反过来指控 Secret 没配」。
# `required: false` 是刻意的:缺省为空串,由签名步的 `::error::` + `exit 1` 做 fail-closed
# (与 `release-build.yml` 的 `ULL_MSA_CLIENT_ID` 同一模式),失败信息才和事实一致。
ULL_MANIFEST_SIGNING_KEY:
description: "清单签名私钥(JSON:key_id + seed_hex);未配置时签名步 fail-closed、不出 Release"
required: false
# 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。
permissions:
contents: write
jobs:
release:
name: GitHub Release
runs-on: ubuntu-latest
permissions:
contents: write
env:
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
# **M184 起是 `windows-x64` + `linux-x64`**:`linux-x64` 已真机验过 —— release 产物在 Linux 真机(WSLg 真窗口)
# 渲出一帧、主题/后端日志齐全,并有一条同槽位的 xvfb 运行时门禁常驻在 `release-build.yml`(证据与实测输出见
# `packaging/docs/linux-evidence.md`)。进名单的代价是「每轮发版都得真出这个包」:齐全性检查是「名单内少一档就 fail」,
# 所以这条名单同时也是「linux-x64 构建红了就挡住整次发布」的闸 —— 与任务要求一致(声明为 ci 就得真能出包)。
# **`linux-arm64` 不在名单里,且这一轮刻意不放**:它只有构建证据,运行时完全没有证据(没有 ARM64 真机跑过一帧,
# 也没有 arm64 的 xvfb 冒烟)。解锁条件 = arm64 真机(或原生 runner)上真跑出一帧 + 主题日志 → 再把键加进这一行。
# 只加进矩阵不加进名单时,它的产物会被下面的裁剪步删掉,但守卫会先打 `::warning::` 明说「未发布、会被裁掉」,
# 不是静默丢弃(这条是任务里点名要验的反方向)。
# `windows-arm64` 同理仍不在名单(M142/M164:它的五轮红全红在误判的体检步,判定权已交回编译器,等下一 tag 的 CI 证)。
# **可逆,且只是一行**:真出包之后把键加进这一行(`"windows-x64 linux-x64 <新键>"`),齐全性检查与 SHA256SUMS
# 自动跟着扩,再同步 README §1/§9 与 release-notes.md 两张表。
# 判据不再靠注释保证:`packaging/check_publish_slots.py`(下面第二个 step 跑它;PR 阶段由 release-guard.yml
# 跑同一份脚本)两个方向都查——声明了缺产物 ⇒ `::error::` + exit 1;有产物但没声明 ⇒ `::warning::` 明说未发布。
# macOS 两档仍未验证,默认连构建都不跑(M139)。
PUBLISH_SLOTS: "windows-x64 linux-x64"
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
# (M164)发布名单守卫:**声明了就必须有产物**(名单内某个槽位缺 GUI 或 CLI 就红,少一档不发版),
# 反方向的「产物在、名单没声明」则打 `::warning::` 明说未发布 —— M142 之后那个方向是**静默**的(裁剪步只
# echo 一行 `drop:`,没有注解、也没有非零退出),一个真编出来的 arm64 会被就地 `rm` 掉而没人知道。
# 判据全在 `packaging/check_publish_slots.py`:同一份代码本地可跑,`--self-test` 注入 11 条回归断言该红的红。
# 它跑在**裁剪前**的全量集上(裁剪只删名单外的文件),所以是原来「裁剪后再查」那次检查的严格超集。
- name: 发布名单守卫(声明 ⇒ 必须有产物;有产物没声明 ⇒ 显式未发布)
shell: bash
run: |
set -euo pipefail
python3 packaging/check_publish_slots.py --dist dist --slots "$PUBLISH_SLOTS"
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
shell: bash
run: |
set -euo pipefail
# 槽位键 `windows-x64` 落到文件名是 `windows_x64`(asset_stem),故取文件名里的「平台_架构」两段比白名单。
keep=" "
for slot in $PUBLISH_SLOTS; do keep+="${slot//-/_} "; done
for f in dist/*; do
base=${f##*/}
[ "$base" = "SHA256SUMS.txt" ] && continue
platform_arch=${base%.exe}; platform_arch=${platform_arch%.tar.gz}; platform_arch=${platform_arch#*_*_}
case "$keep" in
*" $platform_arch "*)
echo "publish: $base"
;;
*)
echo "drop: $base($platform_arch 不在发布白名单:macOS 两档未构建验证 / 两个 arm64 槽位待真机跑一帧;仍留在 dist-<slot> artifact 里取证)"
rm -f "$f"
;;
esac
done
- name: 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
# 齐全性(名单内每个槽位 GUI 与 CLI 都要在)**已由上上步的「发布名单守卫」断言**,且那一步看的是裁剪前的
# 全量集 —— 同一套规则只留一份实现,免得两处判据漂开。这里只负责重算摘要:各槽位各写一份 SHA256SUMS.txt,
# 合并后只有一份是真的,故在 Release 侧按已发布的资产重算。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
shell: bash
env:
TAG: ${{ github.ref_name }}
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
run: |
set -euo pipefail
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
python3 packaging/make_manifest.py --self-test
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
# 后者在 Release 正文里已有,两处各写一份迟早漂。
python3 packaging/make_manifest.py \
--dist dist \
--version "$TAG" \
--channel stable \
--asset-base-url "$RELEASE_BASE/$TAG" \
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# (M190)清单签名:**签不上就不发版**。私钥只从仓库 Secret `ULL_MANIFEST_SIGNING_KEY` 进环境变量,
# 本步 `set +x` 且只回显**公钥**(日志里永远不出现私钥)。四条命令任一非零 ⇒ 本步红 ⇒ 不出 Release:
# ① `--self-test`:RFC 8032 向量(签名脚本自己没坏)
# ② `--check-pinned-keyring`:Secret 里的钥**必须**已在 `crates/core/src/update/trust.rs` 钉住
# (签出一份客户端不认的清单,比不发版更糟)
# ③ `--sign`:签的就是上一步落盘的 `dist/manifest.json` 原始字节,产出 `dist/manifest.json.sig`
# 红了的处置见 `packaging/docs/signing.md` §4 那张表(缺什么、怎么补,都写在脚本的 stderr 里)。
- name: 签更新清单(Ed25519;签不上就阻断发布)
shell: bash
env:
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
run: |
set -euo pipefail
set +x
PY=$(command -v python3 || command -v python)
if [ -z "$PY" ]; then echo "::error::找不到 python,签名步骤无法运行(签名门禁不降级)"; exit 1; fi
"$PY" packaging/sign_manifest.py --self-test
if [ -z "${ULL_MANIFEST_SIGNING_KEY:-}" ]; then
echo "::error::仓库 Secret ULL_MANIFEST_SIGNING_KEY 没配(或为空):没有签名私钥就不发版。"
echo "::error::补法见 packaging/docs/signing.md §3:python3 packaging/sign_manifest.py --generate-key,把打印的 JSON 贴进 Secret、把 PinnedKey 那行贴进 crates/core/src/update/trust.rs"
exit 1
fi
"$PY" packaging/sign_manifest.py --check-pinned-keyring
"$PY" packaging/sign_manifest.py --sign dist/manifest.json
# ④ 产出后**用 trust.rs 里钉住的公钥回验一遍**:复现「客户端会怎么判」,而不是信签名脚本
# 自己那句「签好了」。私钥不需要参与,所以这一步跑在同一个环境里也不多一个泄露面。
"$PY" packaging/sign_manifest.py --verify-pinned dist/manifest.json
ls -l dist/manifest.json dist/manifest.json.sig
- 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.PUBLISH_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ github.event.inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# ── 预发布标记:判据只在 packaging/release_marker.py(M163),本文件不写 case ──
# 三种触发各自创建什么(那张表是唯一真相源):
# push tag(PRERELEASE 为空) → create 不带标记(**稳定版**)、edit 不碰标记
# dispatch prerelease=true → create / edit 都带预发布标记
# dispatch prerelease=false → create 不带标记、edit 显式摘掉预发布标记
# 空值必须是稳定版:GitHub 的 releases/latest 只指向**非 prerelease** 的最新 Release,而
# 自更新清单固定取 `releases/latest/download/manifest.json`(D50)—— push tag 打成预发布,
# 更新通道就是 404(v0.2.2 至今如此,M163 修的就是这条;v0.2.2 那个 Release 保留为历史预发布)。
# 先落文件再 source,不用 `source <(...)`:非法的 prerelease 取值要让这一步当场红,
# 进程替换里的失败会被 `set -e` 吞掉。
marks=/tmp/release-marks.sh
python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks"
source "$marks"
echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})"
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
# `manifest.json.sig` 必须与清单一起上传:客户端取清单后取它旁边的签名(`GitHubReleaseSource`
# 拼的地址是 `<产物根>/manifest.json.sig`),漏传等于让所有人都验签失败(M190)。
assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/manifest.json.sig 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
+155
View File
@@ -0,0 +1,155 @@
name: Release
# 发布流水线:tag `v*` → **默认构建四个槽位**(原生 `windows-latest` / `windows-11-arm` / `ubuntu-22.04` /
# `ubuntu-22.04-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带
# `--prerelease`,否则 `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、
# 不碰标记)。
# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M184 起是 `windows-x64` + `linux-x64`**。
# `linux-x64` 本轮翻牌的依据在 `packaging/docs/linux-evidence.md`:x86_64 槽位在 `ubuntu-22.04`(glibc 2.35,与本地
# WSLg 验证同一地板)真编出 release 产物、真开窗渲出一帧,`release-build.yml` 里还有一条同槽位的 xvfb 运行时门禁
# ——「声明为 ci 就必须真能跑出帧」,不是「矩阵里写了就算支持」。
# **`linux-arm64` 本轮只构建、不发布**:它没有任何运行时证据(没有 ARM64 真机跑过一帧,也没有 arm64 的 xvfb 冒烟)。
# 解锁条件写在 `packaging/release-notes.md` 与 `packaging/docs/compliance-support-boundaries.md` §9 ——真机跑一帧之后
# 才把键加进 `PUBLISH_SLOTS`,不拿没跑过的包去发(M184 tower 裁决)。
# `windows-arm64` 仍留矩阵里取证、红了不挡 x64 发布(M142/M164:它那五轮红全红在一个误判的编译前体检步,该门禁已拆,
# 判定权交回编译器)。`macos-*` 两档从未验证,默认连构建都不跑(M139:省配额、不留红叉);整表六槽位取证走
# `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。
# 名单 ⇄ 产物两个方向都有守卫(M164):`packaging/check_publish_slots.py`(PR 阶段在 release-guard.yml,发布侧在
# release-publish.yml 的 `--dist` 模式)——声明了缺产物 ⇒ 红;有产物没声明 ⇒ `::warning::` 明说未发布。
# 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease`
# 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
#
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链取证(M164 起只取证)/
# cargo build / 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
# (M164 在裁剪前插了一步「发布名单守卫」:主线 7 步,本分支 8 步。)
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
all_slots:
description: "构建全部六个槽位(含未验证的 macOS 两档);默认只跑四个(Windows 两档 + Linux 两档)"
type: boolean
default: false
prerelease:
# 默认 false(M163):这是「新建 / 改写 Release 打不打预发布」的唯一开关,默认值必须落在稳定版上——
# Actions 页面点一次 "Run workflow" 就是这条默认值在决定新版本进不进稳定通道。
# 三种触发各自创建什么由 packaging/release_marker.py 一张表定义,守门在
# packaging/check_release_semantics.py(`.github/workflows/release-guard.yml` 跑它)。
description: "标记为 pre-release(默认 false = 稳定版;仅在 tag ref 上 dispatch 时才生效)"
type: boolean
default: false
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy`
# 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。
plan:
name: 选槽位(默认四个:Windows 两档 + Linux 两档)
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.pick.outputs.matrix }}
steps:
- name: 生成 build 矩阵(`all_slots=true` 才整表六槽位)
id: pick
shell: bash
env:
ALL_SLOTS: ${{ inputs.all_slots }}
run: |
set -euo pipefail
python3 - "$ALL_SLOTS" >> "$GITHUB_OUTPUT" <<'PY'
import json, sys
# 槽位表(唯一真相源)。mode:"ci" = 默认在 CI 里构建;"probe" = 未验证,仅 all_slots=true 时构建。
# **没有交叉槽位**(M140):每槽在自己的架构上原生构建,故无 `target` 键、产物一律在 target/release。
# 要加回交叉槽位:`target` 键 + `build_dir=target/<triple>/release` + `rustup target add` 与 cargo 的 `--target`。
SLOTS = [
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,双击即用的单文件。
dict(slot="windows-x64", os="windows-latest", build_dir="target/release", rustflags="-C target-feature=+crt-static", mode="ci"),
# Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费)。该标签自 2026-09 起是
# **Windows 11 Arm64 + Visual Studio 2026** 镜像(VS 在 `C:\Program Files\Microsoft Visual Studio\18`),
# 镜像清单里 `VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都装好了。M140 前是在 windows-latest 上交叉;
# v0.2.1~v0.2.3 的红全红在 M141/M142 那个**编译前体检**步(cargo 永远 skipped),M164 已把它降级为只取证。
# 原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release",
rustflags="-C target-feature=+crt-static", mode="ci"),
# ↓ Linux 两档(M184):**都改为 `mode: ci`** —— tag 推送默认就编,不再只在 `all_slots=true` 的取证跑里出现。
# Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移(22.04 = glibc 2.35,与本地 WSLg 验证同一地板)。
# 两档的差别只在「有没有运行时证据」,而这件事由 `PUBLISH_SLOTS`(发布侧那一行)决定,不在这里:
# `linux-x64` 已真机验过(release 产物在 WSLg 真窗口渲出一帧 + 同槽位的 xvfb 门禁,证据见
# `packaging/docs/linux-evidence.md`)⇒ 进名单;`linux-arm64` 只有构建、**运行时完全未验**(没有 arm64 真机
# 跑过一帧)⇒ 不进名单,产物照出 artifact 供取证,靠 `check_publish_slots.py` 打 `::warning::` 明说未发布。
# **别把 arm64 直接加进名单**:解锁条件是真机跑一帧,不是「构建绿了」。
dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="ci"),
dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="ci"),
# ↓ 两档 macOS **未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编
# (将来验证 macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS +
# 同步 README §1/§9 与 release-notes.md。
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
dict(slot="macos-arm64", os="macos-latest", build_dir="target/release", rustflags="", mode="probe"),
dict(slot="macos-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"),
]
keep = [s for s in SLOTS if s["mode"] == "ci" or sys.argv[1] == "true"]
print("matrix=" + json.dumps({"include": keep}, separators=(",", ":")))
print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr)
PY
# 构建实体在 release-build.yml(`workflow_call`)。矩阵仍由上面 `plan` 生成、**在外层展开**:`strategy.matrix`
# 必须在调用侧求值(被调方只按单个槽位的四个入参跑),所以 `matrix.*` 只出现在这个 job 的 `with:` 里。
build:
name: build (${{ matrix.slot }})
needs: plan
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
# 可复用工作流的权限只取「调用方 ∩ 被调方」,且不继承调用方的 `env`:两边都声明 read(与原 build job 一致)。
permissions:
contents: read
uses: ./.github/workflows/release-build.yml
with:
slot: ${{ matrix.slot }}
os: ${{ matrix.os }}
build_dir: ${{ matrix.build_dir }}
rustflags: ${{ matrix.rustflags }}
secrets:
# (M149)微软登录的公共客户端 id:值来自仓库 Secret,被调方把它烘进发布二进制(`option_env!` 注入)。
# 可复用工作流拿不到调用方未显式传入的 secret,故必须按名传(与下面 PUBLISH_TOKEN 同一模式,
# 不用 `inherit`:那会把仓库全部 secret 一起透进被调方)。Secret 未录入时求值为空串,被调方把空白
# 当「没配」——产物只是不带 id,不会拿空 id 去联网。
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
release:
name: GitHub Release
needs: build
# `always()` + `!cancelled()`:白名单外的槽位(`windows-arm64`、`linux-arm64`,以及只在 `all_slots` 取证跑里
# 存在的 macOS 两档)红了**不能连坐**那两档已发布的 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:
# 白名单里少任何一个才 fail(M184 之后 `linux-x64` 也在白名单里,它红了就该拦)。
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
if: always() && !cancelled() && github.ref_type == 'tag'
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。
permissions:
contents: write
uses: ./.github/workflows/release-publish.yml
secrets:
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# (M190)清单签名私钥:被调方 release-publish.yml 用它签 `manifest.json`。没配时是空串,
# 签名步会带着「缺什么、怎么补」的注解当场红(宁可不出 Release,也不出未签名的 Release)。
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
+121
View File
@@ -0,0 +1,121 @@
name: Signing status(播报,不是门禁)
# 第一句就是口径:**这是播报,不是门禁。它红了不代表不能合,它绿了也不代表签名就绪。**
# 就绪度的结论只在 job summary 的三行字里(`packaging/check_signing_ready.py --line` 写的),
# 不在 job 的颜色里。**本 workflow 不应被设为 required check**(GitHub 侧分支保护里不要勾它)。
#
# 为什么要有它(M255):M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION`
# 表在 `PINNED-KEYS` 标记之间是空的,而空表一律 `KeyringEmpty` 拒装 ⇒ 今天任何正式二进制都验不过任何
# 清单(fail-closed,用户端表现为「更新永远失败」)。工具链早就齐了,缺的是**可见性**:这三行读数让
# 「今天差什么」在每次运行里都摆在台面上,而不是等发版时才发现。
#
# 口径四条(由 `packaging/tests/signing_ready_wiring_selftest.py` 的接线判据机器校验,不是靠注释自律):
# ① 就绪度那一步抓 `rc=$?` 之后**恒 `exit 0`** —— 未就绪用 `::warning::` 播报,不用 error 级注解
# (error 会让人以为构建挂了,与播报定位直接冲突)。真正的红留在 `release-publish.yml` 的签名步:
# 那里今天必然红是**对的**,因为签不出来的产物本来就不能发。
# ② 文件名/名字都不带 guards、check 字样:它不是守卫,是一条状态播报。
# ③ 本文件**刻意不声明 `workflow_call:`**:没有这个触发器,别的 workflow 就没法 `uses:` 它 ——
# 「不能被接进 dev 线变成必需检查」是一条机制,不是一句嘱咐。接线自检另外扫全部 workflow,
# 任何 `uses: …/signing-status.yml` 出现即红(防的是有人先加 workflow_call 再接进来)。
# ④ **生产签名种子只在 `refs/heads/main` 上注入**,且**没有 `pull_request` 触发**(M255 r1 P2-1)。
# 这条是本文件唯一的**安全**口径,别为了「PR 上也能看读数」把它放开:
# - 仓库内分支的 PR / 任意分支的 dispatch **拿得到**仓库 Secret(只有 fork / Dependabot 拿不到);
# - 而这把种子能给**全量已安装客户端**签出被信任的清单(自更新链 = 用户端 RCE),
# 既有设计把它锁在 tag ref 后面(`release.yml` 的 `if: … github.ref_type == 'tag'`)。
# - 播报**不需要**在 PR 上跑:PR 改了 `check_signing_ready.py` 时,CI 会在那个 PR 自己的代码上执行它,
# 若同时注入种子,等于把种子交给任何能推分支的人。
# 门由两行 env 表达(同一个条件、同一个地方可审):钥给 main,非 main 则给 `ULL_SIGNING_KEY_WITHHELD`
# 一个原因串 —— 于是 ② 读成「未判定」而不是「没配」(把策略说成缺配是一次假读数)。
#
# 判据本体与 0/1/2 的语义:`packaging/check_signing_ready.py` 的文件头;
# 人类要做的那几步:`packaging/docs/signing-runbook.md`。
on:
push:
branches: [main]
# ⚠ 刻意**没有** `pull_request`(见口径④)。要读 PR 的签名就绪度,就跑 push 到分支或对着 main dispatch。
workflow_dispatch:
permissions:
contents: read
jobs:
# 尺子自测:它红 = **判据脚本坏了**(不是「签名没就绪」)。两者措辞在 summary 里分开写。
# 这一步照常会红,因为「尺子坏了」本来就该有人看见 —— 但它不阻断任何东西:本 workflow 独立、
# 非必需,也不在 `dev-guards.yml` 的 `uses:` 里。
selftest:
name: 判据自测(三行/退出码/变异重放/接线)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 判据本体(三行 / 0/1/2 / 两向退出码变异 / --key 用法错 / 指针章节)
run: python3 packaging/tests/signing_ready_selftest.py
# 接线单独一份(400 行红线):播报不是门禁、**生产种子只在 main 注入**、文档指针真的存在,
# 以及「去掉 ref 门 / 加回 pull_request / 改坏文档路径 ⇒ 必须红」三处变异重放。
- name: 接线(ref 门 / 不被别的 workflow 复用 / 无 error 级注解 / 文档路径存在性 + 三处变异)
run: python3 packaging/tests/signing_ready_wiring_selftest.py
- name: 真跑三种情形(今天的状态 / 假 PinnedKey 副本 / 用法错)
run: |
set -uo pipefail
work="$RUNNER_TEMP/signing-ready"
mkdir -p "$work"
# 结论只打印,不判定:这里要的是「三种情形各自的退出码与三行读数长什么样」,
# 判据在上一跑的 selftest 里。所有 `|| true` 只为让这一步本身不变红。
run_and_show() { # run_and_show <说明> <命令...>
note="$1"; shift
echo "── $note"
python3 packaging/check_signing_ready.py "$@" 2>&1 || true
echo
}
run_and_show "今天的状态(真实 trust.rs:表空 ⇒ 未就绪)"
# 副本夹一条假 PinnedKey(**不碰真实 trust.rs**):造出「就绪」的正路长什么样。
python3 - "$work" <<'PY'
import sys
from pathlib import Path
trust = Path("crates/core/src/update/trust.rs").read_text(encoding="utf-8")
begin = trust.index(">>> PINNED-KEYS >>>") + len(">>> PINNED-KEYS >>>")
end = trust.index("<<< PINNED-KEYS <<<")
fake = ' PinnedKey { key_id: "demo-not-a-real-key", public_key_hex: "' + "ab" * 32 + '" },\n'
Path(sys.argv[1], "trust-with-fake-key.rs").write_text(
trust[:begin] + "\n" + fake + trust[end:], encoding="utf-8"
)
PY
run_and_show "副本夹一条假 PinnedKey(②仍缺钥 ⇒ 仍未就绪,但 ① 会变就绪)" \
--trust "$work/trust-with-fake-key.rs"
run_and_show "用法错(不存在的 trust.rs ⇒ 2)" --trust "$work/nope/trust.rs" || true
# 播报:只把三行搬进 job summary,不看颜色。
status:
name: 签名就绪度(播报)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ② 要读「环境里有没有钥」,所以把 Secret 传进来 —— **只在 main ref 上**(口径④)。
# 门是下面这两行 env(同一个条件),不是 `if:`:这样非 main 的运行照样跑完三行读数,
# 只是 ② 读成「未判定」,而不是整个 job 被跳过后一片空白。
# ⚠ 这两行是安全边界,接线自检钉着它们(去掉 ref 门 / 加回 pull_request 都会当场红)。
- name: 三行就绪度(脚本给 0/1/2;本步恒 exit 0)
env:
ULL_MANIFEST_SIGNING_KEY: ${{ github.ref == 'refs/heads/main' && secrets.ULL_MANIFEST_SIGNING_KEY || '' }}
ULL_SIGNING_KEY_WITHHELD: ${{ github.ref == 'refs/heads/main' && '' || '非 main ref(按设计不注入 Secret;要读这一行请对 main 跑)' }}
run: |
set -uo pipefail
set +e
python3 packaging/check_signing_ready.py --line > "$RUNNER_TEMP/signing-ready.txt" 2>&1
rc=$?
set -e
cat "$RUNNER_TEMP/signing-ready.txt"
case "$rc" in
0) echo "::notice::签名就绪:信任表非空,且环境里的钥与它同源。这是一条播报,不是门禁。" ;;
1) echo "::warning::签名未就绪(或 ② 未判定):该做什么见 packaging/docs/signing-runbook.md §2 的下单清单。" ;;
*) echo "::warning::就绪度脚本用不了(rc=$rc,用法错):这是尺子的问题,不是签名状态 —— 看上面的 stderr。" ;;
esac
{
echo
echo "- 本 workflow 是**播报**,不是门禁:**不应被设为 required**,也不阻断任何其它 job。"
echo "- 颜色 ≠ 结论:绿色只说明脚本跑完了;结论只看上面三行(尤其 ①「信任表」)。"
echo "- **Secret 只在 \`refs/heads/main\` 上注入**(生产签名种子锁在 main / tag 后面);非 main ref 的 ② 显示「未判定」而不是「没配」。"
echo "- 真门禁在哪:\`release-publish.yml\` 的签名步(\`--check-pinned-keyring\` / \`--sign\` / \`--verify-pinned\`)。"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
+37
View File
@@ -0,0 +1,37 @@
# Reference source repositories (local-only, git-ignored)
/HMCL/
/PCL/
# Local development docs
/DevDocs/
# Local acceptance screenshots (screenshot channel output, never committed)
/.screens/
/.tmp-screens/
# Agent workspace (local-only)
/.tower/
# Build outputs
build/
dist/
out/
target/
# Python bytecode (packaging/*.py 本地跑过就会生成;提交它会让每次 spawn 都打 WIP 快照)
__pycache__/
*.pyc
# IDE
.idea/
.vscode/
*.iml
# OS
Thumbs.db
.DS_Store
# 签名私钥:绝不入库(M255 手册产出的本地副本)
.signing/
**/signing-secret*.json
**/ull-signing/
+5
View File
@@ -0,0 +1,5 @@
edition = "2021"
max_width = 100
newline_style = "Unix"
use_field_init_shorthand = true
use_try_shorthand = true
+58
View File
@@ -0,0 +1,58 @@
# 更新日志
> 自上而下由新到旧。版本号与 `Cargo.toml` 同源;二进制 `unlucky-launcher --version` 打同一个号。
> 只记**对外可感知的变化**(用户能看到、能用到、能感觉到稳的),不记内部重构流水账。
---
## 0.2.4 — 未发布(当前 `dev`)
这一批的主线是「把存档区做成真正能用」,以及把验证方式从「看起来对」改成「能被证伪」。
**存档**
- 存档列表可重命名(行内输入,回车即提交)、删除(二次确认)、备份导出为 zip
- **游戏运行时写操作被拦下并给出明确原因**——此前在 Linux 上删一个正在跑的世界会成功
- 截图区从「一行文件名」变成**缩略图墙**:真解码、最大边 160px、单次最多 60 张、单张超 8 MiB 跳过;「未生成预览」与「文件过大」是两种不同的格子,失败逐条可见
**外观**
- **强调色自定义真的生效**:填了色,按钮 / 进度条 / 图标跟随;中性底与描边不动(既定语义)
- 非法色值给红框 + 结构化原因,且**不写盘**;手打过程中不闪红,离开输入格或按回车才判
- 三处组件配色改走主题角色(描边、进度轨道、Elevated 底色),不再对着设计稿比色值
**交互**
- 单行输入框支持**回车提交**:存档重命名、强调色两处接上;未接的既有调用点行为不变
**发布与验证**
- GPU 截图证据门禁接进 CI:无 GPU 时**显式跳过**,「跳过」与「通过」分开呈现,不会伪装成已验证
- 新增**签名就绪度**播报与操作手册:一条命令看清「签名还差什么」;播报只播报,不是门禁
- 修掉一处会让 CI 把**生产签名种子**交给 PR 代码的接线(仓库内分支的 PR 拿得到仓库 Secret)
- 合并态全量测试在每次合并后的 tip 上重跑,不再只看定向验证
**已知问题**
- **自动更新链路是断的**:正式签名信任表为空,任何发布二进制都验不过(fail-closed)。通道本身已接到底,缺的是「一把被钉住的公钥 + 一份签名 Release」
- Ink 主题下 Elevated 卡片层级几乎看不出(对比约 1.03:1)
- Windows 路径在界面上仍显示正斜杠
- macOS 两档构建仍是 `probe`,从未真构建、也没在真机跑过
---
## 0.2.3 — 2026-11-08
- 首次进入**稳定更新通道**(`latest` 指向本版;此前的版本只在预览通道)
- 断点与续传、镜像选路、崩溃弹窗与现场日志导出等前序能力在本版收敛完成
> 注:`main` 采用孤儿快照发布,`v0.2.3..HEAD` 之类的区间日志不可用;本条目按发布记录与 README 口径编写。
---
## 0.2.1 — 2026-11-06
- 早期公开版本:安装、启动、日志与设置四项主流程可用
- 具体条目从简(发布记录以 tag 为准)
---
## 0.2.0 — 2026-11-05
- 首个公开预览版本
- 具体条目从简(发布记录以 tag 为准)
Generated
+6506
View File
File diff suppressed because it is too large. Load diff
+171
View File
@@ -0,0 +1,171 @@
[workspace]
resolver = "2"
members = [
"crates/core",
"crates/agent",
# M85(D26):UI 框架无关的宿主层。Slint 版(crates/app)与 iced 版(crates/app-iced)
# 共用它;Slint 退役(M118 起 `crates/app` 已不是 workspace 成员)后只剩 iced 依赖,本 crate 不受影响。
"crates/presenter",
"crates/cli",
"crates/design",
# P4(D26 iced 重写)第一步:与 Slint 版 `crates/app` 并存的新 UI crate。
# bin 名 `unlucky-launcher-iced`,翻牌时才改名(见 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md)。
# M118 起它是发布矩阵里**唯一**的 GUI 来源:发布了 Slint 版的那个 bin 名属于被排除的 `crates/app`。
"crates/app-iced",
# M87(D40 第 2 层):配色取色的解码适配层。**唯一**允许引入 `image` 类依赖的地方,
# 使 `crates/design` 能继续宣称「零第三方依赖」。
"crates/palette-extract",
# M92(D28):国际化地基——语言探测 / 中英消息目录 / 中文字体策略。零第三方依赖、零 UI 依赖,
# 壳层(iced 版现在,别的壳将来)与 presenter 都依赖它,所以它不能认任何一个渲染框架。
"crates/i18n",
]
# M118(D26):`crates/app` 是 Slint 退役树 —— UI 已迁到 iced,目录与源码**原样保留**作历史
# 参考(另一份参考资产见 DevDocs/ops/slint-reference/),但不再进默认构建与发布矩阵。摘出去的
# 是整棵 slint 1.18 + usvg 0.48 依赖树:本机单路冷编 ≈14 分钟,而 CI 的 `--workspace` 每次都会
# 编它,既拖长反馈,又会在并行超配时随机 OOM(见 DevDocs/00-踩坑记录-p2.md T7)。
# 排除 ≠ 删除,但**它确实不再可编**:该 manifest 整份都是 `*.workspace = true` 继承写法,
# 一旦不是 workspace 成员,继承无源、manifest 解析即失败(实测:
# `cargo metadata --manifest-path crates/app/Cargo.toml` → "error inheriting `edition` …")。
# 要读这一版实现请直接读源码,别再尝试构建它。
exclude = ["crates/app"]
[workspace.package]
version = "0.2.4"
edition = "2021"
license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"]
repository = "https://github.com/AskaEth/Unlucky-Launcher"
rust-version = "1.92"
publish = false
[workspace.dependencies]
# 共享依赖(版本在此集中,各 crate 用 `xxx.workspace = true` 引用)
# M15(core::diagnose)首批使用者:规则正则 / 规则 YAML 解析 / 诊断结果序列化。
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# serde_yaml 上游已停维护(RUSTSEC-2024-0320),改用维护中的 API 兼容分支(tower 定案)
serde_yaml_ng = "0.10"
# M13(core::download):HTTP + 异步运行时 + 流 + 校验哈希。
# rustls(ring)而非 native-tls —— 免 OpenSSL/系统证书栈依赖,MinGW 与 Win7 都省事。
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
tokio = { version = "1", features = [
"rt-multi-thread",
"fs",
"io-util",
"time",
"sync",
"net",
"macros",
] }
futures-util = "0.3"
sha1 = "0.10"
sha2 = "0.10"
# M31(core::auth):离线账户 UUID 必须与 Minecraft 服务端/HMCL 的
# `UUID.nameUUIDFromBytes` 逐字节一致 = MD5 + v3 位改写,因此需要 MD5。
# 与 sha1/sha2 同属 RustCrypto(Apache-2.0 OR MIT),版本对齐 digest 0.10。
md-5 = "0.10"
# M29(core::launch::natives):读 natives jar(zip 容器)解压原生库。
# 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。
zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] }
# M190(core::update::ed25519):清单签名**验签**用官方实现,不再自写曲线算术。
# M190 r1 复审的裁定:信任锚不许用自写密码学(非规范编码/小阶点/批量验证差异的后果是
# 「验签通过但本不该通过」)。BSD-3-Clause,在 deny.toml 白名单内。
# 只开 `std`(不用 `rand_core`/`batch`/`fast` 这类可选面):本仓的签名侧在
# `packaging/sign_manifest.py`,二进制里只有「验」这一半。
ed25519-dalek = { version = "2", default-features = false, features = ["std"] }
# M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`,
# 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。
flate2 = "1"
tar = "0.4"
# M87(D40):配色取色的解码适配层。版本与 `crates/app-iced` 的截图通道对齐(同一份
# `image` 只编译一次);编解码器由各 crate 自己按需开 feature,这里只钉版本。
image = { version = "0.25", default-features = false }
# UI 层唯一入口(D2:Slint,RF 2.0 轨道)。
# 默认特性已含 backend-default + renderer-femtovg(GL)+ renderer-software(CPU 软渲染兜底,
# Win7 老机无可用 GPU 时的降级路径,见 DevDocs/PlanDoc/01c-native-gui-options.md §6)。
slint = "1.18"
# app 的 build script 用(把 ui/main.slint 编译成 Rust 代码),版本必须与 slint 主 crate 同步。
slint-build = "1.18"
# agent 层依赖(rig / rmcp)按 D4 在 M16 引入,脚手架期不引入以免版本冲突。
# 版本锁定策略见 DevDocs/PlanDoc/02-ai-agent-design.md §0:rig 锁 =0.45.x,rmcp 3.8.x。
#
# M39 的 MCP server(02 §0「传输定论」):rmcp 官方 SDK(Apache-2.0),**锁精确版本**——
# pre-1.0 且 ~6 天一个版、3.x 内已多次改 server API。只开需要的传输 feature:
# - `server`(default 带)+ `transport-io`(stdio,给 llama.cpp 内置 MCP client)
# - `transport-streamable-http-server`(主力:127.0.0.1 上的 Streamable HTTP)
# `macros` 一并留在 default 里(`#[tool]` 宏不用,但 default 拆开易漏 base64 等隐式依赖)。
rmcp = { version = "=3.8.0", features = [
"transport-io",
"transport-streamable-http-server",
] }
# rmcp 的 Streamable HTTP server 是个 tower Service,官方示例用 axum 挂监听(MIT)。
# 只吃 `nest_service` + `serve`,不引入任何 TLS/模板/DB 生态。
axum = "0.8"
[workspace.lints.rust]
unsafe_code = "deny"
missing_docs = "warn"
rust_2018_idioms = { level = "warn", priority = -1 }
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"
# P6(M96):日常迭代 / 本机验收用的「快」档,发布仍走上面的 release。
# `inherits = "release"` 让其余各项(strip / debug / overflow-checks …)照抄 release,
# 只覆盖拖慢构建的四处:
# - lto = "thin":跨 crate 内联仍有,但可以并行;fat LTO 在 codegen-units = 1 下等于
# 全程序串行链接(375 个 crate 排队),一次性吃掉十几分钟。
# - codegen-units = 16:给 codegen 留并行度,不再把整个 crate 挤成一个 CGU。
# - opt-level = 2:介于 "z" 与 3 之间,"z" 会为了体积关掉一批循环/内联优化,代价是运行期。
# - panic = "unwind":release 是 panic = "abort",inherits 会照抄。留着它是为了让 fast 档编出来的
# **二进制**走 unwind(panic 会栈展开,不是直接 abort)。
# ⚠️ 理由不是「不写就跑不了测试」:`cargo test` **不**依赖这一行 —— cargo 对 test 目标自己强制
# unwind。实测 `cargo test --profile release -p unlucky-i18n`(release = abort 档)照跑,23 个
# 测试全过,且 test 单元的 rustc 参数里**没有** `-Cpanic=`。别拿「cargo test 能跑」当这行生效的证据;
# 要看就用 `cargo build --profile {fast,release} -v` 比 rustc 参数:release 有 `panic=abort`,fast 没有。
#
# ⚠️ 定位(实测,别误读):本机(12 核 + 机械盘源码盘 + -j 4)冷编译 fast = 24m27s,
# **比 release 的 16m01s 还慢**,体积是 2.3 倍(36.41 vs 15.72 MiB GUI)。瓶颈在两个大
# UI crate 的编译 + 链接 + 磁盘 IO,LTO/CGU 这几个旋钮被掩盖了。它的价值在 **SSD 机器**和
# **增量重编**(thin 不必每次全程序 LTO);「加了 fast 档」≠「冷编译会变快」。
[profile.fast]
inherits = "release"
opt-level = 2
lto = "thin"
codegen-units = 16
panic = "unwind"
# P6(M96):依赖不需要为体积做极致优化,但**实测结论与直觉相反**——把依赖的 opt-level 从
# release 的 "z" 抬到 3(本档最初就是这么定的)会让冷编译**更慢**、体积还更大:
# 同机冷编译 opt-3 = 22m45s / 19.46 MiB(GUI),opt-"z" = 16m01s / 15.72 MiB(两臂同为 cu16)。
# 所以这里**opt-level 保持 "z",只把并行度放到 16**;workspace 自己的 crate 仍吃 "z" / 单 CGU。
# ⚠️ **未测**:cu1 → cu16 的收益本轮没有同条件对照(两臂都是 cu16,唯一自变量是 opt-level),
# 所以「cu16 才是收益来源」不是实测结论,要归因得单独跑一轮 cu1 对照。另:GUI 体积相对旧基线
# 涨了 12.9%(13.92 → 15.72 MiB),其中「代码增长」与「依赖 cu16」也没分离。
# **只调优化强度**:panic 与 strip 绝不写进这里。那两项改变的是整个二进制的运行时行为
# (panic 策略决定能否 unwind 出栈、strip 决定符号表留不留),不是「给依赖放宽」的范畴。
[profile.release.package."*"]
opt-level = "z"
codegen-units = 16
# `inherits = "release"` 会把 `[profile.release.package."*"]` **一起继承过来**(实测:删掉下面这块,
# fast 档的外部依赖就从 opt-level 2 退回 `"z"`;workspace 成员不受 `*` 覆盖、仍是 fast 自己的 2)。
# 所以这块**必须存在**,它是承重墙不是冗余兜底 —— 删掉会让 fast 档的依赖静默退回 `"z"`。
[profile.fast.package."*"]
opt-level = 2
codegen-units = 16
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 AskaEth (YeiJ)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+66
View File
@@ -0,0 +1,66 @@
Unlucky-Launcher (ULL)
Copyright 2026 AskaEth (YeiJ)
本产品采用 Apache License 2.0(见 LICENSE)。
--------------------------------------------------------------------------
第三方组件声明
--------------------------------------------------------------------------
1. Slint(https://slint.dev)——**历史源码,不随任何产物分发**
本产品**当前发布的 GUI 框架是 iced**(见第 3 节),二进制里不含任何 Slint 代码。
仓库里仍保留 Slint 版 GUI 的源码(`crates/app`),仅作历史参考:它已被 cargo workspace
`exclude`,不参与构建、测试与发布,也不会进入本项目的任何 Release 资产。
因此 Slint Royalty-Free 2.0(LicenseRef-Slint-Royalty-free-2.0)**对当前产物不适用**;它适用的是
此前已发布、且确实链接了 Slint 的 `v0.1.0` 构建——义务挂在每一次「随应用分发」上,而不是挂在某个
版本号上。上游许可 §2 给出两种形式:(a) 在可从顶层菜单进入的「关于」页/对话框里显示官方
`AboutSlint` 部件(没有该页时改为启动画面);(b) 在可下载该二进制的公开网页上显示官方归属徽章。
**`v0.1.0` 的设置页「关于」分组里内置了 `AboutSlint` 部件**(`crates/app/ui/pages/settings.slint`,
从侧栏进入);该部件是否满足 §2(a) 的全部条件、以及 `v0.1.0` 发布页上那是文字链接而不是 §2(b) 点名的
官方徽章图这两点,**本文件不作法律判断**,具体以 Slint 官方许可原文为准。`v0.1.0` 的 NOTICE、发布页
说明与本文件这一节**都只是记录,不构成履行声明**。后续版本不再链接 Slint,该义务不随新产物新增;
本文件保留这一节,是为了不让这段历史丢失,而不是声明本产品依赖 Slint。
若将来重新把 `crates/app` 纳入构建并发布链接 Slint 的二进制,该义务随之恢复——届时需同时恢复
AboutSlint 部件/徽章与本节的现行声明。Slint 的其余许可分支(GPL-3.0-only / Slint Software
License 3.0)本项目始终不适用。
2. Google Material Color Utilities(https://github.com/material-foundation/material-color-utilities)
本产品的色彩令牌(crates/design 的 color 模块)是 Material 3 色彩算法(HCT 色彩空间 +
ColorSpec2021 调色板/角色映射)的 **Rust 重写移植**,依据 Apache License 2.0 使用,未把其任何语言的源码作为依赖
引入;除下面注明的那张生成常数表外,也没有复制其代码文件。
实际移植的部分(均为纯算法,逐函数对照后以 Rust 重写):
- `hct/Cam16.java`、`hct/ViewingConditions.java`、`hct/Hct.java` → `crates/design/src/color/cam16.rs`、
`viewing_conditions.rs`、`hct.rs`(CAM16 色相/彩度 + CIELAB `L*` 亮度)
- `hct/HctSolver.java` → `crates/design/src/color/hct_solver.rs` + `critical_planes.rs`
(色域映射求解器;其中 255 项 `CRITICAL_PLANES` 常数表是原样搬运的生成数据)
- `utils/ColorUtils.java`、`utils/MathUtils.java` → `crates/design/src/color/space.rs`
- `palettes/TonalPalette.java`、`dynamiccolor/ColorSpec2021.java`、`dynamiccolor/DynamicScheme.java`
→ `crates/design/src/color/tonal.rs`、`palettes.rs`、`scheme.rs`(Tonal Spot 变体的彩度常数与
49 个角色的 tone 映射)
对齐验证:`crates/design/tests/official_baseline.rs` 与 `tests/official_accent.rs` 的期望色值,
是把上述官方 Java 实现(Apache-2.0)在本机编译运行后输出的结果抄本——合计 290 项逐通道
比对(baseline 49 角色 × 2 模式 = 98 项;强调色 8 个种子 × 12 个强调族角色 × 2 模式 = 192 项),
误差上限 1(实测为 0)。两文件的表头各写着自己的分项口径。
Apache License 2.0 全文见 https://www.apache.org/licenses/LICENSE-2.0
上游版权声明:Copyright Google LLC。
3. Rust crates 生态
当前 GUI 框架:iced 0.14(MIT),连同 winit(Apache-2.0)、wgpu(MIT OR Apache-2.0)、
tiny-skia(BSD-3-Clause)——它们都在下面的白名单内。
cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC /
CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。
GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。
4. 参考实现致谢
本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2
(https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。
+446
View File
@@ -0,0 +1,446 @@
# Unlucky Launcher (ULL)
**English** · [简体中文](README.zh-CN.md)
> **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **Version in this README: `0.2.4`** — `unlucky-launcher --version` prints the same number.
Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust
(Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no
WebView — and covers the launcher job from installing a version to reading a crash report: instances
and versions, mod loaders and modpacks, mods / resource packs / shaders, per-instance Java, Microsoft
/ third-party / offline accounts, and a crash diagnosis that runs locally on a YAML rule engine.
Nothing of yours goes through a service of ours: account tokens stay on the machine.
![The instances page: the local instance list on the left; on the right the selected instance's
version, loader, isolation mode and last-played time](docs/images/instances-en-light.png)
*The instances page — 1180×760, English UI, light theme. The instance names are test data, not
anybody's real setup. That `Launch failed · exit0 静默失败` line is also an honest sight of a known
gap: the reason text is still raw Chinese straight out of the launch layer (see
[Not done yet](#not-done-yet)).*
## What works today
### Instances, versions and launching
- **Instances** — a list-detail page: the local instance list on the left; on the right the selected
instance's version, loader, isolation mode (an isolated game directory or the shared one),
files-on-disk state and last-played time, with Play / Stop. Narrow windows switch panes instead of
stacking them.
- **Where an instance comes from** — installing a version (with a name, loader and loader version)
or importing a modpack. The instances page's own **New instance / Import / Duplicate / Open folder
/ Delete** buttons are **not wired yet**: they are placeholder actions that only write a line to
stderr (`crates/app-iced/src/app/update/page_messages.rs`), and the instance wizard is the pending
piece (`crates/app-iced/src/pages/mod.rs`, deviation #8).
- **Version management** — a three-column rail (version type / major version / search) over the
Mojang version list: release, snapshot and ancient versions, plus loader selection and a loader
version when you install. Cancelling an installation really stops it — the in-flight file is cut, the
job ends at the next stage boundary, and no instance is registered
(`crates/presenter/src/host/dispatch.rs:61-88`). The CLI can plan the same install without
downloading (`ull install --dry-run`: per-role file counts, total size, and one source-chain sample
per file category; the full file list is in `--json`).
- **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and
game arguments, a native-library pass, and a JSONL session record for every run. Process trees are
killed as trees, not as one PID.
- **Per-instance launch arguments** — JVM arguments, game arguments, memory, the Java executable and
the graphics backend (`default` / `opengl` / `vulkan`) live in the instance file under `launch.*`,
and every field falls back on its own: instance → global setting → factory default, so an instance
that only sets memory does not wipe the global JVM arguments. JVM arguments are *appended* (global
first, instance last, so the JVM's last-one-wins rule makes the instance win). An out-of-range
memory value counts as "not set" instead of producing `-Xmx0m`, an empty field means "follow", and
the backend row only appears for game versions that have it. The CLI and the GUI read the same
resolved values (`crates/core/src/game/launch_overrides.rs`).
- **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals
(exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the
mod list), matches them against a YAML rule set, and shows the findings with fix actions on the
logs page. No model, no network: it works offline and on a machine with no GPU.
### Mod loaders, packs and the content library
- **Loaders** — Fabric, Quilt, NeoForge and Forge. Forge/NeoForge versions that need the official
installer `processors` really run them, with a managed JDK if the machine has none.
- **Modpacks** — import a CurseForge manifest, a Modrinth `.mrpack`, or a MultiMC/Prism instance;
export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the
panel closes and the result is discarded, but the import thread runs to the end, because `core`'s
import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`).
- **Import conflicts are decided per file** — before anything is written, the scan lists every
conflicting file (what it is, why it conflicts, how big it is) in pages of 40, and three entries
decide the batch: keep all, overwrite all, or choose per item — unpicked rows stay on "keep". Files
that do not conflict are never rewritten, and the report still names every conflict afterwards.
- **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version,
loader and sort filters, paged results, and one-click *install into this instance*.
- **Online source: Modrinth, anonymously** — search goes through `/v2/search` with facets taken from
the target instance (project type, game version, loader); project details and version lists follow,
and a remote entry whose hash is known is completed through `/v2/version_file/<hash>`. No API key,
and no POST: the download engine has no request-body field, so hashes are looked up one GET at a
time. Being offline is its own state — "not connected" — never "no matching content".
- **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the
library (packs go through the import pipeline above); CurseForge's *install* is disabled because
its file endpoint is not wired; and an item whose game version or loader does not match the target
instance is disabled with the mismatch spelled out. Nothing fails silently.
- **CurseForge search needs a key** — set `ULL_CF_API_KEY` before launching; without it that source
is greyed out in the source filter. The key stays in the environment: it is not written to the
settings file and not logged (`crates/presenter/src/host/addon.rs`).
### Java
A managed JDK per instance, with a four-vendor fallback chain ordered by region — Temurin first, then
Oracle before Microsoft inside mainland China (measured 28× faster there) and Microsoft before Oracle
elsewhere, Zulu last — overridable through the `java.vendorPriority` setting
(`crates/core/src/game/java/vendor.rs`). Mirrors are region-aware too. `java list` shows every JDK it
can actually see (including the ones the official launcher downloaded), `java install <major>` fetches
one. The game's JDK is the launcher's data, not its own dependency.
### Accounts
Three tracks on one page:
- **Microsoft** — OAuth 2.0 device code flow → Xbox Live → XSTS → `api.minecraftservices.com`.
- **Third-party community auth servers** (authlib-injector style).
- **Local offline profiles**, for isolated testing.
**Microsoft sign-in works out of the box in official builds.** Release binaries carry the Azure public
client id they need (a public client id, not a credential — it ships inside the binary), so there is
nothing to configure before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` still overrides it: the
variable is read at startup, so restart the launcher after changing it. Building from source bakes in
whatever that variable holds at build time (`ULL_MSA_CLIENT_ID=<id> cargo build`). A build with no id
anywhere stops before any network call, and its alert says exactly what is missing: the title
"Microsoft sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with
it, then restart), and `Code: msa_not_configured`. It offers no Retry button, because retrying in that
same process cannot help. Every other sign-in failure keeps the older shape: one category line plus
its own `Code:` line, with the full reason only in the launcher log. Community auth servers and local
profiles need no id.
### Look and language
- **One UI stack** — iced 0.14 + Material 3 Expressive, a self-drawn title bar, an animated
navigation rail, light / dark / follow-system.
- **Appearance** — window background image with a colour pipeline (panel colours can be derived from
the wallpaper), per-panel transparency, and whole-window transparency on Windows. On Windows 11
and Server 2025 the system is asked for rounded corners; macOS and Linux leave corners to the
desktop's own rules; nothing is drawn by hand.
- **CPU fallback** — `--software-renderer` keeps the interface rendering on machines with no usable
GPU or broken drivers, and the backend probe falls back on its own when no adapter is usable. On
that path panel-level translucency and blur are switched off (window-level transparency still
works) and the settings panel says so.
- **Localisation** — system-language detection, an explicit override in settings, and persistence
across restarts. Most copy comes from one message catalogue (`crates/i18n`).
**Boundary, stated because it is easy to over-read that sentence**: the anti-hard-coding guard
(`crates/app-iced/src/pages/instances/tests/i18n.rs`) scans the instances page sources only, not
every screen; a hard-coded *Chinese* literal has no ASCII word for it to match, so nothing catches
that class; and the appearance panel's older controls are still hard-coded English
(`crates/app-iced/src/pages/appearance/section.rs`), so a Chinese UI still shows them in English.
### The assistant, and the MCP server
These are two different things and the README should not blur them:
- **The in-app assistant is a placeholder shell today.** Its transport is scripted
(`crates/app-iced/src/presenter/transport.rs`), no real model is connected, and the UI says so in
four places on every screen it appears — subtitle, input placeholder, empty-state body, and a
"Simulated" chip. The session state, unread count, draft, confirm flow and named failure codes are
real presenter state; only the model behind them is not there yet.
- **The MCP server is real.** `ull agent serve` exposes the launcher core to external agent tools as
an MCP server over stdio or Streamable HTTP, loopback only (`127.0.0.1`, default port 47823),
read-only unless `agent.mcp.allow_write=true`. The same ten tool specs (`crates/agent/src/tools/`)
feed both shapes, and both go through the same executor — schema validation, sensitivity tiers,
audit trail. `ull agent config` prints ready-made Claude Code / Cursor snippets.
### Command line
The GUI binary also answers every CLI subcommand **before any UI code runs** — no window, no renderer
probe, no font scan. A second, lighter `ull` artifact serves the same command set from a binary that
links no graphics stack at all.
```bash
unlucky-launcher # GUI
unlucky-launcher --software-renderer # GUI, CPU renderer
unlucky-launcher instances list # CLI, no window
unlucky-launcher --json launch 1.20.1 # CLI, global options before the command
unlucky-launcher --version # CLI, version + OS summary
```
The dispatch rule is deliberately narrow, so nothing that used to work changed meaning:
- The first argument is a bare word (not `-…`) → it sits in the subcommand position, so the CLI gets
it. GUI flags never appear there: `--screenshot` / `--diff` / `--data-dir` take their values
*after* the flag.
- The first argument is a flag → the line is a CLI call only if `crates/cli`'s parser also produces a
real command (`--help`, `--version`, `--json instances list`). `--data-dir X` alone parses to "no
subcommand", so it still opens the GUI; every GUI-only flag (`--software-renderer`,
`--screenshot*`, `--renderer`, `--theme`, `--diff`, `--force-state`, …) fails CLI parsing, so it
still opens the GUI.
**Unknown subcommands are an error, not a GUI launch**: `unlucky-launcher lst` prints "未知命令
`lst`", hints `unlucky-launcher --help`, and exits 1. Silently opening a window would tell a script
that its typo took effect. The subcommand table lives in exactly one place
(`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help`
lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`,
`diag`, `config`, `java`, `update`, `agent`, `version`, `help`.
### Updates, and the stable channel
- **Self-update is real, and GUI and CLI share one core.** The settings panel's Update group and
`ull update check` / `ull update apply` both build the same `core::update::Job`
(`crates/core/src/update/`): fetch the manifest at `releases/latest/download/manifest.json`, compare
versions, admit the channel, verify length + SHA256, then swap the binary atomically — rename the
old file aside, move the new one in, roll back if that fails. A running Windows exe can be
*renamed* but not overwritten, which is why the swap is a rename.
- **Four outcomes, never conflated**: up to date / update available / a newer version exists but ships
no artifact for this platform / the check itself failed (offline, broken manifest, channel
mismatch). A failed check is never reported as "up to date".
- **Only the stable channel is published.** `update.channel` defaults to `stable`; beta / alpha /
nightly are modelled and reserved. A manifest is admitted only when both its declared channel and
the version's own pre-release stage agree — a manifest can lie, a version number cannot.
- **Why a release has to be marked stable**: `releases/latest/download/*` only points at the newest
**non-prerelease** release, and the manifest address is pinned there. `0.2.3` is the first release
created without the pre-release flag (`0.2.2` stays a historical pre-release). Which trigger creates
which marker — push tag ⇒ stable, dispatch `prerelease=true` ⇒ pre-release — is one table
(`packaging/release_marker.py`), with a guard over it (`packaging/check_release_semantics.py`).
- **Nothing installs itself behind your back.** The check at startup can be turned off
(`update.auto_check`, the same key the CLI reads), and installing is a button or an explicit
`ull update apply`. Debug builds can rehearse the whole chain against a local directory source
(`ULL_UPDATE_DIR`); release builds do not contain that path at all.
### Where the logs are
Everything the launcher writes on its own side lives under `<data root>/logs/`. The settings page has
a **Log folder** row that shows that path with *Open folder* and *Copy path*; the logs page header has
*Open folder* as well. The data root is `<exe folder>/.ULL` in portable mode, `<per-user app data>/ULL`
in standard mode, and `<custom root>/.ULL` in custom mode — the same value the settings page displays
as the launcher data directory.
| Path | What it is |
|---|---|
| `logs/launcher.log` | The launcher's own runtime log: boot probe, font/theme decisions, backend choice, **the named reason when a render backend fails to start**, and every launch attempt (command line facts, spawn result, exit). Rolled over at 1 MiB into `logs/launcher.1.log`, exactly **one** old copy kept, so it cannot grow without bound. |
| `logs/launcher.1.log` | The previous 1 MiB, replaced on the next rollover. |
| `logs/game/<session-id>.jsonl` | One JSONL event stream per launch: the command line (secrets redacted), every stdout/stderr line, exit code. |
| `logs/game/index.jsonl` | The one-line-per-session index the logs page lists. |
| `logs/install.log` | Install pipeline journal, one line per stage. |
| `logs/session-log-location.json` | A record of where session logs live and where they used to live. Rewritten on every start. |
| `data/agent/sessions/game/…` | **Read-only compatibility**: sessions written by older builds. They are still listed and still openable; nothing is moved and nothing is deleted. |
The game's own files are not in there — they sit in the instance's run directory: `logs/latest.log`,
`crash-reports/crash-*.txt`, and `hs_err_pid*.log`. **`crash-reports/` only exists when the JVM lived long
enough to write one.** If a mod, a missing library, or a wrong Java kills the game before Minecraft
initialises, the game never gets that far, and the honest evidence is the session JSONL plus
`launcher.log` — not a crash report. That is how the game behaves, not something the launcher is
hiding.
### Not done yet
This list is the reason to believe the rest. None of it is hidden behind marketing copy.
- **No installer.** You download a file and run it — there is no setup step, and updates replace that
file in place instead of installing anything (see [Updates](#updates-and-the-stable-channel)).
- **The update path has only been exercised against a local directory source.** The whole chain
(manifest → download → SHA256 → rename/rollback) ran with a debug build and a fixture, because the
real `releases/latest/download/manifest.json` was unreadable while `0.2.2` was still a pre-release.
The first observation on the live stable channel happens after `0.2.3` ships.
- **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*,
*Open folder*, *Delete*. In the settings frame: *Save*, *Reset
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
management from the GUI is the instance-wizard work still outstanding, and settings change as you
edit them rather than on a Save.
- **Linux x64 is published, and "published" means "it runs", not "it looks finished".** The release workflow
builds `linux-x64` on a native Ubuntu 22.04 runner by default and ships it, because the release artifact itself
was started under a real display server (WSLg), opened a window, rendered a non-blank frame and printed its
theme/backend lines — and that same check is now a per-slot CI gate on every release
(`packaging/docs/linux-evidence.md` has the raw output). Nobody has yet looked at it on a real desktop
distribution: dragging, resizing, font metrics and CJK shaping on a real desktop session are unverified.
Two things from the Windows list are simply not code paths on Linux, so there is nothing to verify: whole-window
transparency needs a native window handle the app does not have there (`hwnd=none`), and rounded corners are
delegated to the desktop compositor (`corner=policy=system`) instead of being drawn by the app.
`linux-arm64` is built (native `ubuntu-22.04-arm`) but **not** published — no ARM64 machine has ever run it, so
the artifact stays as build evidence only.
- **macOS is not built by the release workflow, and it is not finalised.** It has two rounds of real-device
evidence (Apple Silicon), which is why the build exists at all — but the menu-bar name, the placement of the
three window buttons (macOS puts them top-left; ours are drawn top-right), and 300s+ steady state still need a
real machine. macOS builds are unsigned, so the first launch needs the Gatekeeper quarantine attribute cleared
(`xattr -d com.apple.quarantine`). The macOS runtime smoke job is a manual `workflow_dispatch` probe with
`continue-on-error`, not a gate; `gh workflow run release.yml -f all_slots=true` builds the two slots for
evidence, but they are never published.
- **The content library is cramped at low window heights**, and the window height is not wired into
the page model yet — the page lays itself out for a nominal height rather than the one it gets.
- **Launch-side failure text is not structured.** The tile takes a Chinese sentence straight from the
launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could
translate — which is exactly the `静默失败` you can see in the English screenshot above.
- **The in-app assistant has no model behind it** (see above). The MCP server does.
- **Install progress is stage-level**, not byte-level, on the content-library install path.
- **No native file chooser.** Import takes drag-and-drop or a pasted path.
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
[`README.termux.md`](README.termux.md)); it was verified on-device twice: once on CPU software rendering, once with GPU acceleration via a self-built Mesa/turnip stack (see the evidence in README.termux.md).
## Download and run
Grab the file for your platform from
[Releases](https://github.com/AskaEth/Unlucky-Launcher/releases). Names follow
`unlucky-launcher_<version>_<platform>_<arch>`; the single source of truth for that shape is
`packaging/make_artifacts.py::asset_stem()`:
| Slot | Asset | Shape |
|------|-------|-------|
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory; glibc 2.35+ and the X11/Wayland + fontconfig stack |
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **built, not published**, see below |
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same — **built, not published** (no runtime evidence yet) |
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not built** by the release workflow |
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not built** by the release workflow |
| every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
**This release publishes `windows-x64` and `linux-x64`, and nothing else.** The publish job keeps only the slots
named in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before it
writes `SHA256SUMS.txt`, so a row marked **not published** is a naming rule, not a downloadable asset. A guard
(`packaging/check_publish_slots.py`) watches all three relations: a slot named in the list whose artifacts are
missing turns the release red (that is also what makes a red `linux-x64` build block the release), artifacts that
exist for an unlisted slot get an explicit "not published" warning instead of being deleted quietly, and a
`mode: ci` slot that produced nothing at all warns too — a red slot must never be visible only as a missing row.
Of the unlisted rows, CI builds two. `windows-arm64` is built natively on GitHub's `windows-11-arm` runner
(Windows 11 Arm64 + Visual Studio 2026); every ARM64 failure so far came from a pre-build toolchain probe that
aborted before `cargo build` ran, that probe is now evidence-only, and the next CI run is the first real answer to
whether the slot can produce a binary. `linux-arm64` is built natively on `ubuntu-22.04-arm` (same glibc 2.35 floor
as x64) and is unpublished for a different reason: **nothing has ever run it on ARM64 hardware**. One frame plus the
theme/backend log lines on a real ARM64 machine is the unlock condition; until then the artifact is build evidence.
Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level
directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
documents are attached to the release next to it — downloading the Windows binary does not strip the
licence. Download links never carry implementation detail in the name, and the binary inside an
archive is always called `unlucky-launcher`.
On Linux x64 that is the whole install:
```bash
tar -xzf unlucky-launcher_<version>_linux_x64.tar.gz
cd unlucky-launcher_<version>_linux_x64
./unlucky-launcher # add --software-renderer if there is no usable GPU adapter
```
It asks for glibc 2.35+ (the slot is built on Ubuntu 22.04), `libxkbcommon`, `fontconfig` and `libGL`; on
Debian/Ubuntu `sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6`
covers the load-time set, and the bare `ull_<version>_linux_x64` beside the archive needs no display at all.
The two shapes are not duplicates. The GUI binary needs the graphics stack when it opens a window (it
`dlopen`s X11 / Wayland / GL and links `libfontconfig` at load); `ull` needs nothing beyond glibc, so
it runs on a server or a CI container where no window can exist. On Linux that floor is glibc ≥ 2.35
(the slots build on Ubuntu 22.04) — an older distribution needs a source build
(`packaging/README.md` §4). Pick `ull` when there is no desktop, `unlucky-launcher <command>` when
there is. Both print the same CLI text and each names itself after the file you ran — `argv[0]` with
its directory and a trailing `.exe` removed, nothing else (`crates/cli/src/cli/program.rs`), so the
`ull` asset prints `ull ·`, the binary inside an archive prints `unlucky-launcher ·`, and a local
`cargo build` prints `unlucky-launcher-iced ·`.
Minimum platform baseline: **Windows 10**; Windows 8.1 / 8 / 7 are not supported. macOS 12+ on Apple
Silicon and Intel, Linux x64 / arm64 (`crates/core/src/platform/os_info.rs` holds the baseline
check). On Windows 10/11 the builds use the standard toolchain (MSVC / GNU), built the same way as
upstream cargo. On Linux, X11 is the preferred path today and Wayland is the long-term direction.
Capability differences are feature gates with runtime probing, never a compile-time "this platform
gets less".
First run with a Microsoft account? Nothing to configure — official builds carry the client id; see
[Accounts](#accounts). Nothing else needs configuring.
## Build from source
Prerequisites:
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; the gates also need the
`rustfmt` and `clippy` components)
- Windows: MSVC toolchain (what CI uses) or MinGW-w64 (`x86_64-pc-windows-gnu`)
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
libxcb1-dev` — winit + wgpu/tiny-skia ask for these at build time
```bash
cargo fmt --all --check # formatting
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
cargo test --workspace # tests
cargo build --release # binaries land in target/release/
cargo deny check licenses bans sources # dependency licences (required in CI)
```
What the build produces:
- A build from source carries no Microsoft client id by default. Set `ULL_MSA_CLIENT_ID` at build time
to bake one in (`ULL_MSA_CLIENT_ID=<id> cargo build`); official release binaries already carry one
— see [Accounts](#accounts).
- `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed
above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that
same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary
`unlucky-launcher` inside the archive.
- `ull` — the CLI as a standalone, UI-free binary. Same command set, no graphics stack, smaller, and
the right artifact for a machine that has no display at all.
Rendering switches:
- `unlucky-launcher-iced --software-renderer` forces tiny-skia (`ICED_BACKEND=tiny-skia`);
`--screenshot <png>` writes one frame and exits — that is the acceptance channel CI's headless
smoke job uses
- With no usable adapter the iced build's explicit probe falls back to the CPU path on its own
**CI gates.** Every push to `main` and every pull request runs fmt / clippy (warnings are errors) /
the full test suite, and the Linux headless render smoke is a hard gate on the same footing: it
renders one real frame under Xvfb, then asserts the PNG is not blank, and a red result blocks the
merge. The macOS probe is not there yet — manual dispatch only, result recorded but not enforced.
| Workflow | Content |
|----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
| `.github/workflows/release.yml` | tag `v*` → build the four default slots (`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` and `linux-x64`; `linux-x64` also gets a per-slot Xvfb first-frame gate; `-f all_slots=true` builds all six slots for evidence) |
| `.github/workflows/release-guard.yml` | release-marker semantics: push tag ⇒ a **stable** release, dispatch `prerelease=true` ⇒ pre-release; the table lives in `packaging/release_marker.py` and the guard mutation-tests itself |
## Architecture and module map
```
Unlucky-Launcher (cargo workspace)
├── crates/core # domain library: zero UI dependencies, eight modules
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one core, two shapes)
├── crates/presenter # business host layer: framework-free state / host / dto / conversion
├── crates/design # design tokens: real HCT color / type / shape / spacing / elevation / motion / icons
│ # / component metrics — zero UI dependencies, no third-party crates
├── crates/i18n # language detection, message catalogue, CJK font policy
├── crates/palette-extract # the only crate that decodes images, for the wallpaper colour pipeline
├── crates/app-iced # the iced UI (bin `unlucky-launcher-iced`): the only GUI the workspace builds
├── crates/cli # CLI (bin `ull`): headless, zero UI
└── packaging # single-file artifact matrix and release scripts
```
Layering: `app-iced` → `presenter` → `core` / `agent`. `design` and `i18n` are consumed by the shell
and the presenter; none of them may depend on a rendering framework, and CI guards enforce it.
The eight modules of `crates/core`:
| Module | Responsibility |
|--------|----------------|
| `setting` | three data-directory modes, schema-based config, atomic writes, migrations |
| `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading |
| `game` | version / instance repository, snapshots and inheritance resolution |
| `launch` | 10-stage launch pipeline, processes, JSONL event stream |
| `auth` | Microsoft sign-in (OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services), third-party community servers, local profiles |
| `addon` | mod loader installation, modpack import, CurseForge / Modrinth, pack export |
| `diagnose` | five signal collectors, YAML rule engine, hs_err parsing |
| `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection |
## License and acknowledgements
- **This project**: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in
[NOTICE](NOTICE).
- **iced** (<https://github.com/iced-rs/iced>), MIT: the framework of the UI, together with `winit`
(Apache-2.0) and, through it, `wgpu` (MIT / Apache-2.0) / `tiny-skia` (BSD-3-Clause).
- **Google Material Color Utilities** (Apache-2.0): the color tokens in `crates/design` are a Rust
port of its HCT / ColorSpec2021 algorithms; details and the exact ported functions are listed in
[NOTICE](NOTICE).
- **Other major dependencies**: `rmcp` and `axum` (Apache-2.0 / MIT) for the MCP server, `tokio`
(MIT), `reqwest` with rustls (MIT / Apache-2.0), `serde` / `serde_json` (MIT / Apache-2.0),
`zip` / `flate2` / `tar` (MIT / Apache-2.0). The allow-list cargo-deny enforces is in `deny.toml`;
GPL-family dependencies may not enter the main line.
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and [PCL2](https://github.com/Meloong-Git/PCL)
(both GPL-3.0). They have been with the vast majority of Minecraft players for years, and this
project has benefited a great deal from their design choices and implementation details.
+59
View File
@@ -0,0 +1,59 @@
# Unlucky Launcher · Termux 适配(`termux-glibc` 分支)
这条分支把启动器带到 **Termux(Android)**:用 glibc 工具链编出 `aarch64-unknown-linux-gnu`
二进制,在 **termux-x11** 里跑 GUI;同时补上 Termux 平台识别、系统 JDK 安装与中文字体。
> 给主线的改动清单、兼容性说明与合并指南见
> [`TERMUX-ADAPTATION.md`](TERMUX-ADAPTATION.md)。
## 已验证
| 项 | 状态 | 证据 |
| --- | --- | --- |
| 源码构建(GUI + CLI) | ✅ | Rust 1.99.1 gnu + clang/lld + glibc 运行时;GPU 构建(wgpu)默认带 CPU 兜底 |
| 窗口显示 | ✅ | termux-x11 `:0`,横屏 1657×1067,首帧 ~60 ms(GPU) |
| 中文 | ✅ | 启动时注册 `/system/fonts/NotoSansCJK-Regular.ttc` |
| 微软登录 id | ✅ | 构建期注入,日志 `client_id=configured source=build` |
| 横屏适配 | ✅ | 默认窗口按 X 屏幕等比自适应(`source=termux-screen`) |
| Java 发现 | ✅ | `JAVA_HOME`/PATH + `$PREFIX/lib/jvm` 扫描 |
| Java 自动安装 | ✅ | `ull java install 17` 由内置镜像装完 openjdk-17(约 20 s) |
| GPU(wgpu) | ✅ | 自编 Mesa 26.2.6 + KGSL;wgpu 选中 Adreno 840(Vulkan/turnip) |
| Minecraft 运行 | ✅ | 真机 1.20.4 跑通:glibc JRE + arm64 natives + softpipe GL,主菜单在 X11 渲染 |
## 快速开始
完整配方(工具链、patchelf、linker、门禁)见
[`packaging/docs/termux-glibc-build.md`](packaging/docs/termux-glibc-build.md)。最短路径:
```sh
termux-x11 :0 -ac &
DISPLAY=:0 ./unlucky-launcher-iced # 默认 auto:有驱动走 GPU,没有则自动降级 CPU
```
- GPU 驱动不随 Termux 源提供,需要按构建文档 §5 自己编一次 Mesa 26 + ICD;
不装也能跑,只是 `auto` 会落到 tiny-skia(CPU)。
- 想强制某一侧:`--renderer=gpu` / `--software-renderer`。
- 窗口尺寸默认按屏幕自适应;`ULL_WINDOW_SIZE=1520x900` 可覆盖。
- Java:`ull java install 17|21|25` 在 Termux 上默认用系统包(内置镜像 + 隔离 apt 状态)。
- **数据根**:Portable 模式按**可执行文件所在目录**生成 `.ULL` / `.minecraft`;
换目录启动会看到另一套实例(`--data-dir` 目前接受并忽略)。
## 已知边界
- 仅单机(OnePlus 15 / Android 17)验证,试验级、不在发布矩阵内。
- **GPU**:已打通,但驱动是自编的(Mesa 26.2.6 + KGSL),装到
`$PREFIX/glibc/{lib,share/vulkan/icd.d}` 后应用会自动启用;Termux 官方源里的
glibc Mesa 太旧(24.2.6 不认识 Adreno 840)。驱动缺失时自动降级 CPU,不会崩。
- **MC**:Android 原生 natives 走不通(LWJGL 没有官方 Android/bionic 产物,且 GLFW 的
窗口模型与 termux-x11 的 X11 不兼容)。有效路线是 glibc JVM,已真机跑通 1.20.4:
Temurin 21 JRE(patchelf 后直接执行)+ Linux arm64 natives(启动器自动改写/镜像兜底)
+ softpipe GL。非致命项:narrator 的 flite 缺失、LWJGL JNI 版本告警;
softpipe 是软件渲染,帧率有限,zink(硬件 GL)仍卡 DRI3。
- Termux 上三条测试失败是环境差异(`/proc/*/children`、长路径脱敏、mtime flaky),
PC 的 Linux CI 不受影响。
## 后续
1. zink 的 DRI3 问题(硬件 GL;当前 softpipe 可跑但慢)——这是 MC 帧率的主要瓶颈
2. 剪贴板/IME 在 termux-x11 下的行为验证
3. narrator/flite 与音频设备(当前 missing 但不阻断)
+377
View File
@@ -0,0 +1,377 @@
# Unlucky Launcher (ULL)
[English](README.md) · **简体中文**
> **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里:
> <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **本文档对应版本:`0.2.3`**——`unlucky-launcher --version` 打出来的是同一个号。
Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是
**一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器
该做的事都覆盖了:实例与版本、加载器与整合包、Mod / 资源包 / 光影、逐实例 Java、微软 / 第三方社区服 /
本地离线账户,以及一套在本地按 YAML 规则跑的崩溃诊断。你的数据不经过我们自己的任何服务:账户令牌只
留在本机。
![实例页:左侧是本地实例列表,右侧是所选实例的版本、加载器、隔离方式与上次游玩时间](docs/images/instances-en-light.png)
*实例页——1180×760,英文界面,浅色档。实例名是测试数据,不是谁的真实存档。图里那句
`Launch failed · exit0 静默失败` 也正好是一处已知缺口的诚实样子:那行原因仍是启动层直接抛出来的中文
原文(见[还没做](#还没做))。*
## 现在能用什么
### 实例、版本与启动
- **实例**——list-detail 版式:左侧是本地实例列表;右侧是所选实例的版本、加载器、隔离方式(独立游戏
目录或共享目录)、文件是否在盘上、上次游玩时间,以及 Play / Stop。窄窗是把两个面板整页切换,不是上下堆。
- **实例从哪来**——装一个版本(带实例名、加载器与加载器版本),或者导入一个整合包。实例页自己的
**新建实例 / 导入 / 复制 / 打开文件夹 / 删除** 五个按钮**还没接后端**:它们是占位动作,点了只往
stderr 写一行(`crates/app-iced/src/app/update/page_messages.rs`),欠的是实例向导
(`crates/app-iced/src/pages/mod.rs` 偏离 #8)。
- **版本管理**——三栏(版本类型 / 主版本 / 搜索)读 Mojang 版本清单:正式版、快照、远古版;安装时可选
加载器与加载器版本。取消安装是真停:在下一个段边界收手,在飞的那个文件就地掐断,且不登记实例
(`crates/presenter/src/host/dispatch.rs:61-88`)。命令行还能只出计划不下载(`--dry-run`:按类的文件数
与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。
- **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位,
每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。
- **实例级启动参数**——JVM 参数、游戏参数、内存、Java 可执行文件与图形后端(`default` / `opengl` /
`vulkan`)落在实例文件的 `launch.*` 里,**每项各自回落**:实例 → 全局设置 → 出厂默认,所以只填了内存的
实例不会把全局 JVM 参数一起顶掉。JVM 参数是**追加**(全局先、实例后,JVM 同名选项后出现者赢);越界的
内存值当作「没填」,而不是拿 `-Xmx0m` 把 JVM 启动弄崩;留空即「跟随」。游戏版本够低时后端那一行直接不
出现。CLI 与 GUI 读的是同一份生效值(`crates/core/src/game/launch_overrides.rs`)。
- **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、
`crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在
日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。
### 加载器、整合包与内容库
- **加载器**——Fabric、Quilt、NeoForge、Forge。需要跑官方 installer `processors` 的 Forge/NeoForge 版本
会真跑一遍;机器上没有 Java 时先装一个托管 JDK。
- **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或
CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的
导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。
- **导入冲突逐文件决策**——写盘之前先扫一遍:每个冲突文件(是什么、为什么冲突、多大)分页列出(每页 40
条),三个入口定这一批——全部保留、全部覆盖、逐项选择(没点过的行留在「保留」)。不冲突的文件一律不重
写,导入报告事后仍逐条列出全部冲突。
- **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果,
以及一键**装入这个实例**。
- **在线源:Modrinth(匿名)**——检索走 `/v2/search`,facets 从目标实例推出来(项目类型、游戏版本、
加载器);详情与版本列表随后取,已知摘要的远端条目再用 `/v2/version_file/<hash>` 补全。不需要 API key,
也不发 POST(下载引擎没有请求体字段,所以摘要一条一条 GET 查)。离线是**独立的一态**——「未联网」,
绝不说成「没有匹配的内容」。
- **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线);
CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是
禁用态,并把不匹配写清楚。没有一条是静默失败的。
- **CurseForge 检索要 key**——启动前设 `ULL_CF_API_KEY`,没有它这个来源在来源筛选里是灰的。key 只活在
环境变量里:不写进配置文件、不进日志(`crates/presenter/src/host/addon.rs`)。
### Java
逐实例的托管 JDK,四家发行版的回退链**按地区排**——Temurin 打头,国内是 Oracle 先于 Microsoft
(那边实测快 28×)、国外反过来,Zulu 收尾——可用 `java.vendorPriority` 设置整体覆盖
(`crates/core/src/game/java/vendor.rs`)。镜像也按地区选。`java list` 把它真能找到的每个 JDK 都列出来
(包括官方启动器自己下的那些),`java install <主版本>` 拉一个。游戏要的 JDK 是启动器的数据,不是启动器
本身的依赖。
### 账户
三条轨道在同一页:
- **微软**——OAuth 2.0 设备码流程 → Xbox Live → XSTS → `api.minecraftservices.com`。
- **第三方社区认证服**(authlib-injector 一类)。
- **本地离线档案**,用于隔离测试。
**微软登录在官方构建里开箱即用。** 发布版二进制已内置它需要的 Azure 公共客户端 id(公共客户端 id 不是
凭据,本来就随二进制一起分发),所以登录前不需要任何配置。设 `ULL_MSA_CLIENT_ID=<id>` 仍可覆盖内置值:
变量在启动时读取,改完要**重启启动器**。从源码构建时该变量有什么就烘什么(`ULL_MSA_CLIENT_ID=<id> cargo build`,
经 `option_env!` 烧进二进制)。两处都没有 id 的构建点登录**不会联网**,弹窗直接说清是这一项没配:标题
「微软登录未配置」,正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后重启),并带
`代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它登录失败仍是
老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
### 外观与语言
- **只有一套 UI 栈**——iced 0.14 + Material 3 Expressive,自绘标题栏、带动效的导航轨,浅色 / 深色 /
跟随系统。
- **外观**——窗口背景图与取色链路(面板配色可从壁纸推出来)、面板透明度、整窗透明度(Windows)。Windows 11
与 Server 2025 上向系统请求外框圆角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。
- **CPU 兜底**——`--software-renderer` 让没有可用 GPU 或驱动有问题的机器照样正常显示;没有可用适配器时,
后端探测自己退到 CPU。这条路上**面板级**半透明与模糊会关掉(**窗口级**透明度照常可用),设置面板会写明。
- **本地化**——跟随系统语言探测、设置里可显式切换、重启后保持。绝大多数文案来自同一份消息目录
(`crates/i18n`)。**边界,因为上面这句很容易被读大**:防硬编码的守门
(`crates/app-iced/src/pages/instances/tests/i18n.rs`)只扫实例页源码,不是每一屏;写死的中文字面量里
没有 ASCII 单词可以匹配,这一类它抓不到;外观面板的老控件还是硬编码英文
(`crates/app-iced/src/pages/appearance/section.rs`),中文界面下它们仍显示英文。
### 助手,以及 MCP server
这是两件不同的事,README 不该含糊:
- **启动器内的助手今天还是占位壳。** 它的 transport 是脚本化的
(`crates/app-iced/src/presenter/transport.rs`),没有接真实模型,而且界面上它出现的每一屏都用四处明说
这一点——副标题、输入框占位符、空态正文,以及一枚「模拟」胶囊。会话状态、未读数、草稿、确认流程与具名
失败码都是真的 presenter 状态;缺的只是它们后面的模型。
- **MCP server 是真的。** `ull agent serve` 把启动器内核以 MCP server 形态(stdio 或 Streamable HTTP)
开放给外部 Agent 工具,只监听回环(`127.0.0.1`,默认端口 47823),除非
`agent.mcp.allow_write=true` 否则只读。十个工具规格(`crates/agent/src/tools/`)同时喂给两种形态,两条
路都过同一个执行器——schema 校验、敏感分级、审计留痕。`ull agent config` 能打印现成的 Claude Code /
Cursor 配置片段。
### 命令行
GUI 二进制在跑任何 UI 代码之前就先认全部 CLI 子命令——不开窗、不探渲染器、不扫字体。另有一份更轻的 `ull`
产物,命令集相同,却一个图形栈都不链接。
```bash
unlucky-launcher # GUI
unlucky-launcher --software-renderer # GUI,CPU 渲染
unlucky-launcher instances list # CLI,不开窗
unlucky-launcher --json launch 1.20.1 # CLI,全局选项排在子命令前面
unlucky-launcher --version # CLI,版本 + 系统摘要
```
判据刻意收得很窄,所以原本能用的用法一条都没变:
- 第一个参数是裸词(不以 `-` 开头)→ 它就在子命令的位置上,交给 CLI。GUI 的 flag 永远不会出现在那个位置:
`--screenshot` / `--diff` / `--data-dir` 的取值都排在 flag 之后。
- 第一个参数是 flag → 只有当 `crates/cli` 的解析器也解析出一条真命令时才算 CLI 调用(`--help`、
`--version`、`--json instances list`)。单独 `--data-dir X` 解析成「没有子命令」,仍然开 GUI;GUI 专属
参数(`--software-renderer`、`--screenshot*`、`--renderer`、`--theme`、`--diff`、`--force-state` 等)
在 CLI 里一律解析失败,也仍然开 GUI。
**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义
(`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`update`、
`agent`、`version`、`help`。
### 自更新与稳定通道
- **自更新是真的,而且 GUI 与 CLI 共用同一个内核。** 设置页的「更新」组与 `ull update check` /
`ull update apply` 造的是同一个 `core::update::Job`(`crates/core/src/update/`):取
`releases/latest/download/manifest.json`、比版本、过渠道准入、校验长度 + SHA256,再原子换包——把旧文件
改名挪开、把新的搬进去,失败就回滚。Windows 上正在运行的 exe 能**改名**、不能覆盖,所以换包是改名。
- **四种结论不许混**:已是最新 / 有可装的更新 / 有更高版本但里面没有本平台产物 / 检查本身失败(断网、清单坏、
渠道不符)。失败绝不会被说成「已是最新」。
- **只发布 stable 通道。** `update.channel` 默认 `stable`;beta / alpha / nightly 已建模但不发。清单准入要两条
同时成立——清单自称的渠道与版本号里的预发布阶段:清单会撒谎,版本号不会。
- **为什么发布必须被标成稳定版**:`releases/latest/download/*` 只指向**非 prerelease** 的最新 Release,而清单
地址就钉在它上面。`0.2.3` 是第一个不带预发布标记建出来的版本(`0.2.2` 保留为历史预发布)。「哪种触发创建什么
标记」只有一张表(`packaging/release_marker.py`),并由 `packaging/check_release_semantics.py` 守着。
- **没有任何东西背着你自动安装。** 启动时的检查可以关掉(`update.auto_check`,CLI 读的是同一个键),安装要么是
点按钮,要么是显式 `ull update apply`。调试构建可以拿本地目录源把整条链演练一遍(`ULL_UPDATE_DIR`);发布
构建里**不存在**这条路径。
### 日志在哪
启动器自己这一侧写的东西全在 `<数据根>/logs/` 一个目录里。设置页有一行**日志文件夹**,直接显示这个
路径,旁边是 *打开文件夹* 与 *复制路径*;日志页页头也有 *打开文件夹*。
![设置页「通用」分组:日志文件夹一行,显示 D:/ULL-Demo/.ULL/logs,旁边是「打开目录」与「复制路径」两个按钮,下面是解释文案](docs/images/logs-folder-zh.png)
*设置页 → 通用 → 日志文件夹。路径就是当前实际生效的日志根(便携 / 标准 / 自定义三种数据模式各自不同),
打开失败时这一行照样显示,右按钮把路径送进剪贴板。*
数据根按三种模式取值:便携是 `<exe 目录>/.ULL`,标准是 `<用户应用数据目录>/ULL`,自定义是
`<自定义根>/.ULL`——与设置页显示的「启动器数据目录」是同一个值。
| 路径 | 是什么 |
|---|---|
| `logs/launcher.log` | 启动器自己的运行日志:后端探测、字体/主题决策、后端选择、**渲染后端起不来时的具名原因**,以及每一次启动(命令行事实、spawn 结果、退出码)。满 1 MiB 滚成 `logs/launcher.1.log`,**只留一份**旧档,所以它不会无限长大。 |
| `logs/launcher.1.log` | 上一份 1 MiB,下次滚动时被替换。 |
| `logs/game/<session-id>.jsonl` | 每次启动一份 JSONL 事件流:命令行(已脱敏)、每一行 stdout/stderr、退出码。 |
| `logs/game/index.jsonl` | 一会话一行摘要的索引,日志页左栏读的就是它。 |
| `logs/install.log` | 安装流水:每个阶段一行。 |
| `logs/session-log-location.json` | 会话日志现在在哪、以前在哪的记录,每次启动重写。 |
| `data/agent/sessions/game/…` | **只读兼容**:旧版本写下的会话。仍然列得出来、打得开;不搬动、不删除任何一个文件。 |
游戏自己的文件不在这里,而在实例的运行目录里:`logs/latest.log`、`crash-reports/crash-*.txt`、
`hs_err_pid*.log`。**`crash-reports/` 只在 JVM 活到那一步时才存在。** 模组、缺库或 Java 选错把游戏
弄死在 Minecraft 初始化之前时,游戏根本没机会写它——那种时候诚实的证据是会话 JSONL 与
`launcher.log`,不是崩溃报告。这是游戏的行为,不是启动器藏了什么。
### 还没做
这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。
- **没有安装包。** 下个文件直接跑——没有安装步骤;更新是就地替换那个文件,不装任何东西
(见[自更新与稳定通道](#自更新与稳定通道))。
- **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → SHA256 → 改名/回滚)是拿调试构建 + 夹具跑的,
因为 `0.2.2` 还是预发布期间读不到真实的 `releases/latest/download/manifest.json`。线上稳定通道的第一次
观察要等 `0.2.3` 发出去之后。
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
(日志页与设置页的「打开日志文件夹」**不再**是占位:它真的会打开 `<数据根>/logs`,见[日志在哪](#日志在哪)。)
- **Linux x64 已发布,但「已发布」= 真能跑,不等于观感已定型。** 发布流水线默认就在原生 Ubuntu 22.04 上编
`linux-x64` 并发出去,依据是:release 产物本身在真显示服务(WSLg)下起过窗、渲出一帧非空白画面、主题与渲染
后端的日志行齐全(实测输出见 `packaging/docs/linux-evidence.md`),而且同一条判据已经常驻成发布流水线里
**这个槽位**的门禁。还没人看过的:真实桌面发行版上的拖动/缩放,以及真桌面会话里的字体度量与中文整形。
「整窗透明度」与「圆角」这两条在 Linux 上**根本没有代码路径**(不是未验):前者要窗口句柄,Linux 上拿不到
(`hwnd=none`);后者交给桌面合成器(`corner=policy=system`),我们不自己画。
`linux-arm64` 只是**编出来了,没发布**——没有任何 ARM64 机器跑过它一帧,产物只作构建证据留着。
- **macOS 不由发布流水线构建,也未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、
三个窗口按钮的位置(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,
首次启动要自己清掉 Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动
`workflow_dispatch` 的探针、带 `continue-on-error`,不是门禁;`gh workflow run release.yml -f all_slots=true`
能编这两档取证,但它们从不发布。
- **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它
真正拿到的高度。
- **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子
(`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的
`静默失败` 就是它。
- **启动器内的助手背后没有模型**(见上)。MCP server 有。
- **内容库的安装进度是阶段级**,不是字节级。
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。
## 下载与运行
从 [Releases](https://github.com/AskaEth/Unlucky-Launcher/releases) 拿你那个平台的文件。文件名形如
`unlucky-launcher_<版本>_<平台>_<架构>`;这个形状的唯一真相源是
`packaging/make_artifacts.py::asset_stem()`:
| 槽位 | 产物 | 形态 |
|------|------|------|
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录;要 glibc 2.35+ 与 X11/Wayland + fontconfig 那一套 |
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **编了、不发布**,见下 |
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **编了、不发布**(运行时零证据) |
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— 发布流水线**不构建** |
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— 发布流水线**不构建** |
| 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
**本版发布 `windows-x64` 与 `linux-x64`,就这两档。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
(`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
**不发布** 的行是命名规则,不是本版可下载的资产。三条关系都有守卫(`packaging/check_publish_slots.py`):
名单里点名了却没产物 ⇒ Release 变红(`linux-x64` 构建红了就挡住整次发布,靠的就是这条);产物在、名单没点名
⇒ 显式打「未发布」警告,不再静默删掉;`mode: ci` 的槽位本次连产物都没有(那一档红了)⇒ 也打警告。
不发布的行里 CI 真编两档。`windows-arm64` 在 GitHub 原生 `windows-11-arm`(Windows 11 Arm64 + Visual Studio
2026)上编来取证——**诚实一句**:此前每一次 arm64 红都红在**编译前的工具链体检**,`cargo build` 一次都没跑过;
M164 把那个体检降级成只取证,所以**下一条 tag 的 CI 才是第一次真正问编译器**。`linux-arm64` 在原生
`ubuntu-22.04-arm`(与 x64 同 glibc 地板)上编,不发布的理由完全不同:**没有任何 ARM64 机器跑过它**。
解锁条件 = 真机渲出一帧 + 主题/后端日志行,之后才把键加进 `PUBLISH_SLOTS`。
Linux x64 的安装就是解开归档直接跑:
```bash
tar -xzf unlucky-launcher_<版本>_linux_x64.tar.gz
cd unlucky-launcher_<版本>_linux_x64
./unlucky-launcher # 没有可用 GPU 适配器就加 --software-renderer
```
载入期要 glibc 2.35+、`libxkbcommon`、`fontconfig`、`libGL`;Debian/Ubuntu 上
`sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6` 就够。
与归档并列的裸 `ull_<版本>_linux_x64` 连显示都不需要。
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
弄丢。下载链接里不带实现细节字样,归档里的二进制名永远是 `unlucky-launcher`。
这两种形态不是重复品。GUI 二进制开窗时要用图形栈(X11 / Wayland / GL 走 `dlopen`,启动时直接链接
`libfontconfig`);`ull` 除了 glibc 什么都不需要,所以在没有窗口的服务器、CI 容器上跑得起来。Linux 上这个
地板是 glibc ≥ 2.35(槽位在 Ubuntu 22.04 上构建)——更老的发行版得源码自建(`packaging/README.md` §4)。
没有桌面就用 `ull`,有桌面就用 `unlucky-launcher <子命令>`。两者打出来的 CLI 文案是同一套,自称的名字跟着
**你敲的那个文件**走——`argv[0]` 去掉目录与结尾的 `.exe`,别的都不剥
(`crates/cli/src/cli/program.rs`),所以 `ull` 打印 `ull ·`,归档里那个二进制打印
`unlucky-launcher ·`,本地 `cargo build` 出来的打印 `unlucky-launcher-iced ·`。
最低平台基线:**Windows 10**;Windows 8.1 / 8 / 7 不在支持范围内。macOS 12+(Apple Silicon 与 Intel),
Linux x64 / arm64(基线判定在 `crates/core/src/platform/os_info.rs`)。Windows 10/11 的产物由标准工具链
(MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异
一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。
第一次用微软账户?不用配置——官方构建已内置客户端 id,见[账户](#账户)。其它都不需要配置。
## 从源码构建
前置:
- Rust stable(`rust-toolchain.toml` 会自动选工具链;门禁还需要 `rustfmt` / `clippy` 组件)
- Windows:MSVC 工具链(CI 用这套)或 MinGW-w64(`x86_64-pc-windows-gnu`)
- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
libxcb1-dev`——winit + wgpu/tiny-skia 编译期要用
```bash
cargo fmt --all --check # 格式
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
cargo test --workspace # 测试
cargo build --release # bin 落在 target/release/
cargo deny check licenses bans sources # 依赖许可(CI 必跑)
```
编出来的 bin:
- 从源码构建默认不带微软客户端 id:想让产物自带,就在构建时设 `ULL_MSA_CLIENT_ID`(`ULL_MSA_CLIENT_ID=<id>
cargo build`);官方发布产物已内置,见[账户](#账户)。
- `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo
的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成
`unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。
- `ull`——CLI 的独立无 UI 构建:同一套命令、不链接图形栈、体积更小,给完全没有显示设备的机器用。
渲染开关:
- `unlucky-launcher-iced --software-renderer` 强制 tiny-skia(`ICED_BACKEND=tiny-skia`);
`--screenshot <png>` 出一帧图后退出——这是 CI 无头冒烟 job 用的验收通道
- 没有可用适配器时,iced 版靠显式后端探测自己退到 CPU
**CI 门禁。** 每次推送到 `main` 与每个 PR 都跑 fmt / clippy(warnings 视为错误)/ 全量测试,Linux 无头
渲染冒烟与它们同级、是硬门禁:在 Xvfb 里真渲染一帧,再断言 PNG 非空白,红了就不许合。macOS 探针还不算
——只手动 dispatch,结果留证据但不作为流水线结论。
| Workflow | 内容 |
|----------|------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 构建默认四档(`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(发布 `windows-x64` 与 `linux-x64`;`linux-x64` 另有一条同槽位的 Xvfb 首帧门禁;`-f all_slots=true` 才整表六槽位取证) |
| `.github/workflows/release-guard.yml` | 发布标记语义守门:push tag ⇒ **稳定版**、dispatch `prerelease=true` ⇒ 预发布;标记表在 `packaging/release_marker.py`,守门会对自己做变异自测 |
## 架构与模块地图
```
Unlucky-Launcher (cargo workspace)
├── crates/core # 领域库:零 UI 依赖,八模块
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
├── crates/presenter # 业务宿主层:与框架无关的 state / host / dto / 转换
├── crates/design # 设计令牌:真 HCT 色彩 / 排版 / 形状 / 间距 / 高程 / 动效 / 图标 / 组件度量
│ # —— 零 UI 依赖、零第三方 crate
├── crates/i18n # 语言探测、消息目录、中文字体策略
├── crates/palette-extract # 唯一解码图片的 crate,供壁纸取色链路使用
├── crates/app-iced # iced UI(bin `unlucky-launcher-iced`):workspace 里唯一的 GUI bin
├── crates/cli # CLI(bin `ull`):headless,零 UI
└── packaging # 单文件产物矩阵与发布脚本
```
层间纪律:`app-iced` → `presenter` → `core` / `agent`。`design` 与 `i18n` 只被壳层与 presenter 消费;
它们都不得反过来依赖渲染框架,CI 守门会拦。
`crates/core` 的八个模块:
| 模块 | 职责 |
|------|------|
| `setting` | 三模式数据目录、schema 配置、原子写、迁移 |
| `download` | 多源健康度、镜像、分块下载、边下边算 |
| `game` | 版本/实例仓库、快照与继承解析 |
| `launch` | 10 阶段启动管线、进程、JSONL 事件流 |
| `auth` | Microsoft 账户登录(OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services)、第三方社区服、本地档案 |
| `addon` | 加载器安装、整合包、CurseForge / Modrinth、整合包导出 |
| `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 |
| `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 |
## 许可证与致谢
- **本项目**:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)。
- **iced**(<https://github.com/iced-rs/iced>),MIT:现役 UI 的框架;与它同栈的 `winit`(Apache-2.0)、
以及经它引入的 `wgpu`(MIT / Apache-2.0)/ `tiny-skia`(BSD-3-Clause)。
- **Google Material Color Utilities**(Apache-2.0):`crates/design` 的色彩令牌是它 HCT /
ColorSpec2021 算法的 Rust 重写移植,移植清单与逐函数对照写在 [NOTICE](NOTICE)。
- **其余主要依赖**:MCP server 的 `rmcp` 与 `axum`(Apache-2.0 / MIT)、`tokio`(MIT)、`reqwest`
与 rustls(MIT / Apache-2.0)、`serde` / `serde_json`(MIT / Apache-2.0)、`zip` / `flate2` / `tar`
(MIT / Apache-2.0)。cargo-deny 强制执行的白名单在 `deny.toml`;GPL 系依赖不得进主干。
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)
(均 GPL-3.0)。这两款启动器陪着绝大多数 MC 玩家走了很多年,
本项目在设计取舍与实现细节上都从中受益良多。
+144
View File
@@ -0,0 +1,144 @@
# Termux 适配:方案、改动清单与合并指南
> 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
## TL;DR
- **构建**:为 Termux 编出 `aarch64-unknown-linux-gnu`(glibc)二进制,在 termux-x11 里跑 GUI。
Android 原生 target 走不通(winit 的 android 后端需要 NativeActivity),所以不用它。
- **已跑通**:GUI(CPU 与 GPU 两条渲染路径)、中文、横屏自适应、JDK 发现与系统包安装、
MC 1.20.4(glibc JRE + arm64 natives + softpipe GL,真机进游戏)。
- **未收口**:MC 启动参数支持(由主线后续补)、MC 硬件 GL(zink 卡 DRI3)、剪贴板/IME。
- **对主线的影响**:所有行为都由 **运行时 Termux 识别 / `cfg(target_os = "linux")` /
文件存在** 三选一 gate 住,Windows / macOS / 普通 Linux 的默认路径不变。
## 方案
### 1. 平台识别
`crates/core/src/platform/termux.rs`:用 `TERMUX_VERSION` 或默认 `PREFIX`
(`/data/data/com.termux/files/usr`)识别 Termux,推导 `$PREFIX` 与 `$PREFIX/lib/jvm`。
不把路径写死到调用方,也不改 `OsFamily`(Android 上仍按 Linux 家族处理)。
### 2. JDK:发现 + 安装
- **发现**:`game/java/discover.rs` 的 Unix 扫描根追加 `$PREFIX/lib/jvm`,
环境可注入(`standard_roots_in`)。补上「Termux 装了 openjdk 但没设 JAVA_HOME/PATH」的盲点。
- **安装**:新增 `game/java/termux.rs`。Termux 的 openjdk 是 `.deb`(bionic),
与 Temurin 的 zip/tar.gz 不是同一形态,所以**委托包管理器**:
- 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序 `apt-get update && install`;
- lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不污染用户 apt 配置;装完清理;
- 装完从 `$PREFIX/lib/jvm` 定位,幂等(已装则不走网络)。
- **vendor 接线**:`JdkVendor::Termux`(17/21/25)。Termux 上缺省安装先试系统包,
失败再走归档链;`--vendor termux` 只走系统包。设置白名单与契约测试同步。
### 3. 字体
`crates/app-iced/src/fonts.rs`:Termux 上把 `/system/fonts/NotoSansCJK-Regular.ttc`
注册进 cosmic-text(fontdb 不扫 Android 字体目录),中文不再显示方框。
### 4. 窗口(横屏优先)
`app/boot/termux_window.rs`:Termux 上默认窗口按当前 X 屏幕等比自适应
(日志 `size_source=termux-screen`),横屏铺满、竖屏不溢出;
`ULL_WINDOW_SIZE` 覆盖与 1180×760 验收基准不变。
### 5. GPU(启动器)
- 驱动是自编的 **Mesa 26.2.6 + KGSL**,装在 `$PREFIX/glibc/{lib,share/vulkan/icd.d}`;
构建/安装步骤见 `packaging/docs/termux-glibc-build.md` §5。
- `app/boot/termux_gpu.rs`:glibc 构建 + Termux + ICD 文件存在时,在探测前设
`VK_ICD_FILENAMES` / `VK_DRIVER_FILES`;用户显式设置不覆盖。
- 实测 wgpu 选中 Adreno 840(turnip Mesa 26.2.6);无驱动自动降级 tiny-skia。
### 6. Minecraft 运行链
- **natives**:1.19+ 的清单把 natives 作为独立库条目(`org.lwjgl:...:natives-linux`),
Linux/aarch64 上改写成 `natives-linux-arm64`(`Library::linux_arm64_native_name`,
install 与 launch 共用);Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404,
下载兜底到阿里云 Maven(`install/libraries.rs`)。
- **JVM**:用 glibc 的 Temurin JRE(patchelf 后直接执行),bionic 的 Termux JVM
加载不了 glibc natives。
- **GL**:glibc Mesa 的 zink 在 termux-x11 下卡 `DRI3 not available`,
子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast` + `LIBGL_ALWAYS_SOFTWARE=1`
(`launch/defaults.rs`,用户自定义环境变量可覆盖)。
## 改动清单
| 文件 | 改动 | 平台隔离 |
| --- | --- | --- |
| `core/platform/termux.rs`(新) | Termux 识别、`$PREFIX`/JVM 根 | 纯函数,按环境判断 |
| `core/platform/mod.rs` | 导出新模块 | — |
| `core/game/java/discover.rs` | `$PREFIX/lib/jvm` 扫描 | Unix + Termux 判断 |
| `core/game/java/termux.rs`(新) | 镜像/隔离 apt 安装、`installed_home` | 仅 Termux 可执行 |
| `core/game/java/vendor.rs` / `source.rs` / `install.rs` | `JdkVendor::Termux` 与安装分派 | Termux 上才启用 |
| `core/setting/keys.rs` + 契约测试 | 白名单加 `termux` | 跨平台可选,非 Termux 明确报错 |
| `cli/commands/java_vendors.rs`、`cli/cli/help.rs`、`presenter/host/java.rs` | 展示与帮助文案 | 展示层 |
| `core/game/library.rs` | `linux_arm64_native_name` / 分类器改写 | 仅 linux+arm64 |
| `core/game/install/libraries.rs` | 现代 natives 条目改写 + Maven 兜底 | 仅 linux+arm64 |
| `core/launch/defaults.rs` | Termux softpipe 环境默认值 | `cfg(linux)` + Termux |
| `presenter/host/launch.rs`、`cli/commands/snapshot.rs` | classpath 用改写后的 arm64 坐标 | 仅 linux+arm64 |
| `app-iced/fonts.rs` | Android CJK 字体注册 | 仅 Termux |
| `app-iced/boot/termux_window.rs`(新) | 横屏自适应 | 仅 Termux |
| `app-iced/boot/termux_gpu.rs`(新) | 自动启用自编 ICD | 仅 glibc+Termux+文件存在 |
| `app-iced/Cargo.toml` | 新增 `unlucky-core` 依赖 | 复用平台识别 |
| `README.termux.md`(新)、`packaging/docs/termux-glibc-build.md`(新) | 分支说明与构建配方 | 文档 |
| `README.md` / `README.zh-CN.md` / 支持矩阵两处 | Termux 行同步 | 文档 |
## 行为变化与兼容性
- 新增 `JdkVendor::Termux` 会改变 `VENDOR_IDS` / `JDK_VENDORS` 的允许值集合
(契约测试已同步)。非 Termux 上显式选它会在安装时明确报「只在 Termux 里可用」,
默认回退序**不含** termux,PC 行为不变。
- Linux/aarch64 的 LWJGL3 natives 会被改写;x86_64、Windows、macOS 不受影响。
- `app-iced` 直接依赖 `unlucky-core`(此前通过 cli/presenter 间接依赖)。
- 所有 Termux 专属路径都需要运行时识别或文件存在才会生效;普通 Linux 桌面不会触发。
## 合并指南
1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
(M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里,
按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。
与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README /
`crates/app-iced/Cargo.toml`)。
2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` /
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
3. 本机已知、**不影响 PC CI** 的三条失败:
- `unlucky-core --test launch`:`/proc/<pid>/task/<pid>/children`(Android 内核没有)
- 同测试:长路径被脱敏成 `<opaque>`
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。
6. **PC 门禁发现的两处、已在 M156 就地修掉**(都只在 Windows/PC 侧才暴露,
所以在 Termux 上跑门禁看不出来):
- `game/java/discover.rs` 的两个 `#[cfg(unix)]` 测试,导入没跟着 gate
→ Windows 上 `-D warnings` 判「未使用导入」。已把两个 `use` 同样 gate 成 `#[cfg(unix)]`;
代价是这两个用例在 Windows 上不编译(由 Linux CI 覆盖)。
- 本分支把 `game/java/install.rs`(456 行)与 `game/install/libraries.rs`(409 行)
顶过了 400 行红线(`guards.yml` 的 `file-size`)。已按步骤拆出
`install/termux_install.rs`、`install/one.rs`、`install/libraries/arm64_natives.rs`,
行为不变、用例原样留在原处。
## 验证矩阵(本机)
| 项 | 结果 |
| --- | --- |
| GUI 截图(横屏 1657×1067) | ✅ 中文正常,`client_id=configured source=build` |
| GPU 渲染 | ✅ wgpu 选中 Adreno 840(turnip Mesa 26.2.6);降级路径也验证过 |
| `ull java install 17` | ✅ SAU 镜像约 20 s,装完清理,`java list` 可见 |
| MC 1.20.4 | ✅ 真机安装(740 MB)→ 主菜单 → 建世界 → 进游戏 |
| fmt / clippy / 核心测试 | ✅ 475 条核心测试通过,clippy 零告警 |
## 后续路线
1. **MC 启动参数**(主线功能,另行排期):当前 `ull launch` 不接受自定义 JVM/游戏参数。
2. **MC 硬件 GL**:修 glibc GLX 的 DRI3(让 zink 生效),或改用 VulkanMod。
3. 剪贴板 / IME 在 termux-x11 下的行为验证。
4. narrator/flite 与音频设备(当前缺失但不阻断)。
5. GPU 驱动分发:目前每次都要按文档自编 Mesa;可考虑脚本化/预编译产物。
## 回滚策略
全部改动按提交分组(platform → fonts → docs → java → gpu → natives → mc),
可按组 revert;Termux 专属逻辑都有运行时/文件存在 gate,回滚不会波及 PC 路径。
+7
View File
@@ -0,0 +1,7 @@
# 与 Cargo.toml 的 rust-version 同步;改 MSRV 时两处一起改。
msrv = "1.92"
# core 的模块边界很薄,参数多于 8 个基本说明该拆结构体了。
too-many-arguments-threshold = 8
# 领域类型(版本号、哈希、URL)普遍是短名,不做最小名长惩罚。
min-ident-chars-threshold = 2
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "unlucky-agent"
description = "Unlucky-Launcher AI Agent:工具注册表 + 内置客户端 + MCP server(双形态共用一个内核)"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 依赖方向:agent 依赖 core 的公共 API,core 不知道 agent 的存在(AGENTS.md §2)。
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies]
unlucky-core = { path = "../core", version = "0.2.4" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。
serde.workspace = true
serde_json.workspace = true
# LLM Provider + agent 循环。**锁精确版本**:rig 12 周 5 个 minor、CHANGELOG 满是 breaking
# (DevDocs/PlanDoc/02-ai-agent-design.md §0 + research/02 §7 R1)。升级必须单独提交并跑回放回归。
# 依赖面收口:整个 crate 只有 src/brain/rig.rs 允许 `use rig_*`。
# feature `reqwest`:rig-core 0.45 的 default 只有 derive + rustls,HTTP 客户端要显式开;TLS 走 rustls
# (免 OpenSSL,对 Win7/MinGW 友好,见 research/02 §7 R3)。
rig-core = { version = "=0.45.0", features = ["reqwest"] }
# TODO(M16-P1): 目前**声明但未使用**——对话循环/工具编排是自研的(要在中间插确认与审计)。
# 保留是为了把版本按 02 §0 钉死;P1 接 rig 的 agent 运行时(工具服务句柄 / rig-cassette 回放 /
# rig-rmcp 让内置 agent 吃 MCP)时再真正消费。若 P1 决定彻底不用,删这一行即可。
rig-agent = "=0.45.0"
# 异步运行时(rig 的 HTTP/流式需要)与流工具(消费 rig 的 Stream)。
tokio.workspace = true
futures-util.workspace = true
# MCP server(M39):rmcp 3.8 投影 + stdio / Streamable HTTP 传输。
# 依赖面收口纪律与 rig 相同——整个 crate 只有 src/mcp/ 允许 `use rmcp::`。
rmcp.workspace = true
axum.workspace = true
[lints]
workspace = true
+283
View File
@@ -0,0 +1,283 @@
//! 审计日志:**每一次工具调用**都留痕(02 文档 §3.1 末条 / mission M16 任务 4)。
//!
//! 落盘位置由装配处给(`<data_root>/agent/audit/audit-<日期>.jsonl`,按天分文件,
//! 见 `PlanDoc/04-data-and-config.md` §2)。一行一条 JSON,只追加不修改。
//!
//! 三条纪律:
//! 1. **参数先脱敏再落盘**([`crate::redact::redact_args`])——`set_config` 能改 AI 端点,
//! 参数里会有 api_key;
//! 2. 写失败**不打断**工具执行(审计是旁路),但要把失败打到 stderr 留痕;
//! 3. YOLO 与「是否经确认」分别记录(`yolo=true, confirmed=false` 与
//! `yolo=false, confirmed=true` 是两件事,事故复盘的差别就在这)。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::clock::{date_of_unix_ms, format_unix_ms, SharedClock};
use crate::redact::redact_args;
/// 一次工具调用的结局(审计用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditOutcome {
/// 成功返回。
Ok,
/// 工具返回错误(含端口失败、未找到会话等)。
ToolError,
/// 参数没过 schema 校验(模型给错了参数)。
InvalidArgs,
/// 用户/策略拒绝。
Denied,
/// 只发出了确认请求,尚未执行(编排层在把请求交给闸门之前先记一条,
/// 这样「AI 想改 X 但被拒」和「用户点了允许」在审计里是两条独立的证据)。
AwaitingConfirmation,
/// 模型调了不存在的工具。
UnknownTool,
}
/// 一条审计记录。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AuditRecord {
/// RFC3339 UTC。
pub ts: String,
/// 会话 id(agent 对话框的会话,不是游戏会话)。
pub agent_session_id: String,
/// 工具名。
pub tool: String,
/// 参数(已脱敏)。
pub args: Value,
/// 结局。
pub outcome: AuditOutcome,
/// 一句话结果摘要。
pub summary: String,
/// 是否运行在 YOLO 模式。
pub yolo: bool,
/// 是否经过用户确认(YOLO 下为 false——自动放行不是"确认")。
pub confirmed: bool,
/// 工具执行耗时(毫秒)。
pub duration_ms: u64,
}
/// 审计出口。
pub trait AuditSink: Send + Sync {
/// 记录一条(实现方不应阻塞工具执行链;失败自行吞掉并留痕)。
fn record(&self, record: &AuditRecord);
}
/// 按天滚动的 JSONL 审计(生产实现)。
pub struct JsonlAudit {
dir: PathBuf,
clock: SharedClock,
guard: Mutex<()>,
}
impl JsonlAudit {
/// `dir` 通常是 `<data_root>/agent/audit`;目录按需创建。
#[must_use]
pub fn new(dir: impl Into<PathBuf>, clock: SharedClock) -> Self {
Self {
dir: dir.into(),
clock,
guard: Mutex::new(()),
}
}
/// 给定时刻对应的文件路径(测试与 UI「查看审计」共用)。
#[must_use]
pub fn path_for(&self, unix_ms: u64) -> PathBuf {
self.dir
.join(format!("audit-{}.jsonl", date_of_unix_ms(unix_ms)))
}
/// 追加一行;失败返回 `std::io::Error`(由 [`AuditSink::record`] 吞掉并打日志)。
fn append(&self, record: &AuditRecord) -> std::io::Result<()> {
let path = self.path_for(self.clock.now_unix_ms());
let line = serde_json::to_string(record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let _guard = self.guard.lock();
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")
}
}
impl AuditSink for JsonlAudit {
fn record(&self, record: &AuditRecord) {
if let Err(err) = self.append(record) {
// 审计是旁路:写不进去也不能让工具调用失败,但要留下痕迹。
eprintln!("[unlucky-agent] 审计写入失败: {err}");
}
}
}
/// 内存审计(测试 + 「P0 不落盘」的降级路径)。
#[derive(Debug, Default)]
pub struct MemoryAudit {
records: Mutex<Vec<AuditRecord>>,
}
impl MemoryAudit {
/// 空审计。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 已记录条数。
#[must_use]
pub fn len(&self) -> usize {
self.records.lock().map(|r| r.len()).unwrap_or(0)
}
/// 是否一条都没有。
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 取快照。
#[must_use]
pub fn records(&self) -> Vec<AuditRecord> {
self.records.lock().map(|r| r.clone()).unwrap_or_default()
}
/// 按工具名筛出记录(测试断言用)。
#[must_use]
pub fn records_for(&self, tool: &str) -> Vec<AuditRecord> {
self.records()
.into_iter()
.filter(|r| r.tool == tool)
.collect()
}
}
impl AuditSink for MemoryAudit {
fn record(&self, record: &AuditRecord) {
if let Ok(mut records) = self.records.lock() {
records.push(record.clone());
}
}
}
/// 丢弃审计(不推荐,只给"连内存都不想留"的极端场景/基准测试)。
#[derive(Debug, Default, Clone, Copy)]
pub struct NullAudit;
impl AuditSink for NullAudit {
fn record(&self, _record: &AuditRecord) {}
}
/// 组装一条记录时的"其余字段"(时间戳从时钟取,参数先脱敏)。
#[derive(Debug, Clone, Copy)]
pub struct AuditDraft<'a> {
/// 工具名。
pub tool: &'a str,
/// 原始参数(内部会脱敏)。
pub args: &'a Value,
/// 结局。
pub outcome: AuditOutcome,
/// 一句话结果摘要。
pub summary: &'a str,
/// 是否 YOLO。
pub yolo: bool,
/// 是否经用户确认。
pub confirmed: bool,
/// 耗时(毫秒)。
pub duration_ms: u64,
}
/// 组装一条审计记录(时间戳从时钟取、参数脱敏,调用方不用自己拼)。
#[must_use]
pub fn make_record(
clock: &dyn crate::clock::Clock,
agent_session_id: &str,
draft: AuditDraft<'_>,
) -> AuditRecord {
AuditRecord {
ts: format_unix_ms(clock.now_unix_ms()),
agent_session_id: agent_session_id.to_string(),
tool: draft.tool.to_string(),
args: redact_args(draft.args),
outcome: draft.outcome,
summary: draft.summary.to_string(),
yolo: draft.yolo,
confirmed: draft.confirmed,
duration_ms: draft.duration_ms,
}
}
/// 便捷:给出数据根下的审计目录(`<data_root>/agent/audit`)。
#[must_use]
pub fn audit_dir(data_root: &Path) -> PathBuf {
data_root.join("agent").join("audit")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FixedClock;
use serde_json::json;
use std::sync::Arc;
#[test]
fn record_redacts_args_before_store() {
let audit = MemoryAudit::new();
let clock = FixedClock::new(1_793_304_753_123);
let args = json!({ "path": "ai.api_key", "value": "sk-x" });
let rec = make_record(
&clock,
"s1",
AuditDraft {
tool: "set_config",
args: &args,
outcome: AuditOutcome::Ok,
summary: "ok",
yolo: false,
confirmed: true,
duration_ms: 3,
},
);
audit.record(&rec);
let stored = &audit.records()[0];
assert_eq!(stored.args["value"], json!("***"), "敏感路径的值必须掩码");
assert_eq!(stored.ts, "2026-10-29T20:12:33.123Z");
}
#[test]
fn jsonl_audit_writes_one_line_per_record_per_day() {
let dir = std::env::temp_dir().join(format!("unlucky-agent-audit-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let audit = JsonlAudit::new(&dir, Arc::clone(&clock));
let args = json!({});
let rec = make_record(
clock.as_ref(),
"s1",
AuditDraft {
tool: "get_config",
args: &args,
outcome: AuditOutcome::Ok,
summary: "ok",
yolo: false,
confirmed: false,
duration_ms: 1,
},
);
audit.record(&rec);
audit.record(&rec);
let path = dir.join("audit-2026-10-29.jsonl");
let content = std::fs::read_to_string(&path).expect("审计文件应存在");
assert_eq!(content.lines().count(), 2);
let first: AuditRecord = serde_json::from_str(content.lines().next().unwrap()).unwrap();
assert_eq!(first.tool, "get_config");
let _ = std::fs::remove_dir_all(&dir);
}
}
+267
View File
@@ -0,0 +1,267 @@
//! 对话协议类型 + [`ChatProvider`] trait(四态端点配置在 [`super::provider`])。
//!
//! 这里**没有协议方言**:OpenAI/Ollama/Anthropic 的报文差异全部由实现方
//! (阶段 B 的 [`super::rig`])吃掉。本模块只定义循环需要的三样东西:
//! 消息、请求/回复、流式增量回调。
//!
//! 一次工具调用的完整往返:
//! `Assistant { tool_calls }` → 编排层执行 → `Tool { call_id, name, content }` → 再次补全。
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::provider::{ProviderConfig, ProviderError};
use crate::event::{AgentEvent, AgentEventSink};
use crate::registry::ToolDefinition;
/// 一条工具调用请求(模型产出)。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
/// 调用 id(OpenAI 方言的 `tool_call_id`;Ollama 原生没有 id,由适配层生成)。
pub id: String,
/// 工具名。
pub name: String,
/// 参数(JSON 对象)。
pub arguments: Value,
}
/// 对话消息。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ChatMessage {
/// 系统提示词(每轮由 [`super::BrainConfig::system_prompt`] 重新注入,不进历史)。
System {
/// 提示词正文。
content: String,
},
/// 用户输入。
User {
/// 正文。
content: String,
},
/// 模型回复(可能带工具调用)。
Assistant {
/// 文本正文(工具调用轮可能为空)。
text: String,
/// 工具调用。
tool_calls: Vec<ToolCall>,
},
/// 工具结果回灌。
Tool {
/// 对应调用 id。
call_id: String,
/// 工具名(Ollama 原生靠 `tool_name` 配对,不是 id)。
name: String,
/// 结果 JSON 字符串。
content: String,
},
}
impl ChatMessage {
/// 便捷:系统消息。
#[must_use]
pub fn system(content: impl Into<String>) -> Self {
Self::System {
content: content.into(),
}
}
/// 便捷:用户消息。
#[must_use]
pub fn user(content: impl Into<String>) -> Self {
Self::User {
content: content.into(),
}
}
/// 便捷:工具结果消息。
#[must_use]
pub fn tool(
call_id: impl Into<String>,
name: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self::Tool {
call_id: call_id.into(),
name: name.into(),
content: content.into(),
}
}
}
/// 一次补全请求。
#[derive(Debug, Clone, PartialEq)]
pub struct ChatRequest {
/// 模型名。
pub model: String,
/// 消息(system 在首位)。
pub messages: Vec<ChatMessage>,
/// 工具定义(双投影的 rig 侧载荷)。
pub tools: Vec<ToolDefinition>,
/// 采样温度。
pub temperature: Option<f32>,
/// token 上限。
pub max_tokens: Option<u32>,
}
/// token 用量(本地模型可能不报)。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
/// 输入 token。
pub prompt_tokens: Option<u32>,
/// 输出 token。
pub completion_tokens: Option<u32>,
}
/// 一次补全结果。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ChatReply {
/// 文本正文。
pub text: String,
/// 工具调用(非空 ⇒ 循环继续)。
pub tool_calls: Vec<ToolCall>,
/// 用量。
pub usage: Option<Usage>,
}
impl ChatReply {
/// 纯文本回复。
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
tool_calls: Vec::new(),
usage: None,
}
}
/// 带工具调用的回复。
#[must_use]
pub fn with_tool_calls(tool_calls: Vec<ToolCall>) -> Self {
Self {
text: String::new(),
tool_calls,
usage: None,
}
}
}
/// 流式增量接收者。实现方必须**非阻塞**。
pub trait DeltaObserver: Send + Sync {
/// 收到一段文本增量。
fn on_delta(&self, text: &str);
}
/// 丢弃增量(不关心流式的调用方)。
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopObserver;
impl DeltaObserver for NoopObserver {
fn on_delta(&self, _text: &str) {}
}
/// 把增量转成 [`AgentEvent::TextDelta`](P1 聊天面板逐字渲染就靠它)。
#[derive(Clone)]
pub struct EventDeltaObserver {
sink: Arc<dyn AgentEventSink>,
}
impl EventDeltaObserver {
/// 用事件流构造。
#[must_use]
pub fn new(sink: Arc<dyn AgentEventSink>) -> Self {
Self { sink }
}
}
impl DeltaObserver for EventDeltaObserver {
fn on_delta(&self, text: &str) {
self.sink.emit(AgentEvent::TextDelta {
text: text.to_string(),
});
}
}
/// 收集增量(测试断言流式拼接)。
#[derive(Debug, Default)]
pub struct CollectingObserver {
chunks: std::sync::Mutex<Vec<String>>,
}
impl CollectingObserver {
/// 收完的内容。
#[must_use]
pub fn collected(&self) -> String {
self.chunks.lock().map(|c| c.concat()).unwrap_or_default()
}
}
impl DeltaObserver for CollectingObserver {
fn on_delta(&self, text: &str) {
if let Ok(mut chunks) = self.chunks.lock() {
chunks.push(text.to_string());
}
}
}
/// 补全未来的箱子类型。
pub type ProviderFuture<'a> =
Pin<Box<dyn Future<Output = Result<ChatReply, ProviderError>> + Send + 'a>>;
/// LLM 端点抽象:**这是 agent 唯一的异步边界**。
///
/// P0 实现 = 测试里的 mock;阶段 B 实现 = [`super::rig`] 的 rig 适配器。
/// 换实现(genai / 自写循环)只需要满足本 trait,[`super::Brain`] 不动。
pub trait ChatProvider: Send + Sync {
/// 当前配置(循环用它取 model/temperature,也用它判断是否启用)。
fn config(&self) -> &ProviderConfig;
/// 一次补全。流式增量通过 `observer` 回吐。
fn complete<'a>(
&'a self,
request: &'a ChatRequest,
observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a>;
}
/// 装配期用的 provider 工厂:按配置造实现(阶段 B 在这里接 rig,测试接 mock)。
pub type ProviderFactory =
Arc<dyn Fn(&ProviderConfig) -> Result<Arc<dyn ChatProvider>, ProviderError> + Send + Sync>;
#[cfg(test)]
mod tests {
use super::*;
use crate::event::MemoryEventSink;
#[test]
fn collecting_observer_concatenates_chunks() {
let observer = CollectingObserver::default();
observer.on_delta("正在");
observer.on_delta("读取日志");
assert_eq!(observer.collected(), "正在读取日志");
}
#[test]
fn event_observer_emits_text_delta() {
let sink = Arc::new(MemoryEventSink::new());
let observer = EventDeltaObserver::new(sink.clone());
observer.on_delta("嗨");
assert_eq!(sink.streamed_text(), "嗨");
}
#[test]
fn message_helpers_round_trip_through_json() {
let messages = vec![
ChatMessage::system("你是助手"),
ChatMessage::user("看看日志"),
ChatMessage::tool("call_1", "read_game_log", "{}"),
];
let json = serde_json::to_string(&messages).expect("可序列化");
let back: Vec<ChatMessage> = serde_json::from_str(&json).expect("可反序列化");
assert_eq!(back, messages);
assert!(json.contains("\"role\":\"tool\""));
}
}
+347
View File
@@ -0,0 +1,347 @@
//! 工具执行编排:schema 校验 → 分级确认 → 执行 → 审计 → 事件。
//!
//! **YOLO 只在这里**(mission M16 任务 3 的分工):工具自己不判断 policy,
//! 只声明「这次需要确认 + diff」;本模块按 [`ConfirmPolicy`] 决定问不问。
//!
//! 一次工具调用最多两条审计记录:
//! 1. 需要确认时先记一条 `awaiting_confirmation`(「AI 想改 X」本身必须留痕);
//! 2. 结局记一条 `ok` / `denied` / `tool_error`(带 `yolo` 与 `confirmed` 标记)。
//!
//! 这两条区分了三种在复盘里完全不同的事:用户点了拒绝、YOLO 自动放行、用户点了允许。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use serde_json::{json, Value};
use crate::audit::{make_record, AuditDraft, AuditOutcome, AuditSink};
use crate::brain::chat::ToolCall;
use crate::clock::SharedClock;
use crate::confirm::{ConfirmDecision, ConfirmGate, ConfirmPolicy, ConfirmRequest, DenyGuidance};
use crate::event::{AgentEvent, AgentEventSink};
use crate::registry::validate::validate;
use crate::registry::{ToolCtx, ToolError, ToolRegistry};
/// 一次工具执行的结局(回灌给模型的内容 + 成败标记)。
#[derive(Debug, Clone, PartialEq)]
pub struct ToolExecOutcome {
/// 回灌内容(结构化)。
pub content: Value,
/// 是否成功(UI/事件用;模型看的是 `content`)。
pub ok: bool,
}
impl ToolExecOutcome {
/// 成功。
#[must_use]
pub fn ok(content: Value) -> Self {
Self { content, ok: true }
}
/// 失败(`code` 给模型看,帮助它改正后重试)。
#[must_use]
pub fn error(code: &str, message: impl Into<String>) -> Self {
Self {
content: json!({ "status": "error", "code": code, "message": message.into() }),
ok: false,
}
}
/// 序列化成回灌给模型的字符串(工具消息的 `content`)。
#[must_use]
pub fn to_model_content(&self) -> String {
serde_json::to_string(&self.content).unwrap_or_else(|_| {
json!({ "status": "error", "code": "internal", "message": "结果序列化失败" })
.to_string()
})
}
}
/// 工具执行器(无状态,可跨轮复用)。
pub struct ToolExecutor {
registry: Arc<ToolRegistry>,
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
guidance: DenyGuidance,
confirm_seq: AtomicU64,
}
impl ToolExecutor {
/// 装配(默认拒绝口径 = 内置对话:[`DenyGuidance::InApp`])。
pub fn new(
registry: Arc<ToolRegistry>,
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
) -> Self {
Self {
registry,
audit,
gate,
policy,
clock,
sink,
guidance: DenyGuidance::InApp,
confirm_seq: AtomicU64::new(0),
}
}
/// 换拒绝口径(外部 MCP 客户端用 [`DenyGuidance::External`]:没有弹窗可点)。
///
/// 只改**文案**:策略、闸门、审计标记一个字都不变——这正是「同一注册表、
/// 同一安全策略、同一审计」在代码上的落点。
#[must_use]
pub fn with_guidance(mut self, guidance: DenyGuidance) -> Self {
self.guidance = guidance;
self
}
/// 执行一次工具调用(含确认与审计)。
pub async fn execute(
&self,
agent_session_id: &str,
call: &ToolCall,
step: usize,
) -> ToolExecOutcome {
let started_at = self.clock.now_unix_ms();
self.sink.emit(AgentEvent::ToolCallStarted {
tool: call.name.clone(),
// 与审计同一套脱敏:事件(P1 会持久化)不能带出凭据明文。
args: crate::redact::redact_args(&call.arguments),
step,
});
let Some(spec) = self.registry.get(&call.name).cloned() else {
let message = format!(
"没有名为 `{}` 的工具。可用工具:{}",
call.name,
self.registry.names().join("、")
);
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::UnknownTool,
&message,
false,
false,
started_at,
);
self.finish_event(&call.name, false, &message, false);
return ToolExecOutcome::error("unknown_tool", message);
};
if let Err(errors) = validate(&spec.input_schema, &call.arguments) {
let message = format!("参数不合法:{}", errors.join(";"));
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::InvalidArgs,
&message,
false,
false,
started_at,
);
self.finish_event(&call.name, false, &message, false);
return ToolExecOutcome::error("invalid_args", message);
}
let ctx = ToolCtx {
agent_session_id: agent_session_id.to_string(),
confirmed: false,
};
let quoted = match (spec.handler)(&ctx, &call.arguments) {
Ok(output) => output,
Err(err) => return self.fail(agent_session_id, call, err, started_at),
};
let Some(confirmation) = quoted.confirmation.clone() else {
return self.succeed(agent_session_id, call, quoted, false, false, started_at);
};
// ── 需要确认:决定问不问(YOLO 只影响这里)──
let asked = self.policy.requires_confirmation(confirmation.sensitivity);
let yolo_auto = !asked && confirmation.sensitivity == crate::confirm::Sensitivity::High;
if asked {
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::AwaitingConfirmation,
&confirmation.summary,
false,
false,
started_at,
);
let request = ConfirmRequest {
id: format!(
"confirm-{}",
self.confirm_seq.fetch_add(1, Ordering::Relaxed) + 1
),
tool: call.name.clone(),
summary: confirmation.summary.clone(),
details: confirmation.details.clone(),
sensitivity: confirmation.sensitivity,
};
self.sink.emit(AgentEvent::ConfirmRequested {
request: Box::new(request.clone()),
});
if self.gate.request(request).await == ConfirmDecision::Denied {
let message = self.guidance.message().to_string();
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::Denied,
&message,
false,
false,
started_at,
);
self.sink.emit(AgentEvent::ToolDenied {
tool: call.name.clone(),
reason: message.clone(),
});
self.finish_event(&call.name, false, &message, true);
return ToolExecOutcome::error("denied", message);
}
}
// ── 已获授权(用户允许或 YOLO):带 confirmed 重放 ──
let confirmed_ctx = ToolCtx {
agent_session_id: agent_session_id.to_string(),
confirmed: true,
};
match (spec.handler)(&confirmed_ctx, &call.arguments) {
Ok(output) => {
if let Some(second) = output.confirmation {
// 工具在"已确认"状态下仍要确认 = 实现写错了,明确报出来而不是死循环。
let message = format!(
"工具 `{}` 在已确认状态下仍请求确认:{}",
call.name, second.summary
);
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::ToolError,
&message,
yolo_auto,
asked,
started_at,
);
self.finish_event(&call.name, false, &message, true);
return ToolExecOutcome::error("internal", message);
}
self.succeed(agent_session_id, call, output, asked, yolo_auto, started_at)
}
Err(err) => {
self.fail_after_confirm(agent_session_id, call, err, asked, yolo_auto, started_at)
}
}
}
fn succeed(
&self,
agent_session_id: &str,
call: &ToolCall,
output: crate::registry::ToolOutput,
confirmed: bool,
yolo: bool,
started_at: u64,
) -> ToolExecOutcome {
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::Ok,
&output.summary,
yolo,
confirmed,
started_at,
);
self.finish_event(
&call.name,
true,
&output.summary,
output.confirmation.is_some(),
);
ToolExecOutcome::ok(output.content)
}
fn fail(
&self,
agent_session_id: &str,
call: &ToolCall,
err: ToolError,
started_at: u64,
) -> ToolExecOutcome {
self.fail_after_confirm(agent_session_id, call, err, false, false, started_at)
}
fn fail_after_confirm(
&self,
agent_session_id: &str,
call: &ToolCall,
err: ToolError,
confirmed: bool,
yolo: bool,
started_at: u64,
) -> ToolExecOutcome {
let message = err.message().to_string();
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::ToolError,
&message,
yolo,
confirmed,
started_at,
);
self.finish_event(&call.name, false, &message, false);
ToolExecOutcome::error(err.code(), message)
}
#[allow(clippy::too_many_arguments)]
fn record(
&self,
agent_session_id: &str,
tool: &str,
args: &Value,
outcome: AuditOutcome,
summary: &str,
yolo: bool,
confirmed: bool,
started_at: u64,
) {
let duration_ms = self.clock.now_unix_ms().saturating_sub(started_at);
let draft = AuditDraft {
tool,
args,
outcome,
summary,
yolo,
confirmed,
duration_ms,
};
self.audit
.record(&make_record(self.clock.as_ref(), agent_session_id, draft));
}
fn finish_event(&self, tool: &str, ok: bool, summary: &str, requires_confirmation: bool) {
self.sink.emit(AgentEvent::ToolCallFinished {
tool: tool.to_string(),
ok,
summary: summary.to_string(),
requires_confirmation,
});
}
}
+323
View File
@@ -0,0 +1,323 @@
//! Agent 大脑:Provider 抽象 + 对话循环 + 流式事件。
//!
//! 选型(M10 定稿):`rig-core` + `rig-agent`,**版本锁 `=0.45.x`**——上游 12 周 5 个 minor
//! 的 breaking 频率决定了必须锁死;依赖面只在本文件收口,其它模块不许直接碰 rig,
//! 以便随时换 genai / 自写循环逃生(`DevDocs/PlanDoc/02-ai-agent-design.md` §0)。
//!
//! Provider 形态(02 §2):OpenAI 兼容(默认,含本地 llama.cpp `127.0.0.1:PORT/v1`)、
//! Anthropic 原生、Ollama 原生、以及「禁用」——禁用时规则引擎仍可用。
//!
//! 统一 trait(M16 落地):`stream_chat(messages, tools) -> Stream<Event>`,含 tool_call 事件。
//! 本 crate 的落地形态是 [`chat::ChatProvider::complete`](`request + observer → ChatReply`):
//! rig 0.45 的流在 [`rig`] 适配层里展开成增量回调,循环侧只消费「文本增量 + 工具调用」两件事。
//! **rig 依赖面收口在 [`rig`] 一个文件**,本文件不 `use rig_*`。
//!
//! ## 对话循环(02 文档 §3.3)
//!
//! ```text
//! run_turn(user_input)
//! ├─ provider.complete(request) ← 流式增量经 EventDeltaObserver → UI
//! ├─ 有 tool_calls → ToolExecutor.execute(校验/确认/YOLO/审计)→ Tool 消息回灌
//! └─ 没有 tool_calls → 结束,返回最终文本
//! ```
//!
//! 三条不变量:
//! 1. **system prompt 每轮重新注入**,不进历史(改提示词立刻生效,也不会被压缩逻辑吃掉);
//! 2. 工具结果**永远回灌**(包括失败),模型才有机会改正;
//! 3. 步数上限 [`DEFAULT_MAX_STEPS`] 兜底,防止模型在两个工具间打转烧 token。
pub mod chat;
pub mod exec;
pub mod provider;
pub mod rig;
use std::fmt;
use std::sync::Arc;
use crate::audit::AuditSink;
use crate::clock::SharedClock;
use crate::confirm::{ConfirmGate, ConfirmPolicy};
use crate::event::{AgentEvent, AgentEventSink};
use crate::registry::ToolRegistry;
pub use chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, EventDeltaObserver,
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
};
pub use exec::{ToolExecOutcome, ToolExecutor};
pub use provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
/// 一轮对话最多几轮工具调用。
pub const DEFAULT_MAX_STEPS: usize = 8;
/// 内置系统提示词(P0 版本;P2 会按实例上下文按需注入)。
#[must_use]
pub fn default_system_prompt() -> String {
[
"你是 Unlucky-Launcher(ULL,Minecraft 启动器)的内置助手。",
"你能调用工具:读配置(get_config)、改配置(set_config)、列出游戏会话(list_game_sessions)、",
"读会话日志(read_game_log)、跑崩溃规则引擎(diagnose_crash)。",
"纪律:",
"1. 先查后改:改任何配置前先用 get_config 确认键名与现值。",
"2. 崩溃问题先看 diagnose_crash 的规则结论;规则未命中或需要更多信息时,才用 read_game_log",
" 取 stderr / exit / crash_found,不要拉全量日志。",
"3. 只能改 get_config 列出的可写键。敏感键会要求用户确认:被拒绝就换方案,不要重复尝试同一改动。",
"4. 用中文回答,给用户可以立即执行的下一步;不确定时直说不确定,不要编造规则或日志内容。",
]
.join("\n")
}
/// 循环参数。
#[derive(Debug, Clone)]
pub struct BrainConfig {
/// 最多几轮工具调用。
pub max_steps: usize,
/// 系统提示词。
pub system_prompt: String,
}
impl Default for BrainConfig {
fn default() -> Self {
Self {
max_steps: DEFAULT_MAX_STEPS,
system_prompt: default_system_prompt(),
}
}
}
impl BrainConfig {
/// 自定义提示词(步数用默认)。
#[must_use]
pub fn with_system_prompt(system_prompt: impl Into<String>) -> Self {
Self {
max_steps: DEFAULT_MAX_STEPS,
system_prompt: system_prompt.into(),
}
}
}
/// 循环的旁路依赖(审计/确认/时钟/事件)。
pub struct BrainDeps {
/// 审计出口。
pub audit: Arc<dyn AuditSink>,
/// 确认闸门(P0 = [`crate::confirm::NoUiGate`])。
pub gate: Arc<dyn ConfirmGate>,
/// YOLO 等策略快照。
pub policy: ConfirmPolicy,
/// 时间源。
pub clock: SharedClock,
/// 事件出口。
pub sink: Arc<dyn AgentEventSink>,
}
impl BrainDeps {
/// 装配。
pub fn new(
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
) -> Self {
Self {
audit,
gate,
policy,
clock,
sink,
}
}
}
/// 一轮对话的结局。
#[derive(Debug, Clone, PartialEq)]
pub struct TurnOutcome {
/// 最终文本(可能是空的,例如撞上步数上限且模型最后一句只有工具调用)。
pub text: String,
/// 用掉的轮次。
pub steps: usize,
/// 工具调用总次数。
pub tool_calls: usize,
/// 为什么停下来。
pub stopped_reason: StopReason,
}
/// 停止原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
/// 模型给出了最终回答。
Completed,
/// 撞上 [`BrainConfig::max_steps`]。
StepLimit,
}
/// 循环级错误。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BrainError {
/// AI 被禁用(规则引擎仍可用)。
Disabled,
/// provider 报错。
Provider(ProviderError),
}
impl fmt::Display for BrainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Disabled => f.write_str(DISABLED_MESSAGE),
Self::Provider(err) => write!(f, "LLM 调用失败:{err}"),
}
}
}
impl std::error::Error for BrainError {}
/// AI 被禁用时给用户的话(装配层直接展示,不用先构造 Brain)。
pub const DISABLED_MESSAGE: &str =
"AI 已禁用:请在设置 → AI 中配置端点。规则引擎崩溃诊断不受影响。";
/// 对话循环。
pub struct Brain {
provider: Arc<dyn ChatProvider>,
registry: Arc<ToolRegistry>,
executor: ToolExecutor,
config: BrainConfig,
sink: Arc<dyn AgentEventSink>,
}
impl Brain {
/// 装配。
pub fn new(
provider: Arc<dyn ChatProvider>,
registry: Arc<ToolRegistry>,
deps: BrainDeps,
config: BrainConfig,
) -> Self {
let executor = ToolExecutor::new(
Arc::clone(&registry),
Arc::clone(&deps.audit),
Arc::clone(&deps.gate),
deps.policy,
Arc::clone(&deps.clock),
Arc::clone(&deps.sink),
);
Self {
provider,
registry,
executor,
config,
sink: deps.sink,
}
}
/// 当前 provider 配置(设置页/UI 展示用)。
#[must_use]
pub fn provider_config(&self) -> &ProviderConfig {
self.provider.config()
}
/// 跑一轮对话。`history` 由调用方持有(P1 落盘为 `agent/sessions/*.jsonl`)。
///
/// # Errors
/// AI 被禁用([`BrainError::Disabled`])或 provider 报错([`BrainError::Provider`])。
pub async fn run_turn(
&self,
agent_session_id: &str,
history: &mut Vec<ChatMessage>,
user_input: &str,
) -> Result<TurnOutcome, BrainError> {
if !self.provider.config().is_enabled() {
self.sink.emit(AgentEvent::Error {
message: DISABLED_MESSAGE.to_string(),
});
return Err(BrainError::Disabled);
}
self.sink.emit(AgentEvent::TurnStarted {
session_id: agent_session_id.to_string(),
user_text: user_input.to_string(),
});
history.push(ChatMessage::user(user_input));
let observer = EventDeltaObserver::new(Arc::clone(&self.sink));
let mut tool_calls = 0usize;
let mut last_text = String::new();
let mut used_steps = 0usize;
for step in 1..=self.config.max_steps {
used_steps = step;
let request = self.build_request(history);
let reply = match self.provider.complete(&request, &observer).await {
Ok(reply) => reply,
Err(err) => {
self.sink.emit(AgentEvent::Error {
message: err.to_string(),
});
return Err(BrainError::Provider(err));
}
};
if !reply.text.trim().is_empty() {
last_text = reply.text.clone();
self.sink.emit(AgentEvent::AssistantText {
text: reply.text.clone(),
});
}
if reply.tool_calls.is_empty() {
history.push(ChatMessage::Assistant {
text: reply.text.clone(),
tool_calls: Vec::new(),
});
self.sink.emit(AgentEvent::TurnFinished {
steps: step,
text: reply.text.clone(),
});
return Ok(TurnOutcome {
text: reply.text,
steps: step,
tool_calls,
stopped_reason: StopReason::Completed,
});
}
history.push(ChatMessage::Assistant {
text: reply.text.clone(),
tool_calls: reply.tool_calls.clone(),
});
for call in &reply.tool_calls {
let outcome = self.executor.execute(agent_session_id, call, step).await;
tool_calls += 1;
history.push(ChatMessage::tool(
call.id.clone(),
call.name.clone(),
outcome.to_model_content(),
));
}
}
self.sink.emit(AgentEvent::TurnFinished {
steps: used_steps,
text: last_text.clone(),
});
Ok(TurnOutcome {
text: last_text,
steps: used_steps,
tool_calls,
stopped_reason: StopReason::StepLimit,
})
}
fn build_request(&self, history: &[ChatMessage]) -> ChatRequest {
let config = self.provider.config();
let mut messages = Vec::with_capacity(history.len() + 1);
messages.push(ChatMessage::system(self.config.system_prompt.clone()));
messages.extend_from_slice(history);
ChatRequest {
model: config.model.clone(),
messages,
tools: self.registry.definitions(),
temperature: config.temperature,
max_tokens: config.max_tokens,
}
}
}
+324
View File
@@ -0,0 +1,324 @@
//! Provider 四态端点配置(02 文档 §2)。
//!
//! | 形态 | 端点 | 备注 |
//! |------|------|------|
//! | OpenAI 兼容(默认) | `base_url` + `api_key` + `model` | 覆盖 OpenAI/DeepSeek/Qwen/GLM/**本地 llama.cpp server**(`http://127.0.0.1:8080/v1`)/LM Studio |
//! | Anthropic 原生 | `api_key` + `model` | Claude 系列(没有可用的 Rust 单厂商 SDK,见 research/02 §3.6) |
//! | Ollama 原生 | `http://127.0.0.1:11434` | 本地另一种主流 |
//! | 禁用 | — | 完全关闭 agent,规则引擎(`diagnose_crash`)仍可用 |
//!
//! 本文件只放**配置与校验**;对话协议在 [`super::chat`]。这样 rig 的 pre-1.0 破坏面
//! (research/02 §7 R1)被关在 [`super::rig`] 一个文件里,这里连 rig 的名字都不出现。
use std::fmt;
use serde::{Deserialize, Serialize};
#[path = "provider/failure.rs"]
mod failure;
pub use failure::ProviderFailure;
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
/// Ollama 默认端点(research/02 §5.2)。
pub const OLLAMA_DEFAULT_BASE_URL: &str = "http://127.0.0.1:11434";
/// Anthropic 官方 API 端点。
pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
/// Provider 四态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
/// OpenAI 兼容(含本地 llama.cpp / LM Studio / DeepSeek / Qwen…)。
OpenAiCompatible,
/// Anthropic 原生。
Anthropic,
/// Ollama 原生。
Ollama,
/// 完全禁用 agent。
Disabled,
}
impl ProviderKind {
/// 该形态是否必须提供 API key(本地推理通常不需要)。
#[must_use]
pub fn requires_api_key(self) -> bool {
matches!(self, Self::Anthropic)
}
/// 该形态的默认端点(OpenAI 兼容没有统一默认值,必须显式填;
/// 本地 llama.cpp 走 [`ProviderConfig::llamacpp_local`] 带的显式默认)。
#[must_use]
pub fn default_base_url(self) -> &'static str {
match self {
Self::OpenAiCompatible => "",
Self::Anthropic => ANTHROPIC_DEFAULT_BASE_URL,
Self::Ollama => OLLAMA_DEFAULT_BASE_URL,
Self::Disabled => "",
}
}
/// 配置键 `ai.provider` 的取值 → 形态(M174)。
///
/// **不能用 serde**:`#[serde(rename_all = "snake_case")]` 把本枚举写成
/// `open_ai_compatible`(见本文件末尾的用例),而 schema 的允许值表
/// (`core::setting::keys::AI_PROVIDERS`)与设置页存的是 `openai_compatible`。两个词表必须在这里
/// 显式对上,否则「设置里存的值」读不到——这正是 M174 之前 agent 侧一次都没读过设置的原因。
/// 未知值给 `None`(由调用方回落默认,不猜)。
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
match value.trim() {
"openai_compatible" => Some(Self::OpenAiCompatible),
"anthropic" => Some(Self::Anthropic),
"ollama" => Some(Self::Ollama),
"disabled" => Some(Self::Disabled),
_ => None,
}
}
/// 本形态在 `ai.provider` 里的取值([`Self::from_setting`] 的逆;写回设置时用)。
#[must_use]
pub fn setting_value(self) -> &'static str {
match self {
Self::OpenAiCompatible => "openai_compatible",
Self::Anthropic => "anthropic",
Self::Ollama => "ollama",
Self::Disabled => "disabled",
}
}
/// 设置页展示名。
#[must_use]
pub fn display_name(self) -> &'static str {
match self {
Self::OpenAiCompatible => "OpenAI 兼容",
Self::Anthropic => "Anthropic 原生",
Self::Ollama => "Ollama 原生",
Self::Disabled => "已禁用",
}
}
}
/// Provider 配置(设置 → AI 的一份快照)。
///
/// `api_key` 只在进程内存在,**任何日志/审计/事件都不得带它**——
/// `Debug` 是手写的(掩码 key),要连形态一起看用 [`ProviderConfig::redacted_debug`]。
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderConfig {
/// 四态。
pub kind: ProviderKind,
/// 端点;空串表示用 [`ProviderKind::default_base_url`]。
pub base_url: String,
/// 模型名;`Disabled` 时可为空。
pub model: String,
/// API key(本地推理可空)。
pub api_key: Option<String>,
/// 采样温度。
pub temperature: Option<f32>,
/// 单次回复 token 上限。
pub max_tokens: Option<u32>,
}
impl Default for ProviderConfig {
fn default() -> Self {
Self::disabled()
}
}
impl ProviderConfig {
fn base(kind: ProviderKind, base_url: &str, model: &str) -> Self {
Self {
kind,
base_url: base_url.to_string(),
model: model.to_string(),
api_key: None,
temperature: None,
max_tokens: None,
}
}
/// OpenAI 兼容端点(云端或自建)。
#[must_use]
pub fn openai_compatible(base_url: &str, model: &str) -> Self {
Self::base(ProviderKind::OpenAiCompatible, base_url, model)
}
/// 本机 llama.cpp server(用户就在跑 llama-server 的默认场景)。
#[must_use]
pub fn llamacpp_local(model: &str) -> Self {
Self::base(
ProviderKind::OpenAiCompatible,
LLAMACPP_DEFAULT_BASE_URL,
model,
)
}
/// Anthropic 原生。
#[must_use]
pub fn anthropic(model: &str) -> Self {
Self::base(ProviderKind::Anthropic, ANTHROPIC_DEFAULT_BASE_URL, model)
}
/// 本机 Ollama。
#[must_use]
pub fn ollama(model: &str) -> Self {
Self::base(ProviderKind::Ollama, OLLAMA_DEFAULT_BASE_URL, model)
}
/// 禁用 agent。
#[must_use]
pub fn disabled() -> Self {
Self::base(ProviderKind::Disabled, "", "")
}
/// 带上 API key。
#[must_use]
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
/// 带上温度。
#[must_use]
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}
/// agent 是否启用。
#[must_use]
pub fn is_enabled(&self) -> bool {
self.kind != ProviderKind::Disabled
}
/// 实际使用的端点(空则回落默认值)。
#[must_use]
pub fn resolved_base_url(&self) -> &str {
if self.base_url.trim().is_empty() {
self.kind.default_base_url()
} else {
self.base_url.trim()
}
}
/// 配置是否可用(装配期调用一次,错就进设置页提示而不是等到对话时炸)。
///
/// # Errors
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
pub fn validate(&self) -> Result<(), ProviderError> {
if !self.is_enabled() {
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
));
}
if self.model.trim().is_empty() {
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"没有配置模型名(model)",
));
}
if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
format!("{} 需要填写端点(base_url)", self.kind.display_name()),
));
}
if self.kind.requires_api_key()
&& self
.api_key
.as_deref()
.is_none_or(|key| key.trim().is_empty())
{
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
format!("{} 需要 API key", self.kind.display_name()),
));
}
Ok(())
}
/// 可打印的形态描述(**key 已掩码**,给日志/审计/设置页用)。
#[must_use]
pub fn redacted_debug(&self) -> String {
let key = match self.api_key.as_deref() {
None => "无".to_string(),
Some(k) if k.trim().is_empty() => "无".to_string(),
Some(_) => crate::redact::MASK.to_string(),
};
format!(
"{} base_url={} model={} api_key={key}",
self.kind.display_name(),
self.resolved_base_url(),
if self.model.is_empty() {
"(未设置)"
} else {
&self.model
},
)
}
}
impl fmt::Debug for ProviderConfig {
/// 手写 Debug:**key 掩码**(`api_key` 是唯一不能出现在日志里的字段)。
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("base_url", &self.resolved_base_url())
.field("model", &self.model)
.field(
"api_key",
&self.api_key.as_deref().map(|_| crate::redact::MASK),
)
.field("temperature", &self.temperature)
.field("max_tokens", &self.max_tokens)
.finish()
}
}
/// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的
/// `ErrorReport` 翻译,见 [`failure`])。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderError {
kind: ProviderFailure,
message: String,
}
impl ProviderError {
/// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
kind: ProviderFailure::Other,
message: message.into(),
}
}
/// 带分类构造(`brain::rig` 的 `map_error` 与 [`ProviderConfig::validate`] 用)。
#[must_use]
pub fn classified(kind: ProviderFailure, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 失败分类(「测试连接」按它分档)。
#[must_use]
pub fn kind(&self) -> ProviderFailure {
self.kind
}
}
impl fmt::Display for ProviderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ProviderError {}
#[cfg(test)]
#[path = "provider/tests.rs"]
mod tests;
@@ -0,0 +1,62 @@
//! Provider 失败的**结构化分类**(M174 r1 复审 P2-5):一次「测试连接」失败到底属于哪一类。
//!
//! ## 为什么不是字符串匹配
//!
//! r1 的版本对 `ProviderError` 的 `Display` 做子串匹配(找 `"401"` / `"connection refused"` /
//! `"timed out"`),有三个真问题:①URL / 请求 id / 模型名里出现 `401` 这类数字就误判(假阳);
//! ②「连接被拒」的实际文本是 `Http client error: error sending request for url (…)`,不含
//! `connection refused`,只能靠补一个更宽的 `error sending request` 兜住——**这是在猜文本**;
//! ③文本随 rig / reqwest 版本漂,升级一次就静默错档(复审实测:`closed_port_is_unreachable`
//! 一度偶发红)。
//!
//! 现在分类由 [`super::rig`] 的适配层从 rig 0.45 的**结构化报告**(`ErrorReport`:`kind` /
//! `http_status` / `code`)翻译出来,本文件只放枚举与「给界面看的稳定名」。本模块不认识 rig。
//!
//! ## 六档口径
//!
//! | 档 | 判据(`super::rig::map_error`) | 界面含义 |
//! |---|---|---|
//! | [`ProviderFailure::NotConfigured`] | 配置不完整(`validate()` 拒绝)——**没有发请求** | 先把配置填完 |
//! | [`ProviderFailure::Auth`] | 有 HTTP 状态且是 401 / 403,或 rig 明确报「凭据被拒」 | 密钥/权限不对 |
//! | [`ProviderFailure::ModelNotFound`] | 有 HTTP 状态 404,或响应体的机器码是 `model_not_found` | 端点通了,模型不存在 |
//! | [`ProviderFailure::Unreachable`] | 传输层失败:连接被拒 / DNS / TLS / 响应被截断(**没有拿到任何回应**) | 端点不可达 |
//! | [`ProviderFailure::Timeout`] | 调用方的时限(探测的 8 秒)或 rig 报 `ErrorKind::Timeout` / 传输层 `io::ErrorKind::TimedOut` | 太慢或端点不响应 |
//! | [`ProviderFailure::Other`] | 其余(JSON / URL / 请求构造 / 5xx 之类) | 请求失败(详情见下) |
/// 一次 provider 调用的失败分类(见模块文档的六档表)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderFailure {
/// 配置不完整:**没有发出任何请求**。
NotConfigured,
/// 鉴权失败(401 / 403 / 凭据被拒)。
Auth,
/// 模型不存在(404 / `model_not_found`)。
ModelNotFound,
/// 端点不可达(传输层失败,没拿到任何回应)。
Unreachable,
/// 超时(调用方时限或传输层超时)。
Timeout,
/// 其它(协议 / 解析 / 5xx / 未知)。
Other,
}
impl ProviderFailure {
/// 稳定名(日志 / CLI / 测试断言用;界面文案走 i18n)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConfigured => "not_configured",
Self::Auth => "auth",
Self::ModelNotFound => "model_not_found",
Self::Unreachable => "unreachable",
Self::Timeout => "timeout",
Self::Other => "other",
}
}
/// 「配置不完整」以外都意味着**请求真的发出去了**(守卫②/③的判据)。
#[must_use]
pub const fn sent_a_request(self) -> bool {
!matches!(self, Self::NotConfigured)
}
}
+107
View File
@@ -0,0 +1,107 @@
//! [`super`] 的用例(M174 r1 复审 P2-5 拆出来:`provider.rs` 撞了 400 行硬线)。
//!
//! 拆的是**纯搬移**:一条断言都没改。分类那半边的新用例在 `failure` 的模块文档里指路,
//! 结构化分类本身由 `brain/rig.rs` 的 `map_error` 产生,用例在 `probe` 的真服务器那几条。
use super::*;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn setting_vocabulary_round_trips() {
// 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的
// `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」
// agent 侧读出来是 None(M174 的根因)。
for kind in [
ProviderKind::OpenAiCompatible,
ProviderKind::Anthropic,
ProviderKind::Ollama,
ProviderKind::Disabled,
] {
assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind));
}
assert_eq!(
ProviderKind::from_setting(" ollama "),
Some(ProviderKind::Ollama)
);
assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None);
assert_eq!(ProviderKind::from_setting(""), None);
// schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。
assert_eq!(
unlucky_core::setting::keys::AI_PROVIDERS,
[
ProviderKind::OpenAiCompatible.setting_value(),
ProviderKind::Anthropic.setting_value(),
ProviderKind::Ollama.setting_value(),
ProviderKind::Disabled.setting_value(),
]
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config = ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
+345
View File
@@ -0,0 +1,345 @@
//! rig 适配层:[`ChatProvider`] 的**唯一**实现,也是整个 crate 唯一允许 `use rig_*` 的文件。
//!
//! 依赖面收窄纪律(`DevDocs/PlanDoc/02-ai-agent-design.md` §0 + research/02 §7 R1):
//! rig 是 pre-1.0、12 周 5 个 minor、CHANGELOG 满是 `[**breaking**]`(0.43 把 MCP 拆出 rig-agent、
//! 0.45 升 rmcp 3.5 并改 owned connection)。所以把它关在这一个文件里:它变了,只有这里编译失败,
//! [`super::ChatProvider`] 以上的循环/编排/审计一行不用动。
//!
//! ## 0.45 的接线(实读源码,非照文档猜)
//!
//! ```text
//! ProviderConfig ─┬─ OpenAiCompatible → OpenAIConfig::new(key).with_base_url(url).client().chat(model)
//! ├─ Anthropic → AnthropicConfig::new(key).with_base_url(url).client().completion(model)
//! ├─ Ollama → OllamaConfig::new().with_base_url(url).client().completion(model)
//! └─ Disabled → 不构造(Brain::run_turn 已提前返回)
//! ↓ Model<W>.erase()
//! DynModel<Completion>(wire + transport 擦除,本文件只持有这一个类型)
//! ```
//!
//! 循环侧只要两件事:流式文本增量、以及折好的 [`ChatReply`](文本 + 工具调用)。
//! 所以这里用 `model.stream(request)` 边转发 [`StreamEvent::Text`] 增量给 [`DeltaObserver`],
//! 最后 `Streamed::finish()` 拿到完整 [`CompletionResponse`]——工具调用从响应里取,
//! 不自己拼分片 JSON(拼分片是 rig 的 `ReplyFold` 已经做过的事)。
//!
//! ## 三个已确认的坑
//!
//! 1. **llama.cpp 默认端点就是 `http://localhost:8080/v1`**(`providers/llamacpp/mod.rs`),
//! 与 [`LLAMACPP_DEFAULT_BASE_URL`] 一致。这里统一走 OpenAI 方言 + 显式 base_url:
//! llama.cpp / LM Studio / DeepSeek 都是 OpenAI 兼容面,方言差异主要在默认端点与特有位
//! (`chat_template_kwargs`、`num_ctx`)——真要下发这些,再换成 `providers::llamacpp` 方言或
//! Ollama 的 `native_completion`。
//! 2. **Ollama 原生方言的工具结果用 `tool_name` 配对(没有 id)**,OpenAI 方言用 `tool_call_id`。
//! 这里走 rig 的 `Ollama::completion`(OpenAI 兼容 `/v1/chat/completions`),由 rig 负责回灌形状;
//! 要下发 `num_ctx`(崩溃日志几千行,上下文必须可配)才需要 `native_completion`,留作 P1。
//! 3. **取消**:`Streamed` 是普通 future/stream,丢弃即断开——P0 用「丢弃 future」实现停止生成,
//! P1 接 UI 的「停止」按钮时按 rig 的取消语义(drop stream)对齐即可,不需要 unsafe。
//!
//! ## 逃生口(保留但不是 Plan A)
//!
//! rig 的破坏变更真伤到(或 Rust 1.92 MSRV 追不上 rig 的 1.97.1)时:换 `genai`
//! (provider + function calling,无 agent 循环)或自己写 OpenAI 方言(约 300~500 行)。
//! **代价只是实现一个新的 [`ChatProvider`]**——这正是把它关在一个文件里的意义。
//!
//! [`LLAMACPP_DEFAULT_BASE_URL`]: super::provider::LLAMACPP_DEFAULT_BASE_URL
//! [`StreamEvent::Text`]: rig_core::streaming::StreamEvent::Text
use std::sync::Arc;
use futures_util::StreamExt;
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
use rig_core::error::{ErrorKind, ProviderError as RigError};
use rig_core::message::{
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
ToolName as RigToolName, ToolResult, ToolResultContent, UserContent,
};
use rig_core::operation::Completion;
use rig_core::providers::anthropic::AnthropicConfig;
use rig_core::providers::ollama::OllamaConfig;
use rig_core::providers::openai::OpenAIConfig;
use rig_core::streaming::{Item, StreamEvent};
use rig_core::DynModel;
use super::chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
ProviderFuture,
};
use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
pub const RIG_VERSION_PIN: &str = "=0.45.0";
/// 擦掉 wire / transport 之后的补全模型——本文件对外只暴露这一个 rig 类型之外的东西。
type ErasedModel = DynModel<Completion>;
/// rig 实现的 [`ChatProvider`]:四态配置 + 一个擦除后的模型。
pub struct RigProvider {
config: ProviderConfig,
model: ErasedModel,
}
impl RigProvider {
/// 按配置造 provider。`Disabled`、缺模型名、缺 key 都在这里被拒(fail-fast)。
///
/// # Errors
/// AI 被禁用、配置不完整、或 rig 端点构造失败时返回可读错误。
pub fn new(config: ProviderConfig) -> Result<Self, ProviderError> {
config.validate()?;
let model = build_model(&config)?;
Ok(Self { config, model })
}
/// 装配层用的工厂:`ProviderFactory` 直接塞给 `crates/app`。
#[must_use]
pub fn factory() -> ProviderFactory {
Arc::new(|config: &ProviderConfig| {
Ok(Arc::new(Self::new(config.clone())?) as Arc<dyn ChatProvider>)
})
}
}
impl ChatProvider for RigProvider {
fn config(&self) -> &ProviderConfig {
&self.config
}
fn complete<'a>(
&'a self,
request: &'a ChatRequest,
observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a> {
Box::pin(async move {
let rig_request = build_request(request)?;
let mut stream = self.model.stream(rig_request).map_err(map_error)?;
// 先转发增量,再拿折好的完整响应(工具调用只从响应取,不拼分片)。
let mut text = String::new();
while let Some(item) = stream.next().await {
match item.map_err(map_error)? {
Item::Event(StreamEvent::Text { text: fragment, .. }) => {
text.push_str(&fragment);
observer.on_delta(&fragment);
}
Item::Event(_) | Item::Unknown(_) => {}
}
}
let response = stream.finish().await.map_err(map_error)?;
to_reply(&response, text)
})
}
}
/// 四态 → rig 模型。
fn build_model(config: &ProviderConfig) -> Result<ErasedModel, ProviderError> {
let model = config.model.trim().to_string();
let base_url = config.resolved_base_url().to_string();
let api_key = config.api_key.clone().unwrap_or_default();
let erased = match config.kind {
ProviderKind::OpenAiCompatible => OpenAIConfig::new(api_key)
.with_base_url(base_url)
.client()
.chat(model)
.erase(),
ProviderKind::Anthropic => AnthropicConfig::new(api_key)
.with_base_url(base_url)
.client()
.completion(model)
.erase(),
ProviderKind::Ollama => OllamaConfig::new()
.with_base_url(base_url)
.client()
.completion(model)
.erase(),
ProviderKind::Disabled => {
return Err(ProviderError::new(
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
))
}
};
Ok(erased)
}
/// 本方消息 → rig 补全请求。
fn build_request(request: &ChatRequest) -> Result<CompletionRequest, ProviderError> {
let mut history = to_rig_messages(&request.messages)?;
// rig 把历史最后一条当 prompt,其余是上下文;空历史没有可发的 prompt。
let prompt = history
.pop()
.ok_or_else(|| ProviderError::new("对话历史为空:至少需要一条用户消息"))?;
let prompt = Arc::unwrap_or_clone(prompt);
let tools = request
.tools
.iter()
.map(to_rig_tool)
.collect::<Result<Vec<_>, _>>()?;
Ok(CompletionRequest::new(prompt)
.messages(history)
.tools(tools)
.temperature(request.temperature.map(f64::from))
.max_tokens(request.max_tokens.map(u64::from)))
}
/// 本方工具定义 → rig 工具定义(入参都是裸 JSON Schema,这正是双投影的支点)。
fn to_rig_tool(tool: &crate::registry::ToolDefinition) -> Result<RigToolDefinition, ProviderError> {
let name = tool_name(&tool.name)?;
Ok(RigToolDefinition::new(
name,
tool.description.clone(),
tool.input_schema.clone(),
))
}
/// 本方消息 → rig 消息。
fn to_rig_messages(messages: &[ChatMessage]) -> Result<Vec<Arc<RigMessage>>, ProviderError> {
let mut out = Vec::with_capacity(messages.len());
for message in messages {
let rig = match message {
ChatMessage::System { content } => RigMessage::system(content.clone()),
ChatMessage::User { content } => RigMessage::user(content.clone()),
ChatMessage::Assistant { text, tool_calls } => {
let mut parts = Vec::with_capacity(tool_calls.len() + 1);
if !text.trim().is_empty() {
parts.push(RigAssistantContent::Text(RigText::new(text.clone())));
}
for call in tool_calls {
parts.push(RigAssistantContent::ToolCall(RigToolCall::new(
CallId::from_wire(call.id.clone()),
ToolFunction::new(tool_name(&call.name)?, call.arguments.clone()),
)));
}
RigMessage::Assistant(RigAssistantMessage::new(parts))
}
ChatMessage::Tool {
call_id,
name,
content,
} => RigMessage::User {
content: vec![UserContent::ToolResult(ToolResult {
call: CallId::from_wire(call_id.clone()),
name: tool_name(name)?,
content: vec![ToolResultContent::text(content.clone())],
is_error: false,
})],
},
};
out.push(Arc::new(rig));
}
Ok(out)
}
/// rig 响应 → 本方回复(文本 + 工具调用 + 用量)。
fn to_reply(
response: &rig_core::completion::CompletionResponse,
streamed: String,
) -> Result<ChatReply, ProviderError> {
let mut text = streamed;
let mut tool_calls = Vec::new();
for part in &response.choice {
match part {
RigAssistantContent::Text(chunk) => {
if text.is_empty() {
text.push_str(&chunk.text);
}
}
RigAssistantContent::ToolCall(call) => {
if let Some(raw) = call.function.invalid_arguments.as_deref() {
return Err(ProviderError::new(format!(
"模型返回的工具调用参数不是 JSON 对象(工具 `{}`):{raw}",
call.function.name.as_str()
)));
}
tool_calls.push(super::chat::ToolCall {
id: call.id.to_string(),
name: call.function.name.as_str().to_string(),
arguments: serde_json::Value::Object(call.function.arguments.clone()),
});
}
// 推理链/图片/未知块不进对话循环(P0 只消费文本 + 工具调用)。
_ => {}
}
}
Ok(ChatReply {
text,
tool_calls,
usage: Some(super::chat::Usage {
prompt_tokens: response
.usage
.input_tokens
.and_then(|v| u32::try_from(v).ok()),
completion_tokens: response
.usage
.output_tokens
.and_then(|v| u32::try_from(v).ok()),
}),
})
}
/// rig 错误 → 本方错误:**分类取自 rig 的结构化报告**(`ErrorReport` 的 `kind` /
/// `http_status` / `code`),不猜 `Display` 文本(M174 r1 复审 P2-5)。
///
/// | rig 侧信号 | 本方分类 |
/// |---|---|
/// | `http_status ∈ {401, 403}` | [`ProviderFailure::Auth`] |
/// | `http_status == 404` | [`ProviderFailure::ModelNotFound`](OpenAI 兼容面的 404 只可能是模型不在,`/chat/completions` 本身是固定路径;Anthropic 的 404 也是同一语义) |
/// | `code == "model_not_found"` / `"model_not_found_error"` | [`ProviderFailure::ModelNotFound`] |
/// | `ErrorKind::Timeout` 或传输层 `io::ErrorKind::TimedOut` | [`ProviderFailure::Timeout`] |
/// | `ErrorKind::Http`(连接被拒 / DNS / TLS / 截断) | [`ProviderFailure::Unreachable`] |
/// | 其余(JSON / URL / 请求构造 / 5xx / 未知) | [`ProviderFailure::Other`] |
fn map_error(error: RigError) -> ProviderError {
let report = error.report();
let kind = match report.kind {
ErrorKind::Timeout => ProviderFailure::Timeout,
ErrorKind::Http => {
if transport_timed_out(&error) {
ProviderFailure::Timeout
} else {
ProviderFailure::Unreachable
}
}
ErrorKind::ProviderResponse | ErrorKind::Provider => match report.http_status {
Some(401 | 403) => ProviderFailure::Auth,
Some(404) => ProviderFailure::ModelNotFound,
_ if matches!(
report.code.as_deref(),
Some("model_not_found" | "model_not_found_error")
) =>
{
ProviderFailure::ModelNotFound
}
_ => ProviderFailure::Other,
},
_ => ProviderFailure::Other,
};
ProviderError::classified(kind, report.message)
}
/// 传输层失败里有没有「超时」:沿 `source()` 链找 `io::ErrorKind::TimedOut`
/// (reqwest 把超时折在 transport error 里,rig 只留一个 `Box<dyn Error>`)。
///
/// 结构化判据(类型 + `ErrorKind`),不是文本匹配:这条路径上的其它失败(连接被拒、DNS、
/// TLS)都归 `Unreachable`。
fn transport_timed_out(error: &RigError) -> bool {
let RigError::Http(http_error) = error else {
return false;
};
let mut source = std::error::Error::source(http_error);
for _ in 0..8 {
let Some(cause) = source else { return false };
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return io.kind() == std::io::ErrorKind::TimedOut;
}
source = cause.source();
}
false
}
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
RigToolName::new(name)
.map_err(|_| ProviderError::new("工具名不能为空(ToolSpec 注册阶段应已拦截)"))
}
#[cfg(test)]
#[path = "rig/tests.rs"]
mod tests;
+82
View File
@@ -0,0 +1,82 @@
//! [`super`](rig 适配层)的用例(M174 r1 复审 P2-5 拆出来:结构化映射让 `rig.rs` 撞了 400 行)。
//!
//! 纯搬移。分类的端到端证据不在本文件:那要真 HTTP,用例在
//! `crates/cli/src/commands/agent/probe/tests.rs`(四类各一台假服务器)。
use super::*;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(
RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant"))
.is_ok()
);
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
+139
View File
@@ -0,0 +1,139 @@
//! 时间源:审计日志、事件流、工具耗时都要时间戳,但**测试不能依赖真实时钟**
//! (否则断言不稳定)。所以照既有做法注入 [`Clock`]。
//!
//! 时间戳格式化自己实现([`format_unix_ms`]),不引 `chrono`:agent 的依赖面按
//! `DevDocs/PlanDoc/02-ai-agent-design.md` §0 越窄越好,RFC3339 UTC 只需要一个
//! 民用历换算(Howard Hinnant 的 `civil_from_days`)。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
/// 时间源。生产用 [`SystemClock`],测试用 [`FixedClock`]。
pub trait Clock: Send + Sync {
/// 当前 Unix 毫秒时间戳。
fn now_unix_ms(&self) -> u64;
}
/// 真实系统时钟(单调性不保证,审计只要"大致有序")。
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now_unix_ms(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
}
}
/// 可控时钟:测试里固定起点、必要时手动推进。
#[derive(Debug)]
pub struct FixedClock {
ms: AtomicU64,
step_ms: u64,
}
impl FixedClock {
/// 从固定时间戳开始,每次读走一步 `step_ms`(0 = 不自动推进)。
#[must_use]
pub fn new(start_ms: u64) -> Self {
Self {
ms: AtomicU64::new(start_ms),
step_ms: 0,
}
}
/// 每次读取自动前进 `step_ms`,用来断言工具耗时字段。
#[must_use]
pub fn with_step(start_ms: u64, step_ms: u64) -> Self {
Self {
ms: AtomicU64::new(start_ms),
step_ms,
}
}
/// 手动推进。
pub fn advance(&self, ms: u64) {
self.ms.fetch_add(ms, Ordering::Relaxed);
}
}
impl Clock for FixedClock {
fn now_unix_ms(&self) -> u64 {
if self.step_ms == 0 {
return self.ms.load(Ordering::Relaxed);
}
self.ms.fetch_add(self.step_ms, Ordering::Relaxed) + self.step_ms
}
}
/// `Arc<dyn Clock>` 的简写。
pub type SharedClock = Arc<dyn Clock>;
/// Unix 毫秒 → `YYYY-MM-DDTHH:MM:SS.mmmZ`(UTC)。
#[must_use]
pub fn format_unix_ms(ms: u64) -> String {
let secs = (ms / 1000) as i64;
let millis = ms % 1000;
let days = secs.div_euclid(86_400);
let sod = secs.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
let (h, mi, s) = (sod / 3600, (sod % 3600) / 60, sod % 60);
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z")
}
/// Unix 毫秒 → `YYYY-MM-DD`(UTC),审计落盘按天分文件用。
#[must_use]
pub fn date_of_unix_ms(ms: u64) -> String {
let (y, m, d) = civil_from_days(((ms / 1000) as i64).div_euclid(86_400));
format!("{y:04}-{m:02}-{d:02}")
}
/// 1970-01-01 起的天数 → (年, 月, 日)。
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_epoch_and_known_instants() {
assert_eq!(format_unix_ms(0), "1970-01-01T00:00:00.000Z");
// 2026-10-29T20:12:33.123Z
assert_eq!(
format_unix_ms(1_793_304_753_123),
"2026-10-29T20:12:33.123Z"
);
assert_eq!(date_of_unix_ms(1_793_304_753_123), "2026-10-29");
}
#[test]
fn handles_leap_day() {
// 2024-02-29T00:00:00Z
assert_eq!(
format_unix_ms(1_709_164_800_000),
"2024-02-29T00:00:00.000Z"
);
}
#[test]
fn fixed_clock_can_step() {
let clock = FixedClock::with_step(1_000, 5);
assert_eq!(clock.now_unix_ms(), 1_005);
assert_eq!(clock.now_unix_ms(), 1_010);
clock.advance(10);
assert_eq!(clock.now_unix_ms(), 1_025);
}
}
+235
View File
@@ -0,0 +1,235 @@
//! 分级确认协议(02 文档 §3.1「分级确认」+ §4b「YOLO」)。
//!
//! 分工:
//! - **工具**只声明「这次调用需要确认」并给出 diff([`ConfirmRequest`]),不自己做决定;
//! - **编排层**([`crate::brain::exec`])拿 [`ConfirmPolicy`] 决定是问人、还是走 YOLO;
//! - **UI** 在 P1 通过 [`ConfirmGate`] 接进来(P0 只有事件 + fail-closed 的默认实现)。
//!
//! fail-closed 是刻意的:P0 没有确认弹窗,敏感键的写入**一律拒绝**而不是静默放行
//! (research/02 §7 R9「写操作的破坏半径」)。
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// 敏感等级。**与 M12 `setting::spec::Sensitivity` 逐字一致**
/// (`DevDocs/SubAgentsTalk/setting-接口约定.md` §8):
///
/// | 等级 | 语义 |
/// |------|------|
/// | `Low` | 静默生效 |
/// | `Medium` | 静默生效,审计里能查到(每次调用本来都审计,这里只是标记用途) |
/// | `High` | 必须用户确认(YOLO 可跳过确认,但**仍受白名单约束**) |
///
/// 「AI 完全不能写」的键(`ai.api_key` 等凭据)由 M12 的 `writable=false` 表达,
/// 它们**不进白名单**,agent 侧走「不在白名单」的拒绝分支,不需要第四级。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Sensitivity {
/// 静默生效。
#[default]
Low,
/// 静默生效但审计标记(下载源、语言、日志级别)。
Medium,
/// 必须用户确认(数据目录、Java 路径、代理、AI 端点/YOLO 开关)。
High,
}
/// 一个待确认的操作:给用户看的措辞 + 结构化细节(UI 画 diff 用)。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ConfirmRequest {
/// 请求 id(编排层生成,UI 回填决定时原样带回)。
pub id: String,
/// 工具名。
pub tool: String,
/// 一句话请求(「AI 请求把 `instance.java.path` 改为 `D:\jdk17`」)。
pub summary: String,
/// 结构化细节(`{"path":…, "old":…, "new":…}`)。
pub details: Value,
/// 敏感等级。
pub sensitivity: Sensitivity,
}
/// 用户决定。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfirmDecision {
/// 允许这一次。
Approved,
/// 拒绝(工具调用返回「用户拒绝」给模型)。
Denied,
/// 允许,且本会话后续不再询问(UI 的「全部允许」)。
///
/// 编排层当前**只当普通放行**处理(`brain/exec.rs` 不区分两者);「据此临时开 YOLO」
/// 是 P1 的行为,落地前不要按已实现来看。
ApprovedAll,
}
impl ConfirmDecision {
/// 是否放行(`Approved` 与 `ApprovedAll` 都放行)。
#[must_use]
pub fn is_approved(self) -> bool {
matches!(self, Self::Approved | Self::ApprovedAll)
}
}
/// 确认闸门:P1 由 app 的 Slint 弹窗实现;测试用 [`ScriptedGate`]。
pub trait ConfirmGate: Send + Sync {
/// 发起确认并等待决定(UI 未接时由实现方立即给出 fail-closed 结果)。
fn request(&self, request: ConfirmRequest) -> ConfirmFuture;
}
/// 确认未来的箱子类型。
pub type ConfirmFuture = Pin<Box<dyn Future<Output = ConfirmDecision> + Send + 'static>>;
/// 一律放行(YOLO 单测 / 未来"用户已信任该工具"场景)。
#[derive(Debug, Default, Clone, Copy)]
pub struct AutoApproveGate;
impl ConfirmGate for AutoApproveGate {
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
Box::pin(async { ConfirmDecision::Approved })
}
}
/// 一律拒绝(fail-closed 的极简形态)。
#[derive(Debug, Default, Clone, Copy)]
pub struct AutoDenyGate;
impl ConfirmGate for AutoDenyGate {
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
Box::pin(async { ConfirmDecision::Denied })
}
}
/// P0 默认闸门:**没有确认 UI** → 一律拒绝(fail-closed)。
///
/// 请求本身已经由编排层作为 [`crate::event::AgentEvent::ConfirmRequested`] 发出去了,
/// 所以 P0 的行为是「敏感操作既不静默执行,也不卡住对话」:事件流里留下了
/// 「AI 想改 X」的完整记录,P1 接上弹窗后同一个事件直接变成可点的确认框。
#[derive(Debug, Default, Clone, Copy)]
pub struct NoUiGate;
impl ConfirmGate for NoUiGate {
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
Box::pin(async { ConfirmDecision::Denied })
}
}
/// 脚本化闸门:按顺序返回预设决定,用完之后走 `default`(测试用)。
pub struct ScriptedGate {
decisions: Mutex<VecDeque<ConfirmDecision>>,
default: ConfirmDecision,
}
impl ScriptedGate {
/// 依次给出 `decisions`,耗尽后返回 `default`。
#[must_use]
pub fn new(
decisions: impl IntoIterator<Item = ConfirmDecision>,
default: ConfirmDecision,
) -> Self {
Self {
decisions: Mutex::new(decisions.into_iter().collect()),
default,
}
}
}
impl ConfirmGate for ScriptedGate {
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
let decision = self
.decisions
.lock()
.ok()
.and_then(|mut q| q.pop_front())
.unwrap_or(self.default);
Box::pin(async move { decision })
}
}
/// 确认策略(编排层用)。YOLO 的真实开关在设置里,这里只是当次运行的快照。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ConfirmPolicy {
/// 自动批准全部工具调用(设置 → AI → YOLO,默认关)。
pub yolo: bool,
}
/// 拒绝时的引导口径:**内置对话**有弹窗可点,**外部 MCP** 没有 UI——这条差别只在
/// 「拒绝文案」上,策略本身完全相同(同一条 `ConfirmPolicy`、同一份审计)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DenyGuidance {
/// 内置对话:弹窗拒绝(或 P0 的 fail-closed)。
InApp,
/// 外部 MCP 客户端:引导用户去设置页确认,或打开 YOLO。
External,
}
impl DenyGuidance {
/// 拒绝后回给模型/用户的一句话(区分「谁拒的」与「下一步怎么办」)。
#[must_use]
pub fn message(self) -> &'static str {
match self {
Self::InApp => "用户拒绝了这次操作,未做任何修改",
Self::External => {
"这次操作需要用户确认,但当前是外部 MCP 客户端(没有确认弹窗),已按默认策略拒绝。\
请让用户在启动器设置页手动修改,或在设置 → AI 里打开 YOLO 模式后重试(审计照记)。"
}
}
}
}
impl ConfirmPolicy {
/// 该等级是否需要**询问**用户:只有 `High` 会问;YOLO 下连 `High` 也不问
/// (02 文档 §4b:YOLO 跳过分级确认,但**仍受白名单约束**——能改的键没变少)。
#[must_use]
pub fn requires_confirmation(self, sensitivity: Sensitivity) -> bool {
!self.yolo && sensitivity == Sensitivity::High
}
/// 该等级是否可以直接执行(不需要询问)。
#[must_use]
pub fn auto_approves(self, sensitivity: Sensitivity) -> bool {
!self.requires_confirmation(sensitivity)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn low_and_medium_keys_never_ask() {
let policy = ConfirmPolicy { yolo: false };
assert!(!policy.requires_confirmation(Sensitivity::Low));
assert!(!policy.requires_confirmation(Sensitivity::Medium));
assert!(policy.auto_approves(Sensitivity::Low));
assert!(policy.auto_approves(Sensitivity::Medium));
}
#[test]
fn high_keys_ask_unless_yolo() {
assert!(ConfirmPolicy { yolo: false }.requires_confirmation(Sensitivity::High));
assert!(!ConfirmPolicy { yolo: false }.auto_approves(Sensitivity::High));
assert!(!ConfirmPolicy { yolo: true }.requires_confirmation(Sensitivity::High));
assert!(ConfirmPolicy { yolo: true }.auto_approves(Sensitivity::High));
}
#[test]
fn sensitivity_matches_m12_levels() {
// 序列化名必须与 M12 `setting::spec::Sensitivity` 一致
assert_eq!(serde_json::to_string(&Sensitivity::Low).unwrap(), "\"low\"");
assert_eq!(
serde_json::to_string(&Sensitivity::Medium).unwrap(),
"\"medium\""
);
assert_eq!(
serde_json::to_string(&Sensitivity::High).unwrap(),
"\"high\""
);
}
}
+197
View File
@@ -0,0 +1,197 @@
//! agent 事件流:**UI 与审计之外的观察者**都从这里接(02 文档 §1 的「event channel」)。
//!
//! P0 只做输出([`AgentEventSink`]),P1 的 Slint 聊天面板订阅同一路事件把
//! 「正在读取日志…」「请求修改设置:下载并发 64→16」画出来,确认弹窗直接复用
//! [`AgentEvent::ConfirmRequested`]。
//!
//! 实现方必须**非阻塞**:`emit` 在对话循环的同步路径上被调用。
use std::sync::mpsc::Sender;
use std::sync::Mutex;
use serde::Serialize;
use serde_json::Value;
use crate::confirm::ConfirmRequest;
/// 一条 agent 事件(全部可序列化:P1 走 IPC 给 app 时不写两套类型)。
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
/// 一轮对话开始。
TurnStarted {
/// agent 会话 id。
session_id: String,
/// 用户输入。
user_text: String,
},
/// 流式文本增量(P1 聊天面板逐字渲染)。
TextDelta {
/// 增量文本。
text: String,
},
/// 模型这一轮的非流式完整文本(流式之外的兜底,也是审计用的正文)。
AssistantText {
/// 文本。
text: String,
},
/// 开始执行一个工具调用。
ToolCallStarted {
/// 工具名。
tool: String,
/// 模型给的参数。
args: Value,
/// 第几轮工具循环(从 1 开始)。
step: usize,
},
/// 一个工具调用结束。
ToolCallFinished {
/// 工具名。
tool: String,
/// 是否成功。
ok: bool,
/// 一句话结果摘要(UI 直接显示)。
summary: String,
/// 这次是否触发了确认请求。
requires_confirmation: bool,
},
/// 需要用户确认(P0 只发事件,P1 变成可点的弹窗)。
ConfirmRequested {
/// 待确认请求。
request: Box<ConfirmRequest>,
},
/// 工具调用被拒绝(策略或用户)。
ToolDenied {
/// 工具名。
tool: String,
/// 拒绝原因。
reason: String,
},
/// 一轮对话结束。
TurnFinished {
/// 实际用掉的最大工具轮次。
steps: usize,
/// 最终给用户看的文本。
text: String,
},
/// 循环级错误(provider 挂了、AI 被禁用…)。
Error {
/// 人类可读原因。
message: String,
},
}
/// 事件出口。
pub trait AgentEventSink: Send + Sync {
/// 发一条事件。实现方不得阻塞。
fn emit(&self, event: AgentEvent);
}
/// 丢弃事件。
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopSink;
impl AgentEventSink for NoopSink {
fn emit(&self, _event: AgentEvent) {}
}
/// 内存事件流(测试断言 + P0 的「暂不接 UI」)。
#[derive(Debug, Default)]
pub struct MemoryEventSink {
events: Mutex<Vec<AgentEvent>>,
}
impl MemoryEventSink {
/// 空 sink。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 取快照。
#[must_use]
pub fn events(&self) -> Vec<AgentEvent> {
self.events.lock().map(|e| e.clone()).unwrap_or_default()
}
/// 已发条数。
#[must_use]
pub fn len(&self) -> usize {
self.events.lock().map(|e| e.len()).unwrap_or(0)
}
/// 是否还没发过事件。
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 拼接所有文本增量(测试断言「流式内容拼起来等于完整文本」)。
#[must_use]
pub fn streamed_text(&self) -> String {
self.events()
.into_iter()
.filter_map(|e| match e {
AgentEvent::TextDelta { text } => Some(text),
_ => None,
})
.collect()
}
}
impl AgentEventSink for MemoryEventSink {
fn emit(&self, event: AgentEvent) {
if let Ok(mut events) = self.events.lock() {
events.push(event);
}
}
}
/// 标准库 channel 出口(P1 的 app 用一个线程收、喂给 Slint 的事件循环)。
#[derive(Debug, Clone)]
pub struct MpscSink {
tx: Sender<AgentEvent>,
}
impl MpscSink {
/// 包一个 `std::sync::mpsc::Sender`。
#[must_use]
pub fn new(tx: Sender<AgentEvent>) -> Self {
Self { tx }
}
}
impl AgentEventSink for MpscSink {
fn emit(&self, event: AgentEvent) {
// 接收端已关闭说明 UI 没了,事件直接丢弃(不能拖累对话)。
let _ = self.tx.send(event);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn memory_sink_collects_and_concatenates_deltas() {
let sink = MemoryEventSink::new();
sink.emit(AgentEvent::TextDelta {
text: "正在".into(),
});
sink.emit(AgentEvent::TextDelta {
text: "读取日志".into(),
});
assert_eq!(sink.streamed_text(), "正在读取日志");
assert_eq!(sink.len(), 2);
}
#[test]
fn mpsc_sink_forwards() {
let (tx, rx) = std::sync::mpsc::channel();
let sink = MpscSink::new(tx);
sink.emit(AgentEvent::Error {
message: "boom".into(),
});
assert!(matches!(rx.recv(), Ok(AgentEvent::Error { .. })));
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Unlucky-Launcher AI Agent(`unlucky_agent`)。
//!
//! 形态定案(决策 D4):**内置 Agent 客户端 + 对外的 MCP server 双形态,共享同一个工具内核**。
//! 设计定稿:`DevDocs/PlanDoc/02-ai-agent-design.md`。
//!
//! 三条不能破的边界:
//! 1. 工具只定义一次([`registry`]),内置客户端与 MCP server 都把同一个 `ToolSpec` 投影出去;
//! 2. AI 是 Port 不是 Service——LLM 不可用时规则引擎仍能诊断;
//! 3. AI 永不接触账户凭据;写配置受 schema 白名单约束并留审计。
//!
//! 与 core 的关系:agent 依赖 core 的公共 API,core 不知道 agent 的存在。
//! 落地方式是把需要 core 的能力收成[`port`]里的五个 trait(配置读写 / 会话日志 / 崩溃诊断 /
//! 实例与 Java / 账户),由装配层用 core 的实现填充——这样 M12/M14/M15/M18/M31/M33 未合并时
//! agent 也能独立编译、独立测试。装配处有两条:`crates/app`(GUI)与 `ull agent serve`(headless)。
//!
//! 当前状态:工具内核 + 对话循环 + 审计(P0 定稿)+ **对外 MCP server**(M39)已落地。
//! 两个外部依赖都收口在单文件:[`brain::rig`] 对 rig、[`mcp`] 对 rmcp(3.8.x)——
//! 它们升 breaking 时只有那两个模块编译失败,上层的循环/编排/审计一行不用动。
//!
//! 模块分工:
//! - [`registry`] 工具注册表(唯一真相源 + 双投影载荷);
//! - [`tools`] 工具 v1 实现(配置读写 / 会话日志 / 崩溃诊断);
//! - [`brain`] Provider 抽象 + 对话循环 + 工具编排;
//! - [`mcp`] 对外 MCP server(rmcp 双投影 + stdio / Streamable HTTP 传输);
//! - [`confirm`] 分级确认协议与 YOLO 策略;
//! - [`audit`] 每次工具调用的审计留痕;
//! - [`event`] 面向 UI 的事件流;
//! - [`port`] 与 core 的接触面(依赖倒置);
//! - [`redact`] 密钥/主目录脱敏;
//! - [`clock`] 可注入时间源(审计/事件用);
//! - [`testing`] 五个端口的 mock 实现(供本 crate、app 与 MCP 契约测试复用)。
#![forbid(unsafe_code)]
pub mod audit;
pub mod brain;
pub mod clock;
pub mod confirm;
pub mod event;
pub mod mcp;
pub mod port;
pub mod redact;
pub mod registry;
pub mod testing;
pub mod tools;
+159
View File
@@ -0,0 +1,159 @@
//! 对外 MCP server(02 文档 §1「双形态共享一个工具内核」的第二形态)。
//!
//! 传输定论(§0):**Streamable HTTP on 127.0.0.1 是主力**(外部 agent 起 stdio 会变成
//! 双实例抢数据目录锁),**stdio 形态保留**给 llama.cpp server 的内置 MCP client。
//!
//! 与内置客户端的关系:两侧都只吃 [`crate::registry::ToolRegistry`] 的 `(name, description,
//! input_schema)`,并且**都必须走** [`crate::brain::exec::ToolExecutor`]——schema 校验、
//! 分级确认、YOLO、审计一个字都不少。所以「用户在聊天里让 AI 改配置」与「Claude Code
//! 通过 MCP 改配置」是同一条路径(02 §1 的铁律)。
//!
//! 安全边界(MCP 场景的差别只有一条):
//! - 敏感键(`Sensitivity::High`)默认**拒绝**而不是弹窗——外部客户端没有我们的 UI;
//! 拒绝文案引导用户去设置页确认,或在设置里打开 `ai.yolo`(YOLO 下照记 `yolo=true` 审计);
//! - 写工具默认不暴露(`agent.mcp.allow_write=false`):只读子集注册进注册表,
//! 调写工具会得到「没有这个工具 + 可用工具清单」(并留一条 `unknown_tool` 审计)。
//!
//! 依赖面纪律:整个 crate 只有本模块允许 `use rmcp::`(与 `brain::rig` 对 rig 的规则同形)。
pub mod projection;
pub mod sample;
pub mod serve;
pub mod server;
use std::fmt;
pub use sample::{config_samples, ConfigSamples};
pub use serve::{bind, serve_http, serve_http_on, serve_stdio};
pub use server::RegistryServer;
/// 一次 MCP server 运行的配置(由设置键 `agent.mcp.*` + 命令行覆盖合成)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpConfig {
/// 监听地址。**只允许回环地址**(默认 `127.0.0.1`):MCP 工具能改配置,不能对外开。
pub host: String,
/// Streamable HTTP 端口(默认 47823,设置键 `agent.mcp.port`)。
pub port: u16,
/// 访问令牌(`Authorization: Bearer <token>`);`None` = 不校验(仅限回环监听时允许)。
pub token: Option<String>,
/// 是否只暴露只读工具(默认 **true**;`agent.mcp.allow_write=true` 才放开写侧)。
pub read_only: bool,
}
impl Default for McpConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 47823,
token: None,
read_only: true,
}
}
}
impl McpConfig {
/// `host:port` 形态的监听地址(IPv6 字面量自动加方括号,`::1` 不会拼成 `::1:47823`)。
#[must_use]
pub fn socket_addr(&self) -> String {
if self.host.contains(':') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
/// MCP 端点 URL(客户端配置样例里用的那串)。
#[must_use]
pub fn url(&self) -> String {
format!("http://{}/mcp", self.socket_addr())
}
/// 拒绝非回环监听——这是安全铁律的**代码化**,不是文档里的建议。
///
/// # Errors
/// `host` 不是回环地址时返回 [`McpServeError::NotLoopback`]。
pub fn ensure_loopback(&self) -> Result<(), McpServeError> {
let host = self.host.trim();
if host == "127.0.0.1" || host == "localhost" || host == "::1" {
return Ok(());
}
Err(McpServeError::NotLoopback(host.to_string()))
}
}
/// MCP server 启动/运行期错误(文案面向用户,可直接进 CLI 输出)。
#[derive(Debug)]
pub enum McpServeError {
/// 监听地址不是回环地址。
NotLoopback(String),
/// 端口被占用(最常见:另一个 `ull agent serve` 已经在跑)。
PortInUse {
/// 监听地址(`127.0.0.1:47823`)。
addr: String,
},
/// 其他 IO 失败(权限、端口不足、传输中断)。
Io(String),
}
impl fmt::Display for McpServeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotLoopback(host) => write!(
f,
"拒绝在 `{host}` 上监听:MCP server 只允许回环地址(127.0.0.1 / localhost / ::1)"
),
Self::PortInUse { addr } => write!(
f,
"端口已被占用:{addr}。可能另一个 `ull agent serve` 正在运行;\
用 `--port <端口>` 或设置键 `agent.mcp.port` 换一个"
),
Self::Io(message) => write!(f, "MCP server 传输失败:{message}"),
}
}
}
impl std::error::Error for McpServeError {}
impl From<std::io::Error> for McpServeError {
fn from(error: std::io::Error) -> Self {
Self::Io(error.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_loopback_hosts_are_accepted() {
let mut cfg = McpConfig::default();
assert!(cfg.ensure_loopback().is_ok());
cfg.host = "localhost".to_string();
assert!(cfg.ensure_loopback().is_ok());
cfg.host = "::1".to_string();
assert_eq!(cfg.url(), "http://[::1]:47823/mcp");
assert!(cfg.ensure_loopback().is_ok());
cfg.host = "0.0.0.0".to_string();
let err = cfg.ensure_loopback().unwrap_err();
assert!(matches!(err, McpServeError::NotLoopback(_)));
assert!(err.to_string().contains("回环地址"));
}
#[test]
fn default_is_read_only_on_the_documented_port() {
let cfg = McpConfig::default();
assert!(cfg.read_only);
assert_eq!(cfg.port, 47823);
assert_eq!(cfg.url(), "http://127.0.0.1:47823/mcp");
}
#[test]
fn port_in_use_message_is_actionable() {
let err = McpServeError::PortInUse {
addr: "127.0.0.1:47823".to_string(),
};
let text = err.to_string();
assert!(text.contains("--port"), "{text}");
assert!(text.contains("agent.mcp.port"), "{text}");
}
}
+106
View File
@@ -0,0 +1,106 @@
//! 双投影的 **MCP 侧**:`ToolDefinition` → rmcp `Tool`(`ToolRoute::new_dyn` 的属性入参)。
//!
//! rig 侧在 [`crate::brain::rig`](`ToolDefinition → RigToolDefinition`)。两侧都只吃裸
//! `serde_json::Value` schema,所以「一致性」是可断言的:`tests/mcp_contract.rs` 遍历注册表,
//! 断言 MCP 侧的 `(name, description, input_schema)` 与注册表逐项相等——这就是
//! research/02 §6.2 建议进 CI 的那条契约。
//!
//! 额外一位信息:`annotations.read_only_hint`。MCP 客户端拿它决定「这个工具能不能
//! 静默调用」,我们用它把 [`Access`] 语义透出去(只读提示,不是权限——权限在编排层)。
use std::sync::Arc;
use rmcp::model::{JsonObject, Tool, ToolAnnotations};
use serde_json::Value;
use crate::registry::{Access, ToolDefinition, ToolRegistry};
/// 一个 `ToolDefinition` → rmcp `Tool`。
///
/// 注册表在注册阶段已保证 `input_schema` 根是 `object`,所以这里的兜底分支
/// (非对象 → 空 schema)只是为了不 panic:真走到它说明注册校验被绕过了。
#[must_use]
pub fn tool_attr(definition: &ToolDefinition, access: Access) -> Tool {
let schema: JsonObject = match &definition.input_schema {
Value::Object(map) => map.clone(),
_ => JsonObject::new(),
};
let mut attr = Tool::new(
definition.name.clone(),
definition.description.clone(),
Arc::new(schema),
);
let mut annotations = ToolAnnotations::default();
annotations.read_only_hint = Some(access == Access::ReadOnly);
annotations.destructive_hint = Some(access == Access::Mutating);
attr.annotations = Some(annotations);
attr
}
/// 注册表 → rmcp `Tool` 列表(顺序同注册顺序,便于两侧比对)。
#[must_use]
pub fn tool_attrs(registry: &ToolRegistry) -> Vec<Tool> {
registry
.specs()
.iter()
.map(|spec| tool_attr(&spec.definition(), spec.access))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::mock_ports;
use crate::tools::build_v1_registry;
use serde_json::json;
fn registry() -> ToolRegistry {
let (ports, _settings) = mock_ports();
build_v1_registry(&ports).expect("注册表应装配成功")
}
#[test]
fn projection_keeps_name_description_and_schema() {
let registry = registry();
let attrs = tool_attrs(&registry);
assert_eq!(attrs.len(), registry.len());
for (attr, spec) in attrs.iter().zip(registry.specs()) {
let definition = spec.definition();
assert_eq!(attr.name.as_ref(), definition.name);
assert_eq!(
attr.description.as_deref(),
Some(definition.description.as_str()),
"{} 的说明必须逐字相等",
definition.name
);
let projected = Value::Object((*attr.input_schema).clone());
assert_eq!(
projected, definition.input_schema,
"{} 的 JSON Schema 必须逐字相等",
definition.name
);
}
}
#[test]
fn read_only_hint_matches_access() {
let registry = registry();
for (attr, spec) in tool_attrs(&registry).iter().zip(registry.specs()) {
let hint = attr
.annotations
.as_ref()
.and_then(|a| a.read_only_hint)
.unwrap_or(false);
assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name);
}
}
#[test]
fn schema_root_stays_an_object_on_the_wire() {
for attr in tool_attrs(&registry()) {
let value = Value::Object((*attr.input_schema).clone());
assert_eq!(value["type"], json!("object"), "{}", attr.name);
}
}
}
+80
View File
@@ -0,0 +1,80 @@
//! 对外客户端配置样例(用户把它们粘进 Claude Code / Cursor 就能连上)。
//!
//! 为什么要生成而不是写在文档里:端口与令牌是**用户本机**的值,手抄必错。
//! 两个客户端的形状不同(02 §0):
//! - **Claude Code**:`{"type":"http","url":…}`(`type` 必填,缺了会被当成 stdio)
//! - **Cursor**:`{"url":…}`(`mcp.json` 里只认 url;多写 type 会被忽略但不报错)
//!
//! 令牌只在设置里非空时出现——回环 + 无令牌是允许的(研究里“至少一个 token”是建议不是铁律,
//! 而我们用 `McpConfig::ensure_loopback` 硬性禁掉对外监听)。
use serde_json::{json, Value};
/// 客户端配置样例(三件套:端点 URL + 两份可直接粘的 JSON)。
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigSamples {
/// `http://127.0.0.1:<port>/mcp`。
pub url: String,
/// Claude Code(`claude mcp add` / `~/.claude.json` 的 `mcpServers` 片段)。
pub claude_code: Value,
/// Cursor(`.cursor/mcp.json` 的 `mcpServers` 片段)。
pub cursor: Value,
}
/// 生成样例。`token` 非空时补 `headers.Authorization`。
#[must_use]
pub fn config_samples(url: &str, token: Option<&str>) -> ConfigSamples {
let headers = token
.filter(|t| !t.trim().is_empty())
.map(|t| json!({ "Authorization": format!("Bearer {t}") }));
let mut claude_entry = json!({ "type": "http", "url": url });
let mut cursor_entry = json!({ "url": url });
if let Some(headers) = &headers {
claude_entry["headers"] = headers.clone();
cursor_entry["headers"] = headers.clone();
}
ConfigSamples {
url: url.to_string(),
claude_code: json!({ "mcpServers": { "unlucky-launcher": claude_entry } }),
cursor: json!({ "mcpServers": { "unlucky-launcher": cursor_entry } }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_code_needs_type_http_and_cursor_does_not() {
let samples = config_samples("http://127.0.0.1:47823/mcp", None);
assert_eq!(
samples.claude_code["mcpServers"]["unlucky-launcher"]["type"],
json!("http")
);
assert_eq!(
samples.claude_code["mcpServers"]["unlucky-launcher"]["url"],
json!("http://127.0.0.1:47823/mcp")
);
assert!(samples.cursor["mcpServers"]["unlucky-launcher"]
.get("type")
.is_none());
assert_eq!(
samples.cursor["mcpServers"]["unlucky-launcher"]["url"],
json!("http://127.0.0.1:47823/mcp")
);
}
#[test]
fn token_becomes_bearer_header_and_empty_token_is_skipped() {
let with = config_samples("http://127.0.0.1:1/mcp", Some("s3cret"));
let header = &with.cursor["mcpServers"]["unlucky-launcher"]["headers"]["Authorization"];
assert_eq!(header, &json!("Bearer s3cret"));
let without = config_samples("http://127.0.0.1:1/mcp", Some(" "));
assert!(without.claude_code["mcpServers"]["unlucky-launcher"]
.get("headers")
.is_none());
}
}
+144
View File
@@ -0,0 +1,144 @@
//! 两种传输:stdio(llama.cpp 内置 MCP client 用)与 Streamable HTTP(主力)。
//!
//! 传输定论(02 §0):**外部 MCP 主力是 127.0.0.1 上的 Streamable HTTP**,
//! 因为 stdio 形态要被外部 agent 拉起 = 同一份数据目录出现第二个实例,
//! 迟早撞上单写者锁(M18 的会话 jsonl、M12 的 launcher.json 都是单写者)。
//! stdio 保留给**已经常驻的**本地模型服务(llama.cpp server 的 MCP client 由它自己拉子进程)。
//!
//! 令牌:`Authorization: Bearer <agent.mcp.token>`。设置里为空 = 不校验(只允许回环监听,
//! `McpConfig::ensure_loopback` 已经拦掉对外监听)。research/02 §4.4 的原话是
//! 「至少要有 headers 里的一枚随机 token」——我们做成可配、默认留空但样例里带。
use std::net::SocketAddr;
use axum::extract::Request;
use axum::http::header::AUTHORIZATION;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
use rmcp::ServiceExt;
use tokio::net::TcpListener;
use crate::mcp::server::RegistryServer;
use crate::mcp::{McpConfig, McpServeError};
/// stdio 形态:读 stdin 写 stdout,直到对端关闭。
///
/// 注意:**stdout 只能有 JSON-RPC**。任何日志都要走 stderr(否则客户端解析失败)。
///
/// # Errors
/// 传输中断或初始化失败时返回 [`McpServeError`]。
pub async fn serve_stdio(server: RegistryServer) -> Result<(), McpServeError> {
let service = server
.serve(rmcp::transport::stdio())
.await
.map_err(|error| McpServeError::Io(format!("stdio 初始化失败:{error}")))?;
service
.waiting()
.await
.map_err(|error| McpServeError::Io(format!("stdio 传输中断:{error}")))?;
Ok(())
}
/// 绑定 Streamable HTTP 监听(**只绑回环**),返回实际地址。
///
/// 拆出来是为了让调用方在进入 `axum::serve` 之前就能打印「监听在哪」——
/// 端口被占用时也能拿到一条可操作的错误(tower 的提醒:撞端口要给出换端口的指引)。
///
/// # Errors
/// 非回环地址、端口被占用、或绑定失败时返回 [`McpServeError`]。
pub async fn bind(cfg: &McpConfig) -> Result<(SocketAddr, TcpListener), McpServeError> {
cfg.ensure_loopback()?;
let addr = cfg.socket_addr();
let listener = TcpListener::bind(&addr).await.map_err(|error| {
if error.kind() == std::io::ErrorKind::AddrInUse {
McpServeError::PortInUse { addr: addr.clone() }
} else {
McpServeError::Io(format!("绑定 {addr} 失败:{error}"))
}
})?;
let local = listener
.local_addr()
.map_err(|error| McpServeError::Io(error.to_string()))?;
Ok((local, listener))
}
/// Streamable HTTP 形态:`POST /mcp` 一条 JSON-RPC 请求 / 一条响应。
///
/// `json_response = true`:简单请求直接回 `application/json`(脚本与裸 JSON-RPC
/// 客户端最省事;需要服务端推送时 rmcp 会自动回落到 SSE)。
///
/// # Errors
/// 绑定失败(见 [`bind`])或服务运行期致命错误时返回 [`McpServeError`]。
pub async fn serve_http(server: RegistryServer, cfg: McpConfig) -> Result<(), McpServeError> {
let (local, listener) = bind(&cfg).await?;
serve_http_on(server, cfg, local, listener).await
}
/// 在已绑定的监听上跑(测试用临时端口,避免抢 47823)。
///
/// # Errors
/// 服务运行期致命错误时返回 [`McpServeError`]。
pub async fn serve_http_on(
server: RegistryServer,
cfg: McpConfig,
local: SocketAddr,
listener: TcpListener,
) -> Result<(), McpServeError> {
let http_config = StreamableHttpServerConfig::default()
.with_json_response(true)
.with_legacy_session_mode(false);
let factory_server = server.clone();
let service: StreamableHttpService<RegistryServer, LocalSessionManager> =
StreamableHttpService::new(
move || Ok(factory_server.clone()),
std::sync::Arc::new(LocalSessionManager::default()),
http_config,
);
let token = cfg.token.clone();
let app = axum::Router::new()
.nest_service("/mcp", service)
.layer(axum::middleware::from_fn(
move |request: Request, next: Next| {
let token = token.clone();
async move { authorize(token, request, next).await }
},
));
eprintln!(
"[unlucky-agent] MCP server 监听 http://{local}/mcp({} 个工具;令牌:{})",
server.tool_count(),
if cfg.token.is_some() {
"已启用"
} else {
"未设置(仅回环监听)"
}
);
axum::serve(listener, app)
.await
.map_err(|error| McpServeError::Io(error.to_string()))
}
/// Bearer 令牌闸门:未配置 = 放行;配置了则严格比对。
async fn authorize(token: Option<String>, request: Request, next: Next) -> Response {
let Some(expected) = token else {
return next.run(request).await;
};
let presented = request
.headers()
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if presented == format!("Bearer {expected}") {
return next.run(request).await;
}
(
StatusCode::UNAUTHORIZED,
"缺少或错误的 Authorization: Bearer <agent.mcp.token>",
)
.into_response()
}
+243
View File
@@ -0,0 +1,243 @@
//! rmcp `ServerHandler`:把同一份注册表挂成 MCP 工具路由。
//!
//! 每次 `tools/call` 都走 [`ToolExecutor`]——schema 校验、确认闸门、YOLO、审计、
//! 事件**与内置客户端完全同一条路径**(02 §1 铁律)。工具级失败返回
//! `CallToolResult::error`(客户端能看到文案),协议级错误只留给「路由不到」的情况
//! (research/02 §3.2:`Err(McpError)` 会被大多数客户端吞成 "internal error")。
use std::sync::Arc;
use rmcp::handler::server::tool::{ToolCallContext, ToolRoute, ToolRouter};
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
};
use rmcp::service::RequestContext;
use rmcp::{ErrorData as McpError, RoleServer, ServerHandler};
use serde_json::Value;
use crate::brain::chat::ToolCall;
use crate::brain::exec::ToolExecutor;
use crate::mcp::projection::tool_attr;
use crate::registry::{Access, ToolRegistry};
/// MCP server 的 `ServerHandler` 实现。
///
/// `Clone` 是 Streamable HTTP 要的:每个会话/请求由工厂造一份(共享同一批 `Arc`,
/// 因此审计与策略仍是同一份)。
#[derive(Clone)]
pub struct RegistryServer {
router: ToolRouter<RegistryServer>,
visible: Arc<Vec<Tool>>,
session_id: String,
}
impl std::fmt::Debug for RegistryServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegistryServer")
.field("session_id", &self.session_id)
.field("tools", &self.visible.len())
.finish()
}
}
impl RegistryServer {
/// 用注册表 + 执行器装配。
///
/// **`router_registry` 必须是完整注册表**(含写工具),`executor` 才决定「哪些工具真能跑」:
/// - 只读模式:`-executor` 用 `read_only_subset()` 装配,`tools/list` 只列只读项,
/// 硬调写工具会路由到执行器 → `unknown_tool`(**tool-level 错误 + 审计**),
/// 而不是 rmcp 的协议级 `-32602 tool not found`(那个客户端只会显示 "internal error")。
/// - 写模式:两者都是完整注册表。
///
/// `session_id` 进审计的 `agent_session_id` 字段(`mcp-http` / `mcp-stdio`),
/// 让复盘时能区分「内置对话改的」和「外部 MCP 改的」。
#[must_use]
pub fn new(
router_registry: Arc<ToolRegistry>,
executor: Arc<ToolExecutor>,
session_id: impl Into<String>,
read_only: bool,
) -> Self {
let mut router = ToolRouter::new();
for spec in router_registry.specs() {
let attr = tool_attr(&spec.definition(), spec.access);
router.add_route(route(attr, Arc::clone(&executor)));
}
let visible: Vec<Tool> = router_registry
.specs()
.iter()
.filter(|spec| !read_only || spec.access == Access::ReadOnly)
.map(|spec| tool_attr(&spec.definition(), spec.access))
.collect();
Self {
router,
visible: Arc::new(visible),
session_id: session_id.into(),
}
}
/// 本 server **对外可见**的工具(`tools/list` 的内容)。
#[must_use]
pub fn tools(&self) -> Vec<Tool> {
(*self.visible).clone()
}
/// 审计用的会话标签。
#[must_use]
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 对外可见的工具数(启动日志/断言用)。
#[must_use]
pub fn tool_count(&self) -> usize {
self.visible.len()
}
}
fn route(attr: Tool, executor: Arc<ToolExecutor>) -> ToolRoute<RegistryServer> {
ToolRoute::new_dyn(attr, move |context: ToolCallContext<'_, RegistryServer>| {
let executor = Arc::clone(&executor);
let session_id = context.service.session_id().to_string();
let name = context.name().to_string();
let arguments = context
.arguments
.clone()
.map_or_else(|| Value::Object(serde_json::Map::new()), Value::Object);
Box::pin(async move {
let call = ToolCall {
id: format!("mcp-{name}"),
name,
arguments,
};
let outcome = executor.execute(&session_id, &call, 0).await;
let text = outcome.to_model_content();
let content = vec![ContentBlock::text(text)];
let result = if outcome.ok {
CallToolResult::structured(outcome.content)
} else {
CallToolResult::error(content)
};
Ok(result.into())
})
})
}
impl ServerHandler for RegistryServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new(
"unlucky-launcher",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"Unlucky-Launcher 的工具服务:读写启动器配置、看实例/版本/Java、读游戏日志、跑崩溃诊断。\
账户凭据永不经由本服务出口;写配置受 schema 白名单与敏感键确认约束。",
)
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult::with_all_items(self.tools()))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
let call_context = ToolCallContext::new(self, request, context);
self.router.call(call_context).await
}
fn get_tool(&self, name: &str) -> Option<Tool> {
self.router.get(name).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::MemoryAudit;
use crate::clock::{FixedClock, SharedClock};
use crate::confirm::{AutoDenyGate, ConfirmPolicy};
use crate::event::NoopSink;
use crate::testing::mock_ports;
use crate::tools::build_v1_registry;
use serde_json::json;
fn server(registry: Arc<ToolRegistry>, read_only: bool) -> (RegistryServer, Arc<MemoryAudit>) {
let audit = Arc::new(MemoryAudit::new());
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let exposed = if read_only {
Arc::new(registry.read_only_subset())
} else {
Arc::clone(&registry)
};
let executor = Arc::new(ToolExecutor::new(
exposed,
audit.clone(),
Arc::new(AutoDenyGate),
ConfirmPolicy::default(),
clock,
Arc::new(NoopSink),
));
(
RegistryServer::new(registry, executor, "mcp-test", read_only),
audit,
)
}
fn registry() -> Arc<ToolRegistry> {
let (ports, _settings) = mock_ports();
Arc::new(build_v1_registry(&ports).expect("注册表应装配成功"))
}
#[test]
fn lists_every_registered_tool_in_write_mode() {
let registry = registry();
let (server, _audit) = server(Arc::clone(&registry), false);
let tools = server.tools();
assert_eq!(tools.len(), registry.len());
assert_eq!(server.tool_count(), registry.len());
assert_eq!(server.session_id(), "mcp-test");
assert!(tools.iter().any(|tool| tool.name == "set_config"));
}
#[test]
fn read_only_mode_hides_the_writing_tool_but_keeps_it_routable() {
let (server, _audit) = server(registry(), true);
let names: Vec<String> = server.tools().iter().map(|t| t.name.to_string()).collect();
assert!(!names.iter().any(|n| n == "set_config"), "{names:?}");
assert!(names.iter().any(|n| n == "get_config"));
assert_eq!(names.len(), 9);
// 路由仍然存在(否则 rmcp 会回协议级 -32602,客户端看到的是 "internal error"),
// 真正拦下来的是只读子集执行器 → tool-level 错误 + 审计。
assert!(server.router.get("set_config").is_some());
}
#[test]
fn info_advertises_tools_and_a_stable_identity() {
let (server, _audit) = server(registry(), false);
let info = server.get_info();
assert!(info.capabilities.tools.is_some());
assert_eq!(info.server_info.name, "unlucky-launcher");
}
#[test]
fn get_tool_returns_the_projected_attr() {
let (server, _audit) = server(registry(), false);
let attr = server.get_tool("get_config").expect("工具存在");
assert_eq!(attr.name, "get_config");
assert_eq!(
Value::Object((*attr.input_schema).clone())["type"],
json!("object")
);
assert!(server.get_tool("nope").is_none());
}
}
+315
View File
@@ -0,0 +1,315 @@
//! 端口层(port):agent 与 `ull-core` 各模块之间**唯一**的接触面。
//!
//! 为什么不直接 `use ull_core::*`:M12(setting)/ M14(game)/ M15(diagnose)/ M18(launch)
//! 与本 crate 并行开发,直接依赖会把 agent 的编译期绑死在别人的 API 日期上。这里用
//! **依赖倒置**——agent 只声明自己需要的形状(本文件的 trait + 结构体),由 `crates/app`
//! 在装配处用 `ull-core` 的实现去满足(`#![forbid(unsafe_code)]` 的纯适配层)。
//!
//! | 端口 | 提供的工具 | 对方模块 / 数据源 |
//! |------|-----------|------------------|
//! | [`SettingPort`] | `get_config` / `set_config` | M12 · `config/*.json` schema |
//! | [`GameSessionPort`] | `list_game_sessions` / `read_game_log` | M18 · `agent/sessions/game/{index,<id>}.jsonl` |
//! | [`DiagnosePort`] | `diagnose_crash` | M15 · 规则引擎 |
//!
//! 形状依据(以对方文件为准,改字段先改对方文件):
//! - `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5(事件行 + 索引行)
//! - `DevDocs/SubAgentsTalk/agent-ToolSpec约定.md`(本 crate 对外的对齐说明)
//!
//! 错误一律用 `Result<T, String>`:错误文本会**原样进工具结果**给模型与用户看
//! (rmcp 的 `CallToolResult::error` 同理——协议级错误不给模型看,见 research/02 §3.2)。
//! 因此端口实现写中文、面向用户、可执行(「配置键 `x` 不存在」而不是 `ENOENT`)。
use std::collections::BTreeMap;
use std::sync::Arc;
use serde::Serialize;
use serde_json::Value;
use crate::confirm::Sensitivity;
pub mod game;
pub use game::{
AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus,
VersionSummary,
};
/// 端口调用结果的错误文本(面向用户可读,直接进工具结果)。
pub type PortResult<T> = Result<T, String>;
/// agent 需要的全部端口。装配处(`crates/app` / `ull agent serve`)用它一次性注入。
#[derive(Clone)]
pub struct Ports {
/// 配置读写(M12)。
pub setting: Arc<dyn SettingPort>,
/// 游戏会话与日志(M18)。
pub sessions: Arc<dyn GameSessionPort>,
/// 崩溃规则引擎(M15)。
pub diagnose: Arc<dyn DiagnosePort>,
/// 实例 / 版本 / Java(M14 + M33)。
pub game: Arc<dyn GamePort>,
/// 账户元数据(M31,**只有公开字段**,凭据永不出口)。
pub accounts: Arc<dyn AccountPort>,
}
// ───────────────────────────── 配置(M12) ─────────────────────────────
/// AI 可写白名单里的一个键,带**敏感分级**(02 文档 §3.1 的「分级确认」依据)。
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SettingKey {
/// 点分路径,如 `download.concurrency`、`java.selected_path`(键名以 M12 的 schema 为准)。
pub path: String,
/// 敏感等级(M12 `Low/Medium/High`):只有 `High` 需要用户确认。
pub sensitivity: Sensitivity,
/// 值类型(`string` / `integer` / `boolean` / `enum` / `json`),用于错误提示。
pub value_type: String,
/// 一句话说明,同时进 `get_config` 概要与模型上下文。
pub description: String,
}
/// 一次成功的写入,带旧值即「diff」(research/02 §6.4 的 dry-run 落点)。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct SettingChange {
/// 被修改的键路径。
pub path: String,
/// 修改前的值;键此前不存在时为 `None`。
pub old: Option<Value>,
/// 写入后的值。
pub new: Value,
}
/// 配置端口(M12 实现,`ull-core::setting` 的 schema API 直译)。
///
/// agent 侧只做**白名单 + 敏感分级 + 确认编排**,值本身的范围/类型校验归 setting 模块所有
/// (单一真相源:配置 schema 在 M12,agent 不复制一份)。
pub trait SettingPort: Send + Sync {
/// 全量概要(`get_config()` 无参时返回):键 → 当前值的扁平表,敏感键已脱敏。
fn summary(&self) -> Value;
/// 读单个键。键不存在时返回人类可读错误。
///
/// **凭据契约**:`writable=false` 的凭据类键(`ai.api_key` 等)必须**拒绝或掩码**,
/// 不得返回明文——agent 侧还有一道 `redact::is_sensitive_key` 守卫,但端口是数据的
/// 第一道闸(模型上下文与对话历史都会带上这里的返回值)。
fn get(&self, path: &str) -> PortResult<Value>;
/// 写单个键(值校验由实现方负责),返回 diff。
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange>;
/// **AI 可写白名单**(含敏感分级)。不在表里的键 agent 一律拒绝。
fn writable_keys(&self) -> Vec<SettingKey>;
/// 配置 schema 的给 AI 版本(`get_config_schema` 工具):只含 AI 可写键与
/// `x-ai-sensitivity` 标注,凭据类键天然不在其中(M12 `SettingSchema::to_json_schema`)。
fn schema(&self) -> Value;
}
// ──────────────────────── 游戏会话与日志(M18) ────────────────────────
/// `index.jsonl` 一行 → 一个会话摘要(同 id 多行由实现方取最后一行)。
///
/// 字段对齐 `launch-接口约定.md` §5;对方新增字段时这里补 `Option`,不破坏旧实现。
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct GameSessionSummary {
/// 会话 id,格式 `YYYYMMDDTHHMMSS-<6 位 hex>`(UTC)。
pub session_id: String,
/// 实例 id。
pub instance_id: Option<String>,
/// 实例显示名。
pub instance_name: Option<String>,
/// MC 版本。
pub game_version: Option<String>,
/// 启动时间(RFC3339 UTC)。
pub started_at: Option<String>,
/// 结束时间;会话未结束时为 `None`。
pub ended_at: Option<String>,
/// 时长毫秒。
pub duration_ms: u64,
/// 退出码;未正常结束时可能缺失。
pub exit_code: Option<i32>,
/// `exit_type`:`normal|application_error|jvm_error|sigkill|sigterm|killed|interrupted`。
pub exit_type: Option<String>,
/// exit 0 但疑似静默失败(D8 升级 1)。
pub silent_failure: bool,
/// 是否检测到游戏窗口。
pub window_detected: bool,
/// 事件条数。
pub event_count: u64,
/// 是否发现 crash-report / hs_err。
pub crash_found: bool,
/// 规则引擎首选结论 id(`diagnosis` 事件的 top candidate)。
pub diagnosis_top: Option<String>,
}
/// `read_game_log` 的查询条件:**按事件 seq 切片**,不是按行号
/// (stdout/stderr 是一行一事件,但同一条日志行可能被切成多块,见 launch §1)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LogQuery {
/// 起始 `seq`(含)。
pub from_seq: Option<u64>,
/// 结束 `seq`(含)。
pub to_seq: Option<u64>,
/// 只要这些 `kind`(空 = 全部)。未知 kind 由实现方忽略而不是报错(前向兼容)。
pub kinds: Vec<String>,
/// 最多返回多少条事件(工具层已钳到 [`crate::tools::session::MAX_LOG_EVENTS`])。
pub max_events: usize,
}
/// 一条事件行(`{v,seq,ts,t_ms,kind,session_id,payload}`),落盘时已脱敏。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct LogEvent {
/// 会话内单调递增序号。
pub seq: u64,
/// RFC3339 UTC。
pub ts: String,
/// 距会话开始的毫秒数。
pub t_ms: u64,
/// 事件类型(`stdout|stderr|exit|crash_found|diagnosis|…`)。
pub kind: String,
/// 事件负载,原样透传(已脱敏)。
pub payload: Value,
}
/// 一页日志事件 + 全量统计(模型拿到摘要就不会盲目翻页,防 token 爆炸)。
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct LogPage {
/// 会话 id。
pub session_id: String,
/// 文件内事件总数。
pub total_events: u64,
/// 命中过滤条件的事件数。
pub matched_events: u64,
/// 是否因 `max_events` 截断。
pub truncated: bool,
/// 全量 kind 计数(不受过滤影响)。
pub kind_counts: BTreeMap<String, u64>,
/// 首事件时间。
pub first_ts: Option<String>,
/// 末事件时间。
pub last_ts: Option<String>,
/// 本次返回的事件(已按 seq 升序)。
pub events: Vec<LogEvent>,
}
/// 会话端口(M18 实现,`ull-core::launch::session_index` + `session` 的读侧)。
///
/// 读取**必须容忍最后一行不完整**(会话被 kill 时 jsonl 尾部可能截断)——这是实现方义务,
/// 由 `launch-接口约定.md` §1 规定,agent 只消费结果。
pub trait GameSessionPort: Send + Sync {
/// 按 `started_at` 倒序、按 `session_id` 去重后的会话列表;`instance_id` 非空时过滤。
fn list_sessions(
&self,
limit: usize,
instance_id: Option<&str>,
) -> PortResult<Vec<GameSessionSummary>>;
/// 按 [`LogQuery`] 切片读取一个会话的事件流。
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage>;
}
// ───────────────────────── 崩溃诊断(M15) ─────────────────────────
/// 六维修复动作(M15 `diagnose::rules::FixActionKind`,internally-tagged enum,
/// YAML 里写 `kind:`)。序列化名与 M15 逐字相同:`change_java|adjust_memory|
/// change_graphics|remove_mod|reinstall|seek_help`。
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FixActionKind {
/// 换 Java(大版本上下界/指定实现)。
ChangeJava,
/// 调 Xmx/Xms、64 位 Java、系统虚拟内存。
AdjustMemory,
/// 驱动/渲染器/分辨率/覆盖层。
ChangeGraphics,
/// 删/禁/换 mod。
RemoveMod,
/// 重装/修组件(java|game_jar|loader|natives|libraries|assets|mods…)。
Reinstall,
/// 导出诊断包 / 需要更多信息。
SeekHelp,
}
/// 一条修复建议(M15 的 `MaterializedFixAction { kind, tool, args }` + 一句人话)。
///
/// `tool` 是 M15 给的**语义动作 id**(`java.set` / `memory.set` / …)。P0 没有这些细分工具,
/// 所以 agent 侧按 [`FixActionKind`] 桥接到现有工具(见 [`crate::tools::diagnose`]),
/// `tool` 只原样透传给模型与审计,不用于分发。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct FixAction {
/// 六维动作类别。
pub kind: FixActionKind,
/// 语义动作 id(如 `memory.set`);P1 落对应工具后可直接当工具名。
pub tool: String,
/// 一句话建议(可为空,agent 会按 `kind` 补兜底文案)。
pub label: String,
/// 已做模板替换与单位换算的参数(M15 `materialize()` 的产物)。
pub args: std::collections::BTreeMap<String, String>,
}
/// 一条诊断候选(M15 `DiagnosisRecord::candidates[]`)。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct DiagnosisCandidate {
/// 规则 id(如 `jvm.32bit`)。
pub id: String,
/// `critical|error|warn|info`。
pub severity: String,
/// 分组(`jvm|memory|graphics|integrity|mod|…`)。
pub category: String,
/// 置信度 0.0~1.0。
pub confidence: f32,
/// i18n key(如 `diagnose.cause.jvm.32bit`)。
pub message_key: String,
/// **渲染后**的人类可读结论(M15 保证非空;agent 侧只在极旧数据上回落 `message_key`)。
pub message: String,
/// 更细的处理建议(M15 `detail`,可能为空)。
pub detail: String,
/// 证据行(已脱敏)。
pub evidence: Vec<String>,
/// 该候选的修复动作。
pub fix_actions: Vec<FixAction>,
}
/// 退出摘要(M15 `DiagnosisRecord::exit`,与 launch 的 `exit` 事件同源)。
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct ExitSummary {
/// 退出码。
pub code: Option<i32>,
/// `normal|application_error|jvm_error|sigkill|sigterm|killed|interrupted`。
pub exit_type: Option<String>,
/// exit 0 但疑似静默失败。
pub silent_failure: bool,
}
/// 规则引擎结论:**这就是给 LLM 的全部输入**(不是裸日志)。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct CrashDiagnosis {
/// 会话 id。
pub session_id: String,
/// `rules|ai`(谁产出的)。
pub source: String,
/// 规则拿不准、需要更多信息时置 true(此时才值得让 LLM 深挖)。
pub needs_more_info: bool,
/// 主因候选,`candidates[0]` 即首因(M15 已按 priority→confidence 排序)。
pub candidates: Vec<DiagnosisCandidate>,
/// 被抑制的次要候选。
pub secondary: Vec<DiagnosisCandidate>,
/// 会话级关键片段(`crash_found.excerpt` / `hs_err` 摘要,已脱敏)。
pub evidence: Vec<String>,
/// 退出摘要。
pub exit: ExitSummary,
}
impl CrashDiagnosis {
/// 首选候选(主因)。
#[must_use]
pub fn top(&self) -> Option<&DiagnosisCandidate> {
self.candidates.first()
}
/// 最高置信度(没有候选时为 0)。
#[must_use]
pub fn top_confidence(&self) -> f32 {
self.top().map_or(0.0, |c| c.confidence)
}
}
/// 诊断端口(M15 实现)。agent 只做「调规则引擎 → 把结论 + 片段交给 LLM」。
pub trait DiagnosePort: Send + Sync {
/// 对一个会话跑规则引擎。会话不存在/没有日志时返回人类可读错误。
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis>;
}
+152
View File
@@ -0,0 +1,152 @@
//! 实例 / 版本 / Java / 账户四个读侧端口(M39 的只读工具集用)。
//!
//! 与 [`super`] 的既有三个端口同形:trait 由 agent 声明,实现在装配层用 `unlucky-core` 填充。
//! 拆分出来的原因是 `port.rs` 挨着 400 行硬上限(`guards.yml` 的 file-size 门禁)——
//! 「会话与日志」「崩溃诊断」留在父模块,「游戏与账户」在这里,语义上也是一刀干净的分界。
//!
//! **凭据红线**(D9 / 02 文档 §3.1):账户端口只露 [`AccountSummary`] 的公开字段。
//! `Credentials`(access token / 刷新令牌 / authlib 密码)在 core 的 Secret 体系里,
//! 根本不出现在本文件的类型里,因此不可能被工具结果带出去。
use serde::Serialize;
use super::PortResult;
// ───────────────────────── 实例 / 版本(M14) ─────────────────────────
/// 一个实例(`profiles.json` 一行)的摘要:模型只需要「能定位、能判断载荷在不在」。
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct InstanceSummary {
/// 实例 id。
pub id: String,
/// 显示名。
pub name: String,
/// 版本 id(如 `1.21.1` / `fabric-loader-0.16.9-1.21.1`)。
pub version_id: String,
/// 该版本的载荷是否真的在 `versions/` 下(`false` = 装了档案但载荷缺失)。
pub version_present: bool,
/// 独立游戏目录(未覆盖时为 `None`)。
pub game_dir: Option<String>,
/// 实例级 Java 覆盖路径(未覆盖时为 `None`)。
pub java_path: Option<String>,
/// 上次启动时刻(Unix 毫秒;从未启动为 `None`)。
pub last_played_at: Option<u64>,
}
/// 已安装版本的摘要(`versions/<id>/` 的存在性 + 可读的元信息)。
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct VersionSummary {
/// 版本 id。
pub id: String,
/// 版本 JSON 里声明的 `type`(`release` / `snapshot` / 加载器自报),读不到为 `None`。
pub kind: Option<String>,
/// 被几个实例引用。
pub instance_count: usize,
}
// ──────────────────────────── Java(M33) ────────────────────────────
/// 一个探测到的 Java 运行时。
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct JavaRuntimeSummary {
/// `java` 可执行文件路径。
pub binary: String,
/// 完整版本串(`21.0.2` / `1.8.0_402`)。
pub version: Option<String>,
/// 主版本号(8 / 17 / 21)。
pub major: Option<u32>,
/// 发行方(temurin / oracle / microsoft / zulu …)。
pub vendor: Option<String>,
/// 来源类别(`instance` / `managed` / `env` / `system`)。
pub origin: String,
}
/// Java 现状(`get_java_status`)。
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct JavaStatus {
/// 默认堆内存(MB,`java.default_memory_mb`)。
pub default_memory_mb: Option<u64>,
/// 托管 JDK 的扫描根。
pub managed_roots: Vec<String>,
/// 探测到的候选(已去重,优先级从高到低)。
pub candidates: Vec<JavaRuntimeSummary>,
}
// ──────────────────────────── 账户(M31) ────────────────────────────
/// 账户的**公开字段**摘要。凭据类字段刻意不存在(凭据红线)。
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct AccountSummary {
/// 条目 id。
pub id: String,
/// 轨道:`offline` / `microsoft` / `authlib`。
pub kind: String,
/// Minecraft 档案 UUID(32 位无连字符)。
pub profile_id: String,
/// 玩家名(可能为空串 = 有档案但名字待刷新)。
pub profile_name: String,
/// 登录名(仅 authlib 轨有值)。
pub login_name: Option<String>,
/// 创建时刻(Unix 毫秒)。
pub created_at_ms: u64,
}
/// 游戏资产端口(M14 实例/版本 + M33 Java 探测)。
pub trait GamePort: Send + Sync {
/// 全部实例(按上次启动时间倒序)。
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>>;
/// 已安装版本清单。
fn list_versions(&self) -> PortResult<Vec<VersionSummary>>;
/// Java 现状(候选 + 托管根 + 默认内存)。
fn java_status(&self) -> PortResult<JavaStatus>;
}
/// 账户端口(M31)。实现方只允许读 `AccountStore::accounts()`(`AccountMeta`),
/// **不得**调用 `credentials()`——那是凭据出口,agent 侧没有对应字段可以承接。
pub trait AccountPort: Send + Sync {
/// 账户清单(公开字段)。
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn account_summary_has_no_credential_fields() {
// 把摘要序列化成 JSON:任何 token / password / secret 形态的键都不许出现。
// 这是凭据红线的**可执行断言**——将来有人往结构体里加字段,这条测试先炸。
let summary = AccountSummary {
id: "a1".to_string(),
kind: "offline".to_string(),
profile_id: "0123456789abcdef0123456789abcdef".to_string(),
profile_name: "Steve".to_string(),
login_name: Some("steve@example.com".to_string()),
created_at_ms: 1_793_304_753_123,
};
let json = serde_json::to_string(&summary).expect("可序列化");
for forbidden in ["token", "password", "secret", "credential", "refresh"] {
assert!(
!json.to_lowercase().contains(forbidden),
"账户摘要不得包含 `{forbidden}`:{json}"
);
}
}
#[test]
fn java_status_round_trips() {
let status = JavaStatus {
default_memory_mb: Some(2048),
managed_roots: vec!["/data/java-managed".to_string()],
candidates: vec![JavaRuntimeSummary {
binary: "/jdk/bin/java".to_string(),
version: Some("21.0.2".to_string()),
major: Some(21),
vendor: Some("temurin".to_string()),
origin: "managed".to_string(),
}],
};
assert_eq!(status.candidates.len(), 1);
assert_eq!(status.candidates[0].major, Some(21));
}
}
+217
View File
@@ -0,0 +1,217 @@
//! 脱敏:审计日志的参数、以及日志工具返回文本的**第二道**防线。
//!
//! 第一道在 launch 落盘时(`launch-接口约定.md` §4:`<redacted:token>` / `<home>` /
//! `<redacted:opaque>`),所以这里不重复实现那套;本模块只兜两件事:
//! 1. 审计日志里的参数可能含 API key(`set_config` 能改 AI 端点)→ 按键名掩码;
//! 2. 日志正文万一含用户主目录绝对路径(旧会话、第三方 mod 输出)→ 掩码用户名段。
//!
//! 幂等:对已脱敏文本再跑一遍结果不变(`<home>` / `***` 原样保留)。
use serde_json::{Map, Value};
/// 掩码占位。
pub const MASK: &str = "***";
/// 键名是否敏感(不区分大小写,含子串即算)。
///
/// 覆盖:`api_key` / `apikey` / `accessToken` / `session_token` / `password` /
/// `secret` / `authorization` / `credential`。
#[must_use]
pub fn is_sensitive_key(key: &str) -> bool {
const NEEDLES: [&str; 8] = [
"api_key",
"apikey",
"token",
"password",
"passwd",
"secret",
"authorization",
"credential",
];
let lower = key.to_ascii_lowercase();
NEEDLES.iter().any(|n| lower.contains(n))
}
/// 审计用:递归把敏感键的值换成 [`MASK`](保留结构,读审计的人仍能看出改的是哪个键)。
///
/// 除了「键名本身敏感」之外,还认 `set_config {path, value}` 这种**值随路径敏感**的形状:
/// 当同级的 `path` 是敏感键名(`ai.api_key`)时,`value`/`old`/`new` 一律掩码——
/// 否则审计日志会把 API key 原文写进磁盘。
#[must_use]
pub fn redact_args(value: &Value) -> Value {
match value {
Value::Object(map) => {
let sensitive_path = map
.get("path")
.and_then(Value::as_str)
.is_some_and(is_sensitive_key);
let mut out = Map::with_capacity(map.len());
for (k, v) in map {
let mask = is_sensitive_key(k)
|| (sensitive_path && matches!(k.as_str(), "value" | "old" | "new"));
let masked = if mask {
Value::String(MASK.to_string())
} else {
redact_args(v)
};
out.insert(k.clone(), masked);
}
Value::Object(out)
}
Value::Array(items) => Value::Array(items.iter().map(redact_args).collect()),
other => other.clone(),
}
}
/// 文本脱敏:用户主目录段 + `Bearer` 令牌。已脱敏文本原样返回(幂等)。
#[must_use]
pub fn redact_text(text: &str) -> String {
let masked = mask_user_dirs(text);
mask_bearer(&masked)
}
/// 任意 JSON 的每个字符串都过 [`redact_text`](工具结果出口的第二道防线)。
///
/// 与 [`redact_args`] 的分工:那个按键名掩码(审计参数),这个按**内容**掩码
/// (日志正文、账户展示名之类可能夹带主目录/Bearer 的字符串)。
#[must_use]
pub fn redact_texts(value: &Value) -> Value {
match value {
Value::String(text) => Value::String(redact_text(text)),
Value::Array(items) => Value::Array(items.iter().map(redact_texts).collect()),
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, child)| (key.clone(), redact_texts(child)))
.collect(),
),
other => other.clone(),
}
}
/// `C:\Users\<名>` / `/home/<名>` / `/Users/<名>` → 用户名段替换为 [`MASK`]。
fn mask_user_dirs(text: &str) -> String {
let lower = text.to_ascii_lowercase();
let mut out = String::with_capacity(text.len());
let mut cursor = 0usize;
while cursor < text.len() {
let Some((start, marker_len)) = find_marker(&lower, cursor) else {
out.push_str(&text[cursor..]);
break;
};
out.push_str(&text[cursor..start + marker_len]);
let name_start = start + marker_len;
let name_end = segment_end(text, name_start);
let segment = &text[name_start..name_end];
if segment.is_empty() || segment == MASK || segment.eq_ignore_ascii_case("<home>") {
out.push_str(segment);
} else {
out.push_str(MASK);
}
cursor = name_end;
}
out
}
/// 找最靠前的用户目录标记,返回 `(字节位置, 标记长度)`。标记须与 `lower` 同源。
fn find_marker(lower: &str, from: usize) -> Option<(usize, usize)> {
const MARKERS: [&str; 3] = ["c:\\users\\", "/home/", "/users/"];
MARKERS
.iter()
.filter_map(|m| lower[from..].find(m).map(|p| (from + p, m.len())))
.min_by_key(|(start, _)| *start)
}
/// 从 `from` 起取到下一个路径分隔符/空白/引号为止。
fn segment_end(text: &str, from: usize) -> usize {
const STOPS: [char; 9] = ['\\', '/', '"', '\'', ' ', '\t', '\n', '\r', ','];
text[from..]
.find(STOPS)
.map_or(text.len(), |offset| from + offset)
}
/// `Bearer <token>` → `Bearer ***`。
fn mask_bearer(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut cursor = 0usize;
while let Some(pos) = text[cursor..].find("Bearer ") {
let token_start = cursor + pos + "Bearer ".len();
let token_end = segment_end(text, token_start);
out.push_str(&text[cursor..token_start]);
if token_end > token_start {
out.push_str(MASK);
}
cursor = token_end;
}
out.push_str(&text[cursor..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn masks_sensitive_arg_keys_only() {
let args = json!({
"path": "launcher.theme",
"value": "light",
"nested": { "accessToken": "abc", "keep": 1 },
"list": [{ "password": "p" }]
});
let red = redact_args(&args);
// 非敏感路径:值原样保留(审计要能看出改成了什么)
assert_eq!(red["value"], json!("light"));
assert_eq!(red["path"], json!("launcher.theme"));
// 键名敏感:值掩码
assert_eq!(red["nested"]["accessToken"], json!(MASK));
assert_eq!(red["nested"]["keep"], json!(1));
assert_eq!(red["list"][0]["password"], json!(MASK));
}
#[test]
fn masks_value_when_path_itself_is_sensitive() {
// `set_config {path: "ai.api_key", value: "sk-…"}` 是最容易漏的泄漏点
let args = json!({ "path": "ai.api_key", "value": "sk-super-secret" });
let red = redact_args(&args);
assert_eq!(red["value"], json!(MASK));
let rendered = red.to_string();
assert!(!rendered.contains("sk-super-secret"), "{rendered}");
}
#[test]
fn masks_windows_and_posix_home() {
assert_eq!(
redact_text(r"C:\Users\aska\.minecraft\logs\latest.log"),
r"C:\Users\***\.minecraft\logs\latest.log"
);
assert_eq!(
redact_text("/home/aska/.minecraft/logs"),
"/home/***/.minecraft/logs"
);
}
#[test]
fn keeps_already_redacted_and_is_idempotent() {
let once = redact_text(r"C:\Users\aska\game\log.txt");
assert_eq!(once, r"C:\Users\***\game\log.txt");
assert_eq!(redact_text(&once), once);
assert_eq!(redact_text("<home>/.minecraft"), "<home>/.minecraft");
}
#[test]
fn masks_bearer_token() {
assert_eq!(
redact_text("Authorization: Bearer eyJhbGciOi.payload.sig"),
"Authorization: Bearer ***"
);
}
#[test]
fn detects_sensitive_key_names() {
assert!(is_sensitive_key("ai.api_key"));
assert!(is_sensitive_key("AccessToken"));
assert!(is_sensitive_key("proxy.password"));
assert!(!is_sensitive_key("download.concurrency"));
}
}
+260
View File
@@ -0,0 +1,260 @@
//! 工具注册表:整个 agent 的**唯一真相源**。
//!
//! 一个 `ToolSpec`(名称 + 描述 + serde JSON Schema)投影两份:
//! - 内置客户端的 rig `DynamicTool` 视图;
//! - 对外 MCP server 的 rmcp `ToolRouter` 视图。
//!
//! 两份投影必须一致,用契约测试固定(同 name / 同 description / 同 schema)。
//! 设计依据:`DevDocs/PlanDoc/02-ai-agent-design.md` §1。
//!
//! 工具 v1 清单(M16 起,M39 扩到十个):`get_config` / `set_config` / `get_config_schema` /
//! `list_instances` / `list_versions` / `get_java_status` / `list_accounts` /
//! `list_game_sessions` / `read_game_log` / `diagnose_crash`。
//!
//! 安全策略随工具走:敏感键分级确认、YOLO 模式标记、审计留痕(02 §3.1 / §4b)。
//!
//! ## 落地形态
//!
//! ```text
//! registry(Vec<ToolSpec>)
//! ├─ definitions() → rig `DynamicTool::new` (内置 agent,`brain::rig`)
//! └─ definitions() → rmcp `ToolRoute::new_dyn` (外部 MCP,`mcp::projection`)
//! ```
//!
//! 防漂移的关键一招(research/02 §6.2 建议进 CI):**契约测试**遍历注册表,断言两侧
//! 投影拿到的 `(name, description, schema)` 逐字节相等。因为两侧都只吃
//! [`ToolDefinition`],这条约束在本 crate 里天然成立——测试只需要锁住
//! 「definition 是纯投影」+「schema 根必须是 object」+「名字不重复」。
//!
//! 注册表**不持有任何端口**:工具闭包在构造时就捕获了 `Arc<dyn …Port>`,注册表只是索引。
pub mod schema;
pub mod spec;
pub mod validate;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::Arc;
pub use spec::{
Access, Confirmation, ToolCtx, ToolDefinition, ToolError, ToolFn, ToolOutput, ToolSpec,
};
/// 注册失败原因。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryError {
/// 工具名为空或重复。
DuplicateName(String),
/// schema 不合法(根必须是 `type: object`,否则 MCP 侧直接拒收)。
InvalidSchema {
/// 工具名。
tool: String,
/// 原因。
reason: String,
},
/// 说明为空(模型靠 description 选工具,空说明等于没有这个工具)。
EmptyDescription(String),
}
impl fmt::Display for RegistryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateName(name) => write!(f, "工具名重复或为空:`{name}`"),
Self::InvalidSchema { tool, reason } => {
write!(f, "工具 `{tool}` 的 schema 不合法:{reason}")
}
Self::EmptyDescription(name) => write!(f, "工具 `{name}` 缺少说明"),
}
}
}
impl std::error::Error for RegistryError {}
/// 工具注册表。
#[derive(Debug, Default, Clone)]
pub struct ToolRegistry {
specs: Vec<Arc<ToolSpec>>,
index: BTreeMap<String, usize>,
}
impl ToolRegistry {
/// 空注册表。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 注册一个工具。注册即校验名/schema/说明,**运行时不再做二次判断**。
///
/// # Errors
/// 名字重复或为空、schema 根不是 object、说明为空时返回 [`RegistryError`]。
pub fn register(&mut self, spec: ToolSpec) -> Result<(), RegistryError> {
let name = spec.name.trim();
if name.is_empty() || self.index.contains_key(name) {
return Err(RegistryError::DuplicateName(name.to_string()));
}
if spec.description.trim().is_empty() {
return Err(RegistryError::EmptyDescription(name.to_string()));
}
if spec.input_schema.get("type").and_then(|t| t.as_str()) != Some("object") {
return Err(RegistryError::InvalidSchema {
tool: name.to_string(),
reason: "根类型必须是 object(rig 与 rmcp 的入参都是对象 schema)".to_string(),
});
}
self.index.insert(name.to_string(), self.specs.len());
self.specs.push(Arc::new(spec));
Ok(())
}
/// 按名查找。
#[must_use]
pub fn get(&self, name: &str) -> Option<&Arc<ToolSpec>> {
self.index.get(name).and_then(|i| self.specs.get(*i))
}
/// 全部工具(按注册顺序,模型看到的顺序稳定)。
#[must_use]
pub fn specs(&self) -> &[Arc<ToolSpec>] {
&self.specs
}
/// 工具名列表(错误提示/审计用)。
#[must_use]
pub fn names(&self) -> Vec<&str> {
self.specs.iter().map(|s| s.name).collect()
}
/// 工具数量。
#[must_use]
pub fn len(&self) -> usize {
self.specs.len()
}
/// 是否没有工具。
#[must_use]
pub fn is_empty(&self) -> bool {
self.specs.is_empty()
}
/// **投影 1/2 的公共载荷**:`(name, description, input_schema)`。
#[must_use]
pub fn definitions(&self) -> Vec<ToolDefinition> {
self.specs.iter().map(|s| s.definition()).collect()
}
/// 只读子集(P1 的 `--readonly` 外部 MCP 模式用)。
#[must_use]
pub fn read_only_definitions(&self) -> Vec<ToolDefinition> {
self.specs
.iter()
.filter(|s| s.access == Access::ReadOnly)
.map(|s| s.definition())
.collect()
}
/// **只读模式**的注册表副本(外部 MCP 默认形态)。
///
/// 返回的是「物理上不含写工具」的新注册表:`tools/list` 里看不到 `set_config`,
/// 硬调它也会在编排层得到 `unknown_tool`(并留一条审计),而不是静默执行。
/// 这比「列表过滤 + 调用拦截」两套逻辑更不容易漏——只有一份子集,两侧天然一致。
#[must_use]
pub fn read_only_subset(&self) -> Self {
let mut subset = Self::new();
for spec in self.specs.iter().filter(|s| s.access == Access::ReadOnly) {
let name = spec.name.to_string();
if subset.index.insert(name, subset.specs.len()).is_none() {
subset.specs.push(Arc::clone(spec));
}
}
subset
}
/// 名 → access(装配处检查白名单/只读模式)。
#[must_use]
pub fn access_of(&self, name: &str) -> Option<Access> {
self.get(name).map(|s| s.access)
}
/// 去重后的名字集合(契约测试用)。
#[must_use]
pub fn unique_names(&self) -> BTreeSet<&str> {
self.specs.iter().map(|s| s.name).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::schema::{non_empty_string, object};
use serde_json::{json, Value};
fn echo(name: &'static str) -> ToolSpec {
ToolSpec::read_only(
name,
"回显",
object(&[("text", non_empty_string("文本"))], &["text"]),
Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo"))),
)
}
#[test]
fn register_and_lookup() {
let mut reg = ToolRegistry::new();
reg.register(echo("echo")).unwrap();
assert_eq!(reg.len(), 1);
assert_eq!(reg.names(), vec!["echo"]);
assert!(reg.get("echo").is_some());
assert!(reg.get("nope").is_none());
assert_eq!(reg.access_of("echo"), Some(Access::ReadOnly));
}
#[test]
fn rejects_duplicate_empty_and_non_object_schema() {
let mut reg = ToolRegistry::new();
reg.register(echo("echo")).unwrap();
assert_eq!(
reg.register(echo("echo")),
Err(RegistryError::DuplicateName("echo".into()))
);
let bad = ToolSpec::read_only(
"bad",
"x",
json!({ "type": "array" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))),
);
assert!(matches!(
reg.register(bad),
Err(RegistryError::InvalidSchema { .. })
));
let no_desc = ToolSpec::read_only(
"nodesc",
" ",
json!({ "type": "object" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))),
);
assert!(matches!(
reg.register(no_desc),
Err(RegistryError::EmptyDescription(_))
));
}
#[test]
fn definitions_are_stable_and_filterable() {
let mut reg = ToolRegistry::new();
reg.register(echo("a")).unwrap();
reg.register(ToolSpec::mutating(
"b",
"写",
json!({ "type": "object" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "b"))),
))
.unwrap();
assert_eq!(reg.definitions().len(), 2);
assert_eq!(reg.read_only_definitions().len(), 1);
assert_eq!(reg.definitions()[0].name, "a");
assert_eq!(reg.unique_names().len(), 2);
}
}
+116
View File
@@ -0,0 +1,116 @@
//! JSON Schema 构造helper。
//!
//! 工具参数 schema 一律**手写 JSON**(research/02 §6.2:`rig` 的 `DynamicTool::new` 与
//! rmcp 的 `ToolRoute::new_dyn` 吃的都是裸 `serde_json::Value`),所以不需要 schemars 派生。
//! 手写的好处是同一份 schema 直接喂两方,坏处是啰嗦——这个模块把啰嗦收敛到几个函数。
//!
//! 生成的 schema 一律按 **JSON Schema 2020-12** 的写法(rmcp 支持该版本;
//! `additionalProperties: false` 让模型少给野参数,[`super::validate`] 会执行它)。
use serde_json::{json, Map, Value};
/// 对象 schema。`properties` 按给定顺序写入(JSON 对象保序,UI 展示稳定)。
#[must_use]
pub fn object(properties: &[(&str, Value)], required: &[&str]) -> Value {
let mut props = Map::new();
for (name, schema) in properties {
props.insert((*name).to_string(), schema.clone());
}
json!({
"type": "object",
"properties": Value::Object(props),
"required": required,
"additionalProperties": false,
})
}
/// 任意 JSON 值(不校验类型)。
#[must_use]
pub fn any() -> Value {
json!({})
}
/// 字符串。
#[must_use]
pub fn string(description: &str) -> Value {
json!({ "type": "string", "description": description })
}
/// 非空字符串(`minLength: 1`,省掉一堆「路径不能为空」的运行时判断)。
#[must_use]
pub fn non_empty_string(description: &str) -> Value {
json!({ "type": "string", "description": description, "minLength": 1 })
}
/// 枚举字符串。
#[must_use]
pub fn string_enum(values: &[&str], description: &str) -> Value {
json!({ "type": "string", "description": description, "enum": values })
}
/// 布尔。
#[must_use]
pub fn boolean(description: &str) -> Value {
json!({ "type": "boolean", "description": description })
}
/// 带范围约束的整数。
#[must_use]
pub fn integer(description: &str, minimum: i64, maximum: i64) -> Value {
json!({
"type": "integer",
"description": description,
"minimum": minimum,
"maximum": maximum,
})
}
/// 字符串数组(可选条数上限)。
#[must_use]
pub fn string_array(description: &str, max_items: u64) -> Value {
json!({
"type": "array",
"description": description,
"items": { "type": "string" },
"maxItems": max_items,
})
}
/// 枚举字符串数组(如 `kinds: ["stderr","exit"]`)。
#[must_use]
pub fn enum_string_array(values: &[&str], description: &str, max_items: u64) -> Value {
json!({
"type": "array",
"description": description,
"items": { "type": "string", "enum": values },
"maxItems": max_items,
"uniqueItems": true,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::validate::validate;
#[test]
fn object_records_required_and_forbids_extras() {
let schema = object(
&[("path", non_empty_string("键路径")), ("value", any())],
&["path", "value"],
);
assert_eq!(schema["type"], json!("object"));
assert_eq!(schema["additionalProperties"], json!(false));
assert_eq!(schema["required"], json!(["path", "value"]));
assert!(validate(&schema, &json!({ "path": "a", "value": 1 })).is_ok());
}
#[test]
fn integer_builder_carries_bounds() {
let schema = integer("条数", 1, 500);
assert_eq!(schema["minimum"], json!(1));
assert_eq!(schema["maximum"], json!(500));
assert!(validate(&schema, &json!(0)).is_err());
assert!(validate(&schema, &json!(500)).is_ok());
}
}
+262
View File
@@ -0,0 +1,262 @@
//! 工具的**唯一定义类型**([`ToolSpec`])与它的两份投影载荷([`ToolDefinition`])。
//!
//! 双投影的支点在 research/02 §6.2:`rig::tool::DynamicTool::new(name, description,
//! parameters: serde_json::Value, ..)` 与 `rmcp::ToolRoute::new_dyn(name, description,
//! schema, handler)` 的入参**都是裸 JSON Schema**。所以本 crate 只维护一份
//! [`ToolSpec`],由 [`ToolSpec::definition`] 生成两侧共用的 `(name, description, schema)`。
//!
//! 工具实现是**同步**的:端口调用全是本地文件/内存,没有网络 IO;异步只存在于
//! provider 边界([`crate::brain::provider::ChatProvider`])。P1 若出现网络类工具
//! (mod 搜索/版本安装)再升成 `Fn -> BoxFuture`,不影响调用方语义。
use std::fmt;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::confirm::Sensitivity;
/// 工具的副作用等级(外部 MCP 只读模式、P1 的 `--readonly` 都按它筛)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Access {
/// 只读:可以给外部 agent、可以在无确认的情况下调用。
ReadOnly,
/// 会改用户数据(写配置),受白名单 + 分级确认约束。
Mutating,
}
/// 工具定义的三元组:**双投影就投这个**。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolDefinition {
/// 工具名(模型可见,`snake_case`)。
pub name: String,
/// 一句话说明(模型可见)。
pub description: String,
/// 参数 JSON Schema(2020-12,根类型必须 `object`)。
pub input_schema: Value,
}
/// 工具调用上下文:编排层注入,工具只读。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCtx {
/// agent 对话会话 id(审计用;与游戏 session_id 不是一回事)。
pub agent_session_id: String,
/// 是否已获确认。**只有编排层能置 true**(YOLO 或用户点了允许),
/// 工具据此决定「先报价再执行」还是「直接执行」。
pub confirmed: bool,
}
/// 工具的返回。
#[derive(Debug, Clone, PartialEq)]
pub struct ToolOutput {
/// 给模型/审计的结构化结果(编排层序列化成 JSON 字符串回灌)。
pub content: Value,
/// 一句话摘要(UI 展示 + 审计 summary)。
pub summary: String,
/// 非空表示「这次没执行,先要确认」([`crate::confirm::ConfirmRequest`] 的原料)。
pub confirmation: Option<Confirmation>,
}
/// 待确认的操作详情(dry-run 的产物)。
#[derive(Debug, Clone, PartialEq)]
pub struct Confirmation {
/// 一句话请求(「AI 请求把 X 从 A 改为 B」)。
pub summary: String,
/// 结构化 diff(`{"path":…,"old":…,"new":…}`)。
pub details: Value,
/// 敏感等级(决定 YOLO 能不能跳过)。
pub sensitivity: Sensitivity,
}
impl ToolOutput {
/// 成功返回。
#[must_use]
pub fn ok(content: Value, summary: impl Into<String>) -> Self {
Self {
content,
summary: summary.into(),
confirmation: None,
}
}
/// 需要确认(本次**没有**产生副作用)。
#[must_use]
pub fn needs_confirmation(
summary: impl Into<String>,
details: Value,
sensitivity: Sensitivity,
) -> Self {
let summary = summary.into();
Self {
content: serde_json::json!({
"status": "needs_confirmation",
"summary": summary,
}),
confirmation: Some(Confirmation {
summary: summary.clone(),
details,
sensitivity,
}),
summary,
}
}
}
/// 工具错误。**全部面向用户可读**——它们会作为工具结果回到模型,
/// 由模型决定重试还是换方案(rmcp 的 `Ok(CallToolResult::error(..))` 同义,
/// 见 research/02 §3.2:协议级 `Err` 会被客户端吞掉,不能用来报业务错误)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolError {
/// 参数语义不合法(schema 校验之外的规则,如路径不在白名单)。
NotAllowed(String),
/// 找不到对象(会话/键/文件)。
NotFound(String),
/// 端口(M12/M15/M18)报错,文本原样透传。
Port(String),
/// 工具自身内部错误(不该发生)。
Internal(String),
}
impl ToolError {
/// 一句话文案(写日志/审计用)。
#[must_use]
pub fn message(&self) -> &str {
match self {
Self::NotAllowed(m) | Self::NotFound(m) | Self::Port(m) | Self::Internal(m) => m,
}
}
/// 错误码(审计与 UI 分类用)。
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::NotAllowed(_) => "not_allowed",
Self::NotFound(_) => "not_found",
Self::Port(_) => "port_error",
Self::Internal(_) => "internal",
}
}
}
impl fmt::Display for ToolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for ToolError {}
/// 工具处理函数。
pub type ToolFn = Arc<dyn Fn(&ToolCtx, &Value) -> Result<ToolOutput, ToolError> + Send + Sync>;
/// 一个工具的完整定义 + 实现。
#[derive(Clone)]
pub struct ToolSpec {
/// 工具名(`snake_case`,同时是 MCP 侧的工具名)。
pub name: &'static str,
/// 说明。
pub description: &'static str,
/// 参数 schema(根必须 object)。
pub input_schema: Value,
/// 副作用等级。
pub access: Access,
/// 实现。
pub handler: ToolFn,
}
impl fmt::Debug for ToolSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ToolSpec")
.field("name", &self.name)
.field("access", &self.access)
.finish_non_exhaustive()
}
}
impl ToolSpec {
/// 建一个只读工具。
#[must_use]
pub fn read_only(
name: &'static str,
description: &'static str,
input_schema: Value,
handler: ToolFn,
) -> Self {
Self {
name,
description,
input_schema,
access: Access::ReadOnly,
handler,
}
}
/// 建一个会改数据的工具。
#[must_use]
pub fn mutating(
name: &'static str,
description: &'static str,
input_schema: Value,
handler: ToolFn,
) -> Self {
Self {
name,
description,
input_schema,
access: Access::Mutating,
handler,
}
}
/// 投影成两侧共用的 `(name, description, schema)`。
#[must_use]
pub fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: self.name.to_string(),
description: self.description.to_string(),
input_schema: self.input_schema.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::schema::{non_empty_string, object};
use serde_json::json;
fn echo_handler() -> ToolFn {
Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo")))
}
#[test]
fn definition_is_a_pure_projection() {
let spec = ToolSpec::read_only(
"echo",
"回显",
object(&[("text", non_empty_string("文本"))], &["text"]),
echo_handler(),
);
let def = spec.definition();
assert_eq!(def.name, "echo");
assert_eq!(def.description, "回显");
assert_eq!(def.input_schema, spec.input_schema);
assert_eq!(spec.access, Access::ReadOnly);
}
#[test]
fn confirmation_carries_details() {
let out = ToolOutput::needs_confirmation(
"AI 请求修改 x",
json!({ "path": "x" }),
Sensitivity::High,
);
assert_eq!(out.content["status"], json!("needs_confirmation"));
let confirmation = out.confirmation.expect("必须有待确认详情");
assert_eq!(confirmation.sensitivity, Sensitivity::High);
assert_eq!(confirmation.summary, out.summary);
assert_eq!(confirmation.details, json!({ "path": "x" }));
}
}
+332
View File
@@ -0,0 +1,332 @@
//! 参数校验:JSON Schema 2020-12 的**最小子集**实现。
//!
//! 为什么不引 `jsonschema` crate:agent 的 schema 全是自己手写的([`super::schema`]),
//! 用到的关键字只有 `type/required/properties/additionalProperties/enum/minimum/maximum/
//! minLength/minItems/maxItems/uniqueItems/items`。三百行的校验器换掉一棵依赖树,
//! 对 Win7 单文件形态(D12)值得。
//!
//! 校验失败的文案是给**模型**看的(它会据此改正参数重试),所以要具体:
//! 「参数 `limit` 必须是 1~500 的整数,实际是 2000」,不是 `schema validation failed`。
use serde_json::Value;
/// 校验入口。返回全部错误(一次给全,省模型来回试错)。
///
/// # Errors
/// 参数不符合 schema 时返回人类可读的错误列表。
pub fn validate(schema: &Value, args: &Value) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
validate_at(schema, args, "", &mut errors);
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
/// 只取第一条错误(日志/审计摘要用)。
#[must_use]
pub fn first_error(schema: &Value, args: &Value) -> Option<String> {
validate(schema, args)
.err()
.and_then(|errors| errors.into_iter().next())
}
fn validate_at(schema: &Value, value: &Value, path: &str, errors: &mut Vec<String>) {
let Some(schema) = schema.as_object() else {
return; // `{}` = 任意值
};
if let Some(expected) = schema.get("type").and_then(Value::as_str) {
if !type_matches(expected, value) {
errors.push(format!(
"参数 {} 应为 {},实际是 {}",
label(path),
expected,
type_name(value)
));
return; // 类型都不对,后面的范围检查没有意义
}
}
if let Some(allowed) = schema.get("enum").and_then(Value::as_array) {
if !allowed.iter().any(|v| v == value) {
let list: Vec<String> = allowed.iter().map(render).collect();
errors.push(format!(
"参数 {} 只能取 [{}],实际是 {}",
label(path),
list.join(", "),
render(value)
));
}
}
check_bounds(schema, value, path, errors);
match value {
Value::Object(map) => {
let required = schema.get("required").and_then(Value::as_array);
for name in required.into_iter().flatten().filter_map(Value::as_str) {
if !map.contains_key(name) {
errors.push(format!("缺少必填参数 {}", label(&join(path, name))));
}
}
let properties = schema.get("properties").and_then(Value::as_object);
for (key, entry) in map {
match properties.and_then(|p| p.get(key)) {
Some(sub) => validate_at(sub, entry, &join(path, key), errors),
None => {
if schema.get("additionalProperties").and_then(Value::as_bool)
== Some(false)
{
let known: Vec<&str> = properties
.map(|p| p.keys().map(String::as_str).collect())
.unwrap_or_default();
errors.push(format!(
"参数 {} 不是可接受的参数名(可用:{})",
label(&join(path, key)),
if known.is_empty() {
"无".to_string()
} else {
known.join(", ")
}
));
}
}
}
}
}
Value::Array(items) => {
if let Some(sub) = schema.get("items") {
for (i, item) in items.iter().enumerate() {
validate_at(sub, item, &format!("{path}[{i}]"), errors);
}
}
}
_ => {}
}
}
fn check_bounds(
schema: &serde_json::Map<String, Value>,
value: &Value,
path: &str,
errors: &mut Vec<String>,
) {
let number = value.as_f64();
let minimum = schema.get("minimum").and_then(Value::as_f64);
let maximum = schema.get("maximum").and_then(Value::as_f64);
if let (Some(n), Some(min), Some(max)) = (number, minimum, maximum) {
if n < min || n > max {
errors.push(format!(
"参数 {} 必须在 {}~{} 之间,实际是 {}",
label(path),
render_number(min),
render_number(max),
render_number(n)
));
return;
}
} else if let (Some(n), Some(min)) = (number, minimum) {
if n < min {
errors.push(format!(
"参数 {} 不能小于 {},实际是 {}",
label(path),
render_number(min),
render_number(n)
));
}
} else if let (Some(n), Some(max)) = (number, maximum) {
if n > max {
errors.push(format!(
"参数 {} 不能大于 {},实际是 {}",
label(path),
render_number(max),
render_number(n)
));
}
}
if let Some(text) = value.as_str() {
if let Some(min_len) = schema.get("minLength").and_then(Value::as_u64) {
if (text.chars().count() as u64) < min_len {
errors.push(format!(
"参数 {} 不能为空(最少 {} 个字符)",
label(path),
min_len
));
}
}
}
if let Some(items) = value.as_array() {
if let Some(max_items) = schema.get("maxItems").and_then(Value::as_u64) {
if items.len() as u64 > max_items {
errors.push(format!(
"参数 {} 最多 {} 项,实际 {} 项",
label(path),
max_items,
items.len()
));
}
}
if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64) {
if (items.len() as u64) < min_items {
errors.push(format!(
"参数 {} 至少 {} 项,实际 {} 项",
label(path),
min_items,
items.len()
));
}
}
if schema.get("uniqueItems").and_then(Value::as_bool) == Some(true) {
if let Some(duplicate) = first_duplicate(items) {
errors.push(format!(
"参数 {} 不能有重复项({} 出现了多次)",
label(path),
render(duplicate)
));
}
}
}
}
/// 数组里第一个重复出现的元素(`uniqueItems` 用;`null` 也算一个值)。
fn first_duplicate(items: &[Value]) -> Option<&Value> {
items
.iter()
.enumerate()
.find(|(index, item)| items.iter().take(*index).any(|prev| prev == *item))
.map(|(_, item)| item)
}
fn type_matches(expected: &str, value: &Value) -> bool {
match expected {
"object" => value.is_object(),
"array" => value.is_array(),
"string" => value.is_string(),
"boolean" => value.is_boolean(),
"null" => value.is_null(),
"integer" => value.as_f64().is_some_and(|n| n.fract() == 0.0),
"number" => value.is_number(),
_ => true, // 未知关键字不拦(前向兼容)
}
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(n) if n.as_f64().is_some_and(|f| f.fract() == 0.0) => "integer",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn label(path: &str) -> String {
if path.is_empty() {
"(根)".to_string()
} else {
format!("`{path}`")
}
}
fn join(path: &str, key: &str) -> String {
if path.is_empty() {
key.to_string()
} else {
format!("{path}.{key}")
}
}
fn render(value: &Value) -> String {
match value {
Value::String(s) => format!("\"{s}\""),
other => other.to_string(),
}
}
fn render_number(n: f64) -> String {
if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
n.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::schema::{
enum_string_array, integer, non_empty_string, object, string_array, string_enum,
};
use serde_json::json;
fn errors(schema: &Value, args: &Value) -> Vec<String> {
validate(schema, args).unwrap_err()
}
#[test]
fn accepts_valid_and_reports_missing_required() {
let schema = object(
&[("session_id", non_empty_string("会话 id"))],
&["session_id"],
);
assert!(validate(&schema, &json!({ "session_id": "20261029T124501-9f3c2a" })).is_ok());
let errs = errors(&schema, &json!({}));
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("缺少必填参数 `session_id`"), "{errs:?}");
}
#[test]
fn rejects_empty_string_and_wrong_type() {
let schema = object(
&[("session_id", non_empty_string("会话 id"))],
&["session_id"],
);
assert!(errors(&schema, &json!({ "session_id": "" }))[0].contains("不能为空"));
assert!(errors(&schema, &json!({ "session_id": 1 }))[0].contains("应为 string"));
}
#[test]
fn rejects_out_of_range_and_unknown_keys() {
let schema = object(
&[
("limit", integer("条数", 1, 500)),
("kinds", string_array("类型", 8)),
],
&[],
);
let errs = errors(&schema, &json!({ "limit": 2000 }));
assert!(errs[0].contains("1~500"), "{errs:?}");
let errs = errors(&schema, &json!({ "limit": 1, "evil": true }));
assert!(errs[0].contains("不是可接受的参数名"), "{errs:?}");
}
#[test]
fn checks_enum_and_nested_objects() {
let schema = object(&[("level", string_enum(&["error", "warn"], "级别"))], &[]);
assert!(validate(&schema, &json!({ "level": "error" })).is_ok());
assert!(errors(&schema, &json!({ "level": "fatal" }))[0].contains("只能取"));
}
#[test]
fn rejects_duplicate_items_when_unique() {
let schema = object(
&[("kinds", enum_string_array(&["stderr", "exit"], "类型", 4))],
&[],
);
let errs = errors(&schema, &json!({ "kinds": ["stderr", "exit", "stderr"] }));
assert!(errs[0].contains("不能有重复项"), "{errs:?}");
assert!(validate(&schema, &json!({ "kinds": ["stderr", "exit"] })).is_ok());
}
#[test]
fn integer_type_rejects_fraction() {
assert!(validate(&json!({ "type": "integer" }), &json!(1.5)).is_err());
assert!(validate(&json!({ "type": "integer" }), &json!(2.0)).is_ok());
}
}
+190
View File
@@ -0,0 +1,190 @@
//! 测试替身:五个端口的 **mock 实现**(无盘、无网络、无时间依赖)。
//!
//! 放在库内(而不是只在 `#[cfg(test)]`)是刻意的:`crates/app` 装配时也要能给
//! 自己的单测造一套「无盘」环境,且 P1/M39 的 MCP 契约测试要复用同一批假数据。
//!
//! 按端口拆文件(`port.rs` 挨 400 行上限的同款理由):`settings` 配置、`sessions`
//! 会话日志在下面,`game` 实例/Java/账户与 `diagnose` 诊断各一个子模块。
//! 对外名字不变,`unlucky_agent::testing::MockSettings` 之类照旧可用(走 re-export)。
pub mod diagnose;
pub mod game;
pub mod settings;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Value};
use crate::port::{GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult};
pub use diagnose::MockDiagnose;
pub use game::{MockAccounts, MockGame};
pub use settings::MockSettings;
/// 内存会话端口。
#[derive(Debug, Default)]
pub struct MockSessions {
sessions: Vec<GameSessionSummary>,
events: BTreeMap<String, Vec<LogEvent>>,
}
impl MockSessions {
/// 空数据。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 加一个会话及其事件流。
pub fn add(&mut self, summary: GameSessionSummary, events: Vec<LogEvent>) {
self.events.insert(summary.session_id.clone(), events);
self.sessions.push(summary);
}
/// 便捷:造一个崩溃会话(`exit_code=1` + stderr + crash_found)。
#[must_use]
pub fn crashing_session(session_id: &str) -> (GameSessionSummary, Vec<LogEvent>) {
let summary = GameSessionSummary {
session_id: session_id.to_string(),
instance_id: Some("inst-1".to_string()),
instance_name: Some("1.20.1 Forge".to_string()),
game_version: Some("1.20.1".to_string()),
started_at: Some("2026-10-29T12:45:01.123Z".to_string()),
ended_at: Some("2026-10-29T12:46:31.123Z".to_string()),
duration_ms: 90_000,
exit_code: Some(1),
exit_type: Some("application_error".to_string()),
silent_failure: false,
window_detected: true,
event_count: 5,
crash_found: true,
diagnosis_top: Some("forge_missing_library".to_string()),
};
let events = vec![
event(
0,
"stdout",
json!({ "line": "Starting Minecraft 1.20.1", "level": "info" }),
),
event(
1,
"stderr",
json!({ "line": "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher", "level": "error" }),
),
event(
2,
"crash_found",
json!({ "source": "crash_report", "path": r"C:\Users\aska\.minecraft\crash-reports\crash-1.txt", "excerpt": "Description: Mod loading failure" }),
),
event(
3,
"exit",
json!({ "code": 1, "exit_type": "application_error", "silent_failure": false }),
),
event(
4,
"session_end",
json!({ "reason": "exit", "duration_ms": 90000 }),
),
];
(summary, events)
}
}
fn event(seq: u64, kind: &str, payload: Value) -> LogEvent {
LogEvent {
seq,
ts: format!("2026-10-29T12:45:0{seq}.000Z"),
t_ms: seq * 1_000,
kind: kind.to_string(),
payload,
}
}
impl GameSessionPort for MockSessions {
fn list_sessions(
&self,
limit: usize,
instance_id: Option<&str>,
) -> PortResult<Vec<GameSessionSummary>> {
let mut out: Vec<GameSessionSummary> = self
.sessions
.iter()
.filter(|s| instance_id.is_none_or(|id| s.instance_id.as_deref() == Some(id)))
.cloned()
.collect();
out.sort_by(|a, b| b.started_at.cmp(&a.started_at));
out.truncate(limit);
Ok(out)
}
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage> {
let events = self
.events
.get(session_id)
.ok_or_else(|| format!("会话 `{session_id}` 不存在"))?;
let mut kind_counts = BTreeMap::new();
for event in events {
*kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1;
}
let matched: Vec<&LogEvent> = events
.iter()
.filter(|e| query.from_seq.is_none_or(|from| e.seq >= from))
.filter(|e| query.to_seq.is_none_or(|to| e.seq <= to))
.filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind))
.collect();
let max = if query.max_events == 0 {
matched.len()
} else {
query.max_events
};
let truncated = matched.len() > max;
Ok(LogPage {
session_id: session_id.to_string(),
total_events: events.len() as u64,
matched_events: matched.len() as u64,
truncated,
kind_counts,
first_ts: events.first().map(|e| e.ts.clone()),
last_ts: events.last().map(|e| e.ts.clone()),
events: matched.into_iter().take(max).cloned().collect(),
})
}
}
/// 一次性造好全部端口的 [`crate::port::Ports`](含一个崩溃会话,够跑通对话与日志工具)。
#[must_use]
pub fn mock_ports() -> (crate::port::Ports, std::sync::Arc<MockSettings>) {
use std::sync::Arc;
let settings = Arc::new(MockSettings::new());
let (summary, events) = MockSessions::crashing_session("20261029T124501-9f3c2a");
let mut sessions = MockSessions::new();
sessions.add(summary, events);
let ports = crate::port::Ports {
setting: settings.clone(),
sessions: Arc::new(sessions),
diagnose: Arc::new(MockDiagnose::new()),
game: Arc::new(MockGame::new()),
accounts: Arc::new(MockAccounts::new()),
};
(ports, settings)
}
/// 测试里常用的「全部工具名」集合(契约测试与 MCP 投影比对用)。
#[must_use]
pub fn expected_v1_tool_names() -> BTreeSet<&'static str> {
[
"get_config",
"get_config_schema",
"set_config",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
]
.into_iter()
.collect()
}
+87
View File
@@ -0,0 +1,87 @@
//! 内存诊断端口(`DiagnosePort` 的测试替身)。
use crate::port::{
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
PortResult,
};
/// 内存诊断端口(固定返回一份规则结论)。
#[derive(Debug, Clone)]
pub struct MockDiagnose {
report: Option<CrashDiagnosis>,
}
impl Default for MockDiagnose {
fn default() -> Self {
Self {
report: Some(Self::forge_missing_library()),
}
}
}
impl MockDiagnose {
/// 构造使用默认结论的端口。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 让端口对所有会话返回「会话不存在」。
#[must_use]
pub fn unavailable() -> Self {
Self { report: None }
}
/// 默认结论:Forge 缺库(对应 analyse/02 的高发场景)。
#[must_use]
pub fn forge_missing_library() -> CrashDiagnosis {
CrashDiagnosis {
session_id: String::new(),
source: "rules".to_string(),
needs_more_info: false,
candidates: vec![DiagnosisCandidate {
id: "integrity.forge_missing_library".to_string(),
severity: "error".to_string(),
category: "integrity".to_string(),
confidence: 0.86,
message_key: "diagnose.cause.integrity.forge_missing_library".to_string(),
message: "Forge 启动缺少 bootstraplauncher 库,通常是版本组件未下载完整"
.to_string(),
detail: "在实例上重装 Forge 组件(或先用官方启动器补全一次再回来)".to_string(),
evidence: vec![
"java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher"
.to_string(),
],
fix_actions: vec![FixAction {
kind: FixActionKind::Reinstall,
tool: "component.reinstall".to_string(),
label: "重装该版本的 Forge 组件".to_string(),
args: [("component".to_string(), "forge".to_string())]
.into_iter()
.collect(),
}],
}],
secondary: Vec::new(),
evidence: vec!["Description: Mod loading failure".to_string()],
exit: ExitSummary {
code: Some(1),
exit_type: Some("application_error".to_string()),
silent_failure: false,
},
}
}
}
impl DiagnosePort for MockDiagnose {
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
self.report.clone().map_or_else(
|| Err(format!("会话 `{session_id}` 不存在")),
|report| {
Ok(CrashDiagnosis {
session_id: session_id.to_string(),
..report
})
},
)
}
}
+158
View File
@@ -0,0 +1,158 @@
//! 实例 / 版本 / Java / 账户四个只读端口的测试替身。
use crate::port::game::{
AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus,
VersionSummary,
};
use crate::port::PortResult;
/// 内存游戏端口:一个 1.20.1 Forge 实例 + 两个已安装版本 + 两个 Java 候选。
#[derive(Debug, Clone)]
pub struct MockGame {
instances: Vec<InstanceSummary>,
versions: Vec<VersionSummary>,
java: JavaStatus,
fail: Option<String>,
}
impl Default for MockGame {
fn default() -> Self {
Self::new()
}
}
impl MockGame {
/// 默认假数据(够跑通 `list_instances` / `list_versions` / `get_java_status`)。
#[must_use]
pub fn new() -> Self {
Self {
instances: vec![InstanceSummary {
id: "inst-1".to_string(),
name: "1.20.1 Forge".to_string(),
version_id: "1.20.1-forge-47.2.20".to_string(),
version_present: true,
game_dir: None,
java_path: Some(r"D:\jdk17\bin\java.exe".to_string()),
last_played_at: Some(1_793_304_753_123),
}],
versions: vec![
VersionSummary {
id: "1.20.1-forge-47.2.20".to_string(),
kind: Some("release".to_string()),
instance_count: 1,
},
VersionSummary {
id: "1.21.1".to_string(),
kind: Some("release".to_string()),
instance_count: 0,
},
],
java: JavaStatus {
default_memory_mb: Some(2048),
managed_roots: vec![r"D:\ULL\.ULL\data\java-managed".to_string()],
candidates: vec![
JavaRuntimeSummary {
binary: r"D:\ULL\.ULL\data\java-managed\jdk-17-temurin\bin\java.exe"
.to_string(),
version: Some("17.0.13".to_string()),
major: Some(17),
vendor: Some("temurin".to_string()),
origin: "managed".to_string(),
},
JavaRuntimeSummary {
binary: "/usr/bin/java".to_string(),
version: Some("21.0.2".to_string()),
major: Some(21),
vendor: None,
origin: "PATH".to_string(),
},
],
},
fail: None,
}
}
/// 让三个查询全部返回同一个错误(测端口错误透传)。
#[must_use]
pub fn failing(message: &str) -> Self {
Self {
fail: Some(message.to_string()),
..Self::new()
}
}
fn fail(&self) -> Option<String> {
self.fail.clone()
}
}
impl GamePort for MockGame {
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>> {
self.fail().map_or_else(|| Ok(self.instances.clone()), Err)
}
fn list_versions(&self) -> PortResult<Vec<VersionSummary>> {
self.fail().map_or_else(|| Ok(self.versions.clone()), Err)
}
fn java_status(&self) -> PortResult<JavaStatus> {
self.fail().map_or_else(|| Ok(self.java.clone()), Err)
}
}
/// 内存账户端口:一个离线账户。**只有公开字段**,凭据无处可放。
#[derive(Debug, Clone)]
pub struct MockAccounts {
accounts: Vec<AccountSummary>,
fail: Option<String>,
}
impl Default for MockAccounts {
fn default() -> Self {
Self::new()
}
}
impl MockAccounts {
/// 一个离线账户(Steve)。
#[must_use]
pub fn new() -> Self {
Self {
accounts: vec![AccountSummary {
id: "acc-1".to_string(),
kind: "offline".to_string(),
profile_id: "8667ba71b85a4004af54457a9734eed7".to_string(),
profile_name: "Steve".to_string(),
login_name: None,
created_at_ms: 1_793_304_753_123,
}],
fail: None,
}
}
/// 空账户列表(测「没有账户」的提示文案)。
#[must_use]
pub fn empty() -> Self {
Self {
accounts: Vec::new(),
fail: None,
}
}
/// 让查询失败(测端口错误透传)。
#[must_use]
pub fn failing(message: &str) -> Self {
Self {
fail: Some(message.to_string()),
..Self::new()
}
}
}
impl AccountPort for MockAccounts {
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>> {
self.fail
.clone()
.map_or_else(|| Ok(self.accounts.clone()), Err)
}
}
+201
View File
@@ -0,0 +1,201 @@
//! 内存配置端口(`SettingPort` 的测试替身)。
use std::collections::BTreeMap;
use std::sync::Mutex;
use serde_json::{json, Map, Value};
use crate::confirm::Sensitivity;
use crate::port::{PortResult, SettingChange, SettingKey, SettingPort};
/// 内存配置端口。
#[derive(Debug, Default)]
pub struct MockSettings {
keys: Vec<SettingKey>,
values: Mutex<BTreeMap<String, Value>>,
fail_set: Mutex<Option<String>>,
set_calls: Mutex<usize>,
}
/// 与 `PlanDoc/04` §3 / M12 `setting-接口约定.md` §8 的键表一致的默认白名单
/// (覆盖三档敏感度;`ai.api_key` 这类凭据是 `writable=false`,**故意不在这里**)。
fn default_keys() -> Vec<SettingKey> {
fn key(
path: &str,
sensitivity: Sensitivity,
value_type: &str,
description: &str,
) -> SettingKey {
SettingKey {
path: path.to_string(),
sensitivity,
value_type: value_type.to_string(),
description: description.to_string(),
}
}
vec![
key(
"appearance.theme_mode",
Sensitivity::Low,
"enum",
"界面主题",
),
key(
"download.concurrency",
Sensitivity::Low,
"integer",
"下载并发(1~256)",
),
key(
"java.default_memory_mb",
Sensitivity::Low,
"integer",
"最大内存(512~65536 MB)",
),
key("download.region", Sensitivity::Medium, "enum", "下载源区域"),
key("ai.model", Sensitivity::Medium, "text", "AI 模型名"),
key(
"java.selected_path",
Sensitivity::High,
"path",
"Java 可执行文件路径",
),
key("download.proxy_url", Sensitivity::High, "text", "HTTP 代理"),
key("ai.base_url", Sensitivity::High, "text", "AI 端点"),
key(
"core.data_mode",
Sensitivity::High,
"enum",
"数据目录模式(portable/standard/custom)",
),
]
}
impl MockSettings {
/// 用默认白名单 + 一批初值造一个。
#[must_use]
pub fn new() -> Self {
let mut values = BTreeMap::new();
values.insert("appearance.theme_mode".to_string(), json!("metro_dark"));
values.insert("download.concurrency".to_string(), json!(8));
values.insert("java.default_memory_mb".to_string(), json!(2048));
Self {
keys: default_keys(),
values: Mutex::new(values),
fail_set: Mutex::new(None),
set_calls: Mutex::new(0),
}
}
/// 让接下来的 `set` 全部失败(测错误透传)。
pub fn fail_set_with(&self, message: &str) {
if let Ok(mut slot) = self.fail_set.lock() {
*slot = Some(message.to_string());
}
}
/// 当前值(断言「未确认时没有写进去」)。
#[must_use]
pub fn value_of(&self, path: &str) -> Option<Value> {
self.values.lock().ok().and_then(|v| v.get(path).cloned())
}
/// 直接塞一个值,模拟 M12 真实实现里存在的**任意键**(含凭据键)。
///
/// 白名单以外的键也能塞——测「`get_config` 不把凭据读给模型」需要它
/// (`ai.api_key` 是 `writable=false`,故意不在白名单里)。
pub fn seed_value(&self, path: &str, value: Value) {
if let Ok(mut values) = self.values.lock() {
values.insert(path.to_string(), value);
}
}
/// 成功写入次数。
#[must_use]
pub fn set_calls(&self) -> usize {
self.set_calls.lock().map(|n| *n).unwrap_or(0)
}
}
impl SettingPort for MockSettings {
fn summary(&self) -> Value {
let values = self.values.lock().map(|v| v.clone()).unwrap_or_default();
let mut map = serde_json::Map::new();
for key in &self.keys {
let value = values.get(&key.path).cloned().unwrap_or(Value::Null);
map.insert(key.path.clone(), value);
}
Value::Object(map)
}
fn get(&self, path: &str) -> PortResult<Value> {
self.values
.lock()
.ok()
.and_then(|v| v.get(path).cloned())
.ok_or_else(|| format!("配置键 `{path}` 没有值"))
}
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange> {
if let Ok(slot) = self.fail_set.lock() {
if let Some(message) = slot.as_ref() {
return Err(message.clone());
}
}
let old = self.get(path).ok();
if let Ok(mut values) = self.values.lock() {
values.insert(path.to_string(), value.clone());
}
if let Ok(mut calls) = self.set_calls.lock() {
*calls += 1;
}
Ok(SettingChange {
path: path.to_string(),
old,
new: value.clone(),
})
}
fn writable_keys(&self) -> Vec<SettingKey> {
self.keys.clone()
}
fn schema(&self) -> Value {
// 与 `core::setting::catalog::SettingSchema::to_json_schema` 同形:
// 只含白名单键 + `x-ai-sensitivity`,凭据键天然缺席。
let mut properties = Map::new();
for key in &self.keys {
properties.insert(
key.path.clone(),
json!({
"type": key.value_type,
"description": key.description,
"x-ai-sensitivity": key.sensitivity,
}),
);
}
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "ull.launcher",
"type": "object",
"additionalProperties": false,
"properties": properties,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_excludes_credential_keys() {
let schema = MockSettings::new().schema();
let json = schema.to_string();
assert!(
!json.contains("ai.api_key"),
"schema 不得包含凭据键:{json}"
);
assert!(json.contains("download.concurrency"));
}
}
+110
View File
@@ -0,0 +1,110 @@
//! `list_accounts`:账户清单(**凭据永不出口**)。
//!
//! 这个工具存在的理由很具体:用户常问「我有哪几个号 / 这局用的是哪个号」。
//! 但 D4/D9 的凭据红线是硬的——工具结果会回灌模型、也可能被发往远端 LLM,
//! 所以:
//! 1. 端口只回 [`crate::port::AccountSummary`](`AccountMeta` 的公开字段);
//! 2. 本文件里**没有任何**能表达 token / 密码的类型(连字段名都不出现);
//! 3. 工具结果再过一遍 [`crate::redact`],兜住实现方把凭据塞进 `profile_name` 的意外。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::port::AccountPort;
use crate::registry::schema::object;
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 工具名。
pub const LIST_ACCOUNTS: &str = "list_accounts";
/// 注册 `list_accounts`。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn AccountPort>,
) -> Result<(), RegistryError> {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
let accounts = port.list_accounts().map_err(ToolError::Port)?;
let count = accounts.len();
let display: Vec<String> = accounts
.iter()
.map(|a| {
if a.profile_name.trim().is_empty() {
format!("{}({})", a.id, a.kind)
} else {
format!("{}({})", a.profile_name, a.kind)
}
})
.collect();
let mut content = json!({
"count": count,
"accounts": accounts,
"credential_policy": "账户凭据(access token / 刷新令牌 / authlib 密码)永不经由 AI 工具出口",
});
// 兜底脱敏:端口实现若把敏感串塞进了公开字段,这里也不会带出去。
content = crate::redact::redact_texts(&content);
Ok(ToolOutput::ok(
content,
if count == 0 {
"还没有任何账户".to_string()
} else {
format!("账户 {count} 个:{}", display.join("、"))
},
))
});
registry.register(ToolSpec::read_only(
LIST_ACCOUNTS,
"列出启动器里已登录的账户(展示名 / 轨道 offline|microsoft|authlib / 档案 UUID / 创建时间)。**永不返回任何凭据**:令牌与密码不在工具结果的类型里,需要登录态请让用户在启动器界面查看。",
object(&[], &[]),
handler,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockAccounts;
fn registry(port: Arc<dyn AccountPort>) -> ToolRegistry {
let mut registry = ToolRegistry::new();
register(&mut registry, port).expect("注册应成功");
registry
}
fn call(registry: &ToolRegistry) -> Value {
let spec = registry.get(LIST_ACCOUNTS).expect("工具存在").clone();
let ctx = ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
};
(spec.handler)(&ctx, &json!({})).expect("应成功").content
}
#[test]
fn lists_accounts_without_credentials() {
let content = call(&registry(Arc::new(MockAccounts::new())));
assert_eq!(content["count"], json!(1));
assert_eq!(content["accounts"][0]["profile_name"], json!("Steve"));
// 只扫账户数据本身:`credential_policy` 那句说明文字里本来就写着 "token"。
let fields = content["accounts"].to_string().to_lowercase();
for forbidden in ["token", "password", "refresh", "secret"] {
assert!(
!fields.contains(forbidden),
"账户字段含 `{forbidden}`:{fields}"
);
}
}
#[test]
fn empty_list_is_not_an_error() {
let content = call(&registry(Arc::new(MockAccounts::empty())));
assert_eq!(content["count"], json!(0));
assert_eq!(content["accounts"], json!([]));
}
}
+345
View File
@@ -0,0 +1,345 @@
//! `get_config` / `set_config`(02 文档 §3.1「有权限修改 ULL 各项配置」)。
//!
//! 安全边界都落在这里,但**分级确认的"要不要问"不在这里**:
//! 工具只负责「白名单过滤 → 生成 diff → 需要确认时只报价不执行」,
//! 问不问由编排层按 [`crate::confirm::ConfirmPolicy`] 决定(YOLO 也只改那一处)。
//!
//! 值校验(类型/范围/schema)归 M12 的配置 schema 所有:agent 不复制一份,
//! 否则两处规则迟早漂移(02 文档 §3.1「schema 强校验」)。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::confirm::Sensitivity;
use crate::port::{SettingKey, SettingPort};
use crate::registry::schema::{any, non_empty_string, object, string};
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 读配置。
pub const GET_CONFIG: &str = "get_config";
/// 写配置。
pub const SET_CONFIG: &str = "set_config";
/// 错误提示里最多列多少个可写键(防"把白名单灌给模型")。
pub const MAX_LISTED_KEYS: usize = 16;
/// 摘要里值的最大展示长度。
const BRIEF_CHARS: usize = 60;
/// 注册两个配置工具。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn SettingPort>,
) -> Result<(), RegistryError> {
registry.register(get_config_spec(Arc::clone(&port)))?;
registry.register(set_config_spec(port))
}
fn get_config_spec(port: Arc<dyn SettingPort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let path = args
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|p| !p.is_empty());
match path {
None => {
let summary = port.summary();
let writable = port.writable_keys();
Ok(ToolOutput::ok(
json!({
"scope": "all",
"values": summary,
"writable_keys": writable.iter().map(describe_key).collect::<Vec<_>>(),
}),
format!(
"读取全部配置概要({} 个键,其中 {} 个 AI 可写)",
summary.as_object().map_or(0, serde_json::Map::len),
writable.len()
),
))
}
Some(path) => {
// 凭据类键一律不读:值会进 ToolOutput.content → 回灌模型 → 发往远端 LLM,
// 违反本 crate 的硬边界(AI 永不接触账户凭据,见 `lib.rs`)。
// 这是 agent 侧的第二道防线:第一道是 M12 不把凭据放白名单/概要。
if crate::redact::is_sensitive_key(path) {
return Err(ToolError::NotAllowed(format!(
"`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看。"
)));
}
let value = port.get(path).map_err(ToolError::Port)?;
Ok(ToolOutput::ok(
json!({ "scope": "key", "path": path, "value": value }),
format!("读取配置 `{path}` = {}", brief(&value)),
))
}
}
});
ToolSpec::read_only(
GET_CONFIG,
"读取启动器配置。不带 path 返回全部配置概要(含 AI 可写键及其敏感等级);带 path 返回单个键的值。",
object(
&[(
"path",
string("配置键点分路径,如 `download.concurrency`;省略则返回全部概要"),
)],
&[],
),
handler,
)
}
fn set_config_spec(port: Arc<dyn SettingPort>) -> ToolSpec {
let handler: ToolFn =
Arc::new(move |ctx: &ToolCtx, args: &Value| apply_set_config(port.as_ref(), ctx, args));
ToolSpec::mutating(
SET_CONFIG,
"修改启动器配置(仅限白名单内的键)。普通键立即生效;敏感键(Java 路径/数据目录/代理/AI 端点)会先返回 needs_confirmation,等用户确认后才会真正写入。值必须符合该键的类型与范围。",
object(
&[
("path", non_empty_string("配置键点分路径,必须是 get_config 列出的可写键")),
("value", any()),
],
&["path", "value"],
),
handler,
)
}
/// `set_config` 的实现(独立成函数便于单测直接调用)。
///
/// # Errors
/// 键不在白名单、值缺失、端口写入失败时返回 [`ToolError`]。
pub fn apply_set_config(
port: &dyn SettingPort,
ctx: &ToolCtx,
args: &Value,
) -> Result<ToolOutput, ToolError> {
let path = args
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|p| !p.is_empty())
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `path`".to_string()))?
.to_string();
let value = args
.get("value")
.cloned()
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `value`".to_string()))?;
let keys = port.writable_keys();
let key = keys
.iter()
.find(|k| k.path == path)
.ok_or_else(|| ToolError::NotAllowed(not_writable_message(&keys, &path)))?;
// 旧值读不到不影响写(首次设置),只是 diff 少一半。
let old = port.get(&path).ok();
if !ctx.confirmed && key.sensitivity == Sensitivity::High {
let diff = json!({
"path": path,
"old": old,
"new": value,
"sensitivity": key.sensitivity,
"description": key.description,
});
return Ok(ToolOutput::needs_confirmation(
format!(
"AI 请求把 `{path}` 从 {} 改为 {}({})",
brief_opt(&old),
brief(&value),
sensitivity_label(key.sensitivity)
),
diff,
key.sensitivity,
));
}
let change = port.set(&path, &value).map_err(ToolError::Port)?;
Ok(ToolOutput::ok(
json!({
"status": "written",
"path": change.path,
"old": change.old,
"new": change.new,
"sensitivity": key.sensitivity,
}),
format!(
"已把 `{}` 从 {} 改为 {}",
change.path,
brief_opt(&change.old),
brief(&change.new)
),
))
}
/// 白名单拒绝的文案:说清"不能写"并给出可写键(模型会据此改用正确路径)。
fn not_writable_message(keys: &[SettingKey], path: &str) -> String {
let listed: Vec<&str> = keys
.iter()
.take(MAX_LISTED_KEYS)
.map(|k| k.path.as_str())
.collect();
let rest = keys.len().saturating_sub(listed.len());
let tail = if rest > 0 {
format!(",另有 {rest} 个键见 get_config 概要")
} else {
String::new()
};
format!(
"配置键 `{path}` 不在 AI 可写白名单内。可写键({} 个):{}{tail}",
keys.len(),
listed.join("、")
)
}
fn describe_key(key: &SettingKey) -> Value {
json!({
"path": key.path,
"sensitivity": key.sensitivity,
"value_type": key.value_type,
"description": key.description,
})
}
fn sensitivity_label(sensitivity: Sensitivity) -> &'static str {
match sensitivity {
Sensitivity::Low => "普通",
Sensitivity::Medium => "中等(静默但审计)",
Sensitivity::High => "敏感,需确认",
}
}
fn brief(value: &Value) -> String {
let rendered = match value {
Value::String(s) => s.clone(),
other => other.to_string(),
};
truncate(&rendered)
}
fn brief_opt(value: &Option<Value>) -> String {
value
.as_ref()
.map_or_else(|| "(未设置)".to_string(), brief)
}
fn truncate(text: &str) -> String {
if text.chars().count() <= BRIEF_CHARS {
return text.to_string();
}
let head: String = text.chars().take(BRIEF_CHARS).collect();
format!("{head}…")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockSettings;
fn ctx(confirmed: bool) -> ToolCtx {
ToolCtx {
agent_session_id: "s1".to_string(),
confirmed,
}
}
#[test]
fn rejects_keys_outside_whitelist() {
let port = MockSettings::new();
let err = apply_set_config(
&port,
&ctx(true),
&json!({ "path": "java.random.thing", "value": 1 }),
)
.unwrap_err();
assert!(matches!(err, ToolError::NotAllowed(_)));
assert!(err.message().contains("不在 AI 可写白名单内"));
assert!(err.message().contains("download.concurrency"));
assert_eq!(port.set_calls(), 0);
}
#[test]
fn normal_key_writes_without_confirmation() {
let port = MockSettings::new();
let out = apply_set_config(
&port,
&ctx(false),
&json!({ "path": "download.concurrency", "value": 16 }),
)
.unwrap();
assert!(out.confirmation.is_none());
assert_eq!(out.content["status"], json!("written"));
assert_eq!(port.value_of("download.concurrency"), Some(json!(16)));
}
#[test]
fn medium_key_writes_silently_too() {
// M12:Medium = 静默生效但审计标记,不弹窗
let port = MockSettings::new();
let out = apply_set_config(
&port,
&ctx(false),
&json!({ "path": "download.region", "value": "cn" }),
)
.unwrap();
assert!(out.confirmation.is_none());
assert_eq!(port.set_calls(), 1);
}
#[test]
fn high_key_quotes_first_then_writes_after_confirm() {
let port = MockSettings::new();
let args = json!({ "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" });
let quoted = apply_set_config(&port, &ctx(false), &args).unwrap();
let confirmation = quoted.confirmation.expect("High 键应要求确认");
assert_eq!(confirmation.sensitivity, Sensitivity::High);
assert_eq!(confirmation.details["new"], json!("D:/jdk17/bin/java.exe"));
assert_eq!(port.set_calls(), 0, "未确认前不得写入");
let written = apply_set_config(&port, &ctx(true), &args).unwrap();
assert!(written.confirmation.is_none());
assert_eq!(port.set_calls(), 1);
assert_eq!(
port.value_of("java.selected_path"),
Some(json!("D:/jdk17/bin/java.exe"))
);
}
#[test]
fn locked_credential_key_is_not_writable() {
// M12:ai.api_key 是 writable=false,不在白名单里 → 任何情况下都拒绝
let port = MockSettings::new();
let err = apply_set_config(
&port,
&ctx(true),
&json!({ "path": "ai.api_key", "value": "sk-x" }),
)
.unwrap_err();
assert!(matches!(err, ToolError::NotAllowed(_)));
assert_eq!(port.set_calls(), 0);
}
#[test]
fn port_error_is_passed_through() {
let port = MockSettings::new();
port.fail_set_with("磁盘只读");
let err = apply_set_config(
&port,
&ctx(true),
&json!({ "path": "appearance.theme_mode", "value": "metro_light" }),
)
.unwrap_err();
assert_eq!(err.code(), "port_error");
assert!(err.message().contains("磁盘只读"));
}
}
+335
View File
@@ -0,0 +1,335 @@
//! `diagnose_crash`(02 文档 §3.2 第三条)。
//!
//! 流水线是「**规则在前、模型补位**」(analyse/02 §8.3):本工具只跑 M15 的规则引擎,
//! 把**结构化结论 + 关键片段**交给模型,绝不让模型读裸日志。规则未命中
//! (`candidates` 空 / `needs_more_info`)时,模型再去用 `read_game_log` 自己找证据。
//!
//! P0 与 M15 的桥:M15 的修复动作指向 `java.set` / `memory.set` 这些 **P1 才有的**语义工具 id。
//! P0 只有 `set_config`,所以这里按 [`FixActionKind`] 生成 `p0_next_steps`
//! (`adjust_memory` → `set_config {"path":"java.default_memory_mb"}`,`change_java` →
//! `set_config {"path":"java.selected_path"}`,见 [`MEMORY_KEY`] / [`JAVA_KEY`]),
//! 剩下的动作明确标成「P0 无工具,解释给用户」。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::port::{CrashDiagnosis, DiagnosePort, DiagnosisCandidate, FixAction, FixActionKind};
use crate::redact::redact_text;
use crate::registry::schema::{integer, non_empty_string, object};
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 诊断工具名。
pub const DIAGNOSE_CRASH: &str = "diagnose_crash";
/// 单次返回的证据行上限(防 token 爆炸)。
pub const MAX_EVIDENCE_LINES: usize = 40;
/// 证据行默认条数。
pub const DEFAULT_EVIDENCE_LINES: usize = 20;
/// 单条证据最大字符数。
pub const MAX_EVIDENCE_CHARS: usize = 400;
/// 内存建议的配置键(M12 白名单里的键名,见 `setting-接口约定.md` §8)。
pub const MEMORY_KEY: &str = "java.default_memory_mb";
/// Java 路径的配置键。
pub const JAVA_KEY: &str = "java.selected_path";
/// 注册诊断工具。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn DiagnosePort>,
) -> Result<(), RegistryError> {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let session_id = args
.get("session_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))?;
let max_lines = args
.get("max_evidence_lines")
.and_then(Value::as_u64)
.map_or(DEFAULT_EVIDENCE_LINES, |n| n as usize)
.clamp(1, MAX_EVIDENCE_LINES);
let report = port.diagnose(session_id).map_err(ToolError::Port)?;
Ok(render(&report, max_lines))
});
registry.register(ToolSpec::read_only(
DIAGNOSE_CRASH,
"对某次游戏会话跑规则引擎(59 条崩溃规则)并返回结构化结论:主因、置信度、证据行、修复动作。规则未命中时会提示需要更多信息——此时再用 read_game_log 取 stderr/exit 片段。",
object(
&[
("session_id", non_empty_string("会话 id,来自 list_game_sessions")),
(
"max_evidence_lines",
integer("返回多少条证据行", 1, MAX_EVIDENCE_LINES as i64),
),
],
&["session_id"],
),
handler,
))
}
/// 把规则引擎结论压成工具结果。
fn render(report: &CrashDiagnosis, max_evidence_lines: usize) -> ToolOutput {
let candidates: Vec<Value> = report
.candidates
.iter()
.map(|c| render_candidate(c, max_evidence_lines))
.collect();
let secondary: Vec<Value> = report
.secondary
.iter()
.take(5)
.map(|c| {
json!({
"id": c.id,
"severity": c.severity,
"category": c.category,
"confidence": c.confidence,
"message": message_of(c),
})
})
.collect();
let next_steps: Vec<Value> = report
.candidates
.iter()
.flat_map(|c| c.fix_actions.iter())
.map(bridge_to_p0)
.collect();
let content = json!({
"session_id": report.session_id,
"source": report.source,
"needs_more_info": report.needs_more_info,
"exit": report.exit,
"top_confidence": report.top_confidence(),
"candidates": candidates,
"secondary": secondary,
"evidence": cap_lines(&report.evidence, max_evidence_lines),
"p0_next_steps": next_steps,
});
ToolOutput::ok(content, summarize(report))
}
fn render_candidate(candidate: &DiagnosisCandidate, max_evidence_lines: usize) -> Value {
json!({
"id": candidate.id,
"severity": candidate.severity,
"category": candidate.category,
"confidence": candidate.confidence,
"message": message_of(candidate),
"detail": candidate.detail,
"evidence": cap_lines(&candidate.evidence, max_evidence_lines),
"fix_actions": candidate.fix_actions,
})
}
/// `message` 为空时回落显示 i18n key(装配层没渲染时至少不丢信息)。
fn message_of(candidate: &DiagnosisCandidate) -> String {
let text = candidate.message.trim();
if text.is_empty() {
candidate.message_key.clone()
} else {
text.to_string()
}
}
fn summarize(report: &CrashDiagnosis) -> String {
match report.top() {
Some(top) => {
let label = top
.fix_actions
.first()
.map(action_label)
.unwrap_or_else(|| "没有可执行的修复动作".to_string());
format!(
"规则引擎命中 {} 条候选,主因 `{}`(置信度 {:.2});建议:{label}",
report.candidates.len(),
top.id,
top.confidence
)
}
None if report.needs_more_info => {
"规则引擎未命中,且需要更多信息(用 read_game_log 取 stderr/exit 片段后再判断)"
.to_string()
}
None => "规则引擎未命中(59 条规则都不匹配),需要结合日志片段人工/AI 判断".to_string(),
}
}
/// M15 的语义动作 → P0 现有工具。返回给模型看的"下一步"。
fn bridge_to_p0(action: &FixAction) -> Value {
let label = action_label(action);
match action.kind {
FixActionKind::AdjustMemory => {
let value = pick_memory_mb(action).map_or(Value::Null, |mb| json!(mb));
json!({
"action": action.tool,
"kind": action.kind,
"label": label,
"suggested_tool": "set_config",
"suggested_args": { "path": MEMORY_KEY, "value": value },
})
}
FixActionKind::ChangeJava => {
let value = pick_arg(action, &["path", "java_path", "binary", "value"])
.map_or(Value::Null, |p| json!(p));
json!({
"action": action.tool,
"kind": action.kind,
"label": label,
"suggested_tool": "set_config",
"suggested_args": { "path": JAVA_KEY, "value": value },
})
}
other => json!({
"action": action.tool,
"kind": other,
"label": label,
"suggested_tool": Value::Null,
"unavailable_reason": "P0 没有对应工具(P1 落地),请把这个建议解释给用户",
}),
}
}
/// 动作的人话:优先 M15 给的 `label`,否则按 kind 兜底。
fn action_label(action: &FixAction) -> String {
let label = action.label.trim();
if !label.is_empty() {
return label.to_string();
}
match action.kind {
FixActionKind::ChangeJava => "换一个匹配的 Java 版本".to_string(),
FixActionKind::AdjustMemory => "调整最大内存".to_string(),
FixActionKind::ChangeGraphics => "调整图形设置/驱动".to_string(),
FixActionKind::RemoveMod => "移除或替换冲突的 Mod".to_string(),
FixActionKind::Reinstall => "重装/修复对应组件".to_string(),
FixActionKind::SeekHelp => "导出诊断包并反馈".to_string(),
}
}
/// 从动作参数里挑内存上限:先看常见键,再退化成"第一个带数字的值"。
fn pick_memory_mb(action: &FixAction) -> Option<i64> {
if let Some(value) = pick_arg(
action,
&["value", "suggest", "mb", "max_mb", "xmx", "xmx_mb"],
) {
if let Some(mb) = parse_mb(&value) {
return Some(mb);
}
}
action.args.values().find_map(|v| parse_mb(v))
}
fn pick_arg(action: &FixAction, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| action.args.get(*key).cloned())
.filter(|v| !v.trim().is_empty())
}
/// `"4096"` / `"4G"` / `"6144M"` → MB。
fn parse_mb(raw: &str) -> Option<i64> {
let text = raw.trim().to_ascii_lowercase();
let (number, scale) = if let Some(rest) = text.strip_suffix('g') {
(rest, 1024)
} else if let Some(rest) = text.strip_suffix("gb") {
(rest, 1024)
} else if let Some(rest) = text.strip_suffix('m') {
(rest, 1)
} else if let Some(rest) = text.strip_suffix("mb") {
(rest, 1)
} else {
(text.as_str(), 1)
};
number.trim().parse::<i64>().ok().map(|n| n * scale)
}
fn cap_lines(lines: &[String], max_lines: usize) -> Vec<String> {
lines
.iter()
.take(max_lines)
.map(|line| {
let masked = redact_text(line);
if masked.chars().count() <= MAX_EVIDENCE_CHARS {
masked
} else {
let head: String = masked.chars().take(MAX_EVIDENCE_CHARS).collect();
format!("{head}…")
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockDiagnose;
fn call(port: &MockDiagnose, session_id: &str) -> ToolOutput {
let report = port.diagnose(session_id).expect("mock 应返回结论");
render(&report, DEFAULT_EVIDENCE_LINES)
}
#[test]
fn renders_candidates_with_p0_bridge_for_reinstall() {
let out = call(&MockDiagnose::new(), "s-1");
assert_eq!(out.content["candidates"].as_array().unwrap().len(), 1);
let confidence = out.content["top_confidence"].as_f64().unwrap();
assert!((confidence - 0.86).abs() < 1e-6, "{confidence}");
let steps = out.content["p0_next_steps"].as_array().unwrap();
assert_eq!(steps[0]["kind"], json!("reinstall"));
assert_eq!(steps[0]["suggested_tool"], Value::Null);
assert!(out.summary.contains("建议:重装该版本的 Forge 组件"));
}
#[test]
fn bridges_memory_action_to_set_config() {
let mut report = MockDiagnose::forge_missing_library();
if let Some(candidate) = report.candidates.first_mut() {
candidate.fix_actions = vec![FixAction {
kind: FixActionKind::AdjustMemory,
tool: "memory.set".to_string(),
label: String::new(),
args: [("suggest".to_string(), "4G".to_string())]
.into_iter()
.collect(),
}];
}
let out = render(&report, 5);
let steps = out.content["p0_next_steps"].as_array().unwrap();
assert_eq!(steps[0]["suggested_tool"], json!("set_config"));
assert_eq!(steps[0]["suggested_args"]["path"], json!(MEMORY_KEY));
assert_eq!(steps[0]["suggested_args"]["value"], json!(4096));
assert_eq!(steps[0]["label"], json!("调整最大内存"));
}
#[test]
fn parses_memory_units() {
assert_eq!(parse_mb("4096"), Some(4096));
assert_eq!(parse_mb("4G"), Some(4096));
assert_eq!(parse_mb("6144mb"), Some(6144));
assert_eq!(parse_mb("abc"), None);
}
#[test]
fn empty_candidates_says_need_more_info() {
let report = CrashDiagnosis {
session_id: "s-2".into(),
source: "rules".into(),
needs_more_info: true,
candidates: vec![],
secondary: vec![],
evidence: vec![],
exit: crate::port::ExitSummary::default(),
};
let out = render(&report, 5);
assert!(out.summary.contains("需要更多信息"));
}
}
+199
View File
@@ -0,0 +1,199 @@
//! 实例 / 版本 / Java 三个只读工具(M39 任务 3 的读侧)。
//!
//! 全部只读、全部走 [`crate::port::GamePort`]:agent 不自己去翻 `versions/` 目录,
//! 也不自己扫 JDK——那两件事的真相源在 M14(game)与 M33(java),重复一份必然漂。
//!
//! 输出形状刻意做成「摘要 + 上限」:实例可能几十个、Java 候选可能十几个,
//! 一次全塞给模型等于烧 token(02 §3.2 的「单次返回上限防 token 爆炸」同理)。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::port::GamePort;
use crate::registry::schema::{integer, object};
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 实例列表工具名。
pub const LIST_INSTANCES: &str = "list_instances";
/// 版本列表工具名。
pub const LIST_VERSIONS: &str = "list_versions";
/// Java 现状工具名。
pub const GET_JAVA_STATUS: &str = "get_java_status";
/// 单次最多返回多少个实例/版本。
pub const MAX_ROWS: usize = 50;
/// 注册三个只读工具。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(registry: &mut ToolRegistry, port: Arc<dyn GamePort>) -> Result<(), RegistryError> {
registry.register(list_instances_spec(Arc::clone(&port)))?;
registry.register(list_versions_spec(Arc::clone(&port)))?;
registry.register(java_status_spec(port))
}
fn limit_arg(args: &Value) -> usize {
args.get("limit")
.and_then(Value::as_u64)
.map_or(MAX_ROWS, |n| usize::try_from(n).unwrap_or(MAX_ROWS))
.clamp(1, MAX_ROWS)
}
fn limit_schema() -> Value {
integer(
&format!("最多返回多少条(1~{MAX_ROWS},默认 {MAX_ROWS})"),
1,
MAX_ROWS as i64,
)
}
fn list_instances_spec(port: Arc<dyn GamePort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let limit = limit_arg(args);
let mut instances = port.list_instances().map_err(ToolError::Port)?;
let total = instances.len();
instances.truncate(limit);
Ok(ToolOutput::ok(
json!({
"total": total,
"shown": instances.len(),
"truncated": total > instances.len(),
"instances": instances,
}),
format!("列出 {}/{} 个实例", instances.len(), total),
))
});
ToolSpec::read_only(
LIST_INSTANCES,
"列出启动器里的游戏实例(id / 名称 / 版本 id / 载荷是否已安装 / 独立游戏目录 / 实例级 Java)。要看某个实例的日志先用 list_game_sessions。",
object(&[("limit", limit_schema())], &[]),
handler,
)
}
fn list_versions_spec(port: Arc<dyn GamePort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let limit = limit_arg(args);
let mut versions = port.list_versions().map_err(ToolError::Port)?;
let total = versions.len();
versions.truncate(limit);
Ok(ToolOutput::ok(
json!({
"total": total,
"shown": versions.len(),
"truncated": total > versions.len(),
"versions": versions,
}),
format!("列出 {}/{} 个已安装版本", versions.len(), total),
))
});
ToolSpec::read_only(
LIST_VERSIONS,
"列出已安装的游戏版本(`versions/<id>/` 下真实存在的载荷),带类型与被几个实例引用。判断「实例的载荷缺没缺」时配合 list_instances 的 version_present 一起看。",
object(&[("limit", limit_schema())], &[]),
handler,
)
}
fn java_status_spec(port: Arc<dyn GamePort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
let status = port.java_status().map_err(ToolError::Port)?;
let majors: Vec<String> = status
.candidates
.iter()
.filter_map(|c| c.major.map(|m| m.to_string()))
.collect();
Ok(ToolOutput::ok(
json!({
"default_memory_mb": status.default_memory_mb,
"managed_roots": status.managed_roots,
"candidate_count": status.candidates.len(),
"majors": majors,
"candidates": status.candidates,
}),
format!(
"Java 候选 {} 个(主版本 {})",
status.candidates.len(),
if majors.is_empty() {
"无".to_string()
} else {
majors.join("/")
}
),
))
});
ToolSpec::read_only(
GET_JAVA_STATUS,
"读取 Java 现状:托管扫描根、默认堆内存、探测到的全部运行时(路径 / 版本 / 主版本 / 发行方 / 来源)。启动报「不支持的 class 版本」「找不到 Java」时先看这里。",
object(&[], &[]),
handler,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockGame;
fn registry() -> ToolRegistry {
let mut registry = ToolRegistry::new();
register(&mut registry, Arc::new(MockGame::new())).expect("注册应成功");
registry
}
fn call(registry: &ToolRegistry, name: &str, args: Value) -> Value {
let spec = registry.get(name).expect("工具存在").clone();
let ctx = ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
};
(spec.handler)(&ctx, &args).expect("应成功").content
}
#[test]
fn lists_instances_with_limit() {
let content = call(&registry(), LIST_INSTANCES, json!({ "limit": 1 }));
assert_eq!(content["total"], json!(1));
assert_eq!(content["instances"][0]["id"], json!("inst-1"));
assert_eq!(content["truncated"], json!(false));
}
#[test]
fn lists_versions() {
let content = call(&registry(), LIST_VERSIONS, json!({}));
assert_eq!(content["total"], json!(2));
assert_eq!(content["versions"][0]["id"], json!("1.20.1-forge-47.2.20"));
}
#[test]
fn java_status_summarises_majors() {
let content = call(&registry(), GET_JAVA_STATUS, json!({}));
assert_eq!(content["candidate_count"], json!(2));
assert_eq!(content["majors"], json!(["17", "21"]));
assert_eq!(content["default_memory_mb"], json!(2048));
}
#[test]
fn port_errors_are_passed_through() {
let mut registry = ToolRegistry::new();
register(
&mut registry,
Arc::new(MockGame::failing("versions 目录不可读")),
)
.expect("注册应成功");
let spec = registry.get(LIST_VERSIONS).expect("工具存在").clone();
let ctx = ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
};
let err = (spec.handler)(&ctx, &json!({})).unwrap_err();
assert_eq!(err.code(), "port_error");
assert!(err.message().contains("versions 目录不可读"));
}
}
+39
View File
@@ -0,0 +1,39 @@
//! 工具集 v1(02 文档 §3):配置读写 / 会话与日志 / 崩溃诊断 / 实例与 Java / 账户。
//!
//! 分工边界(重要,评审时按这条看):
//! - **工具**:参数语义 → 端口调用 → 结构化结果;需要确认时**只报价不执行**。
//! - **编排层**([`crate::brain::exec`]):schema 校验、确认闸门、YOLO 判定、审计、事件。
//! - **端口**([`crate::port`]):真正的 IO 与领域校验(M12/M14/M15/M18/M31/M33 拥有)。
//!
//! 这样「YOLO」只在一个地方(编排层),工具的测试不需要关心 policy。
//!
//! 双投影:这里的注册表是**唯一真相源**,内置客户端投成 rig `DynamicTool`,
//! 对外 MCP server 投成 rmcp `ToolRoute`([`crate::mcp`])。工具数量与顺序对外一致。
pub mod accounts;
pub mod config;
pub mod diagnose;
pub mod game;
pub mod schema_tool;
pub mod session;
use crate::port::Ports;
use crate::registry::{RegistryError, ToolRegistry};
/// 组装 v1 的全部工具。注册顺序 = 模型看到的顺序(只读在前,写在后)。
///
/// 顺序不是随便排的:模型在长工具表里更容易选中靠前的项,所以「先看再改」
/// (`get_config*` 在 `set_config` 之前)是刻意的提示。
///
/// # Errors
/// 工具名重复 / schema 非法时返回 [`RegistryError`](属于编码错误,装配期就该炸)。
pub fn build_v1_registry(ports: &Ports) -> Result<ToolRegistry, RegistryError> {
let mut registry = ToolRegistry::new();
config::register(&mut registry, ports.setting.clone())?;
schema_tool::register(&mut registry, ports.setting.clone())?;
game::register(&mut registry, ports.game.clone())?;
accounts::register(&mut registry, ports.accounts.clone())?;
session::register(&mut registry, ports.sessions.clone())?;
diagnose::register(&mut registry, ports.diagnose.clone())?;
Ok(registry)
}
+84
View File
@@ -0,0 +1,84 @@
//! `get_config_schema`:把 M12 的配置 schema(**只含 AI 可写键**)投给模型。
//!
//! 为什么单独一个工具而不是塞进 `get_config`:模型要能「先看有哪些键、类型是什么、
//! 哪些是敏感键」,再决定要不要写——这是一次性的静态信息,和读当前值不是同一件事。
//! 拆开还能让 token 预算更可控(schema 比概要大一个量级)。
//!
//! 凭据边界:schema 由 M12 的 `SettingSchema::to_json_schema()` 生成,只遍历
//! `ai.writable && !deprecated` 的键,`ai.api_key`(`locked`)天然不在其中。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::port::SettingPort;
use crate::registry::schema::object;
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 工具名。
pub const GET_CONFIG_SCHEMA: &str = "get_config_schema";
/// 注册 `get_config_schema`。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn SettingPort>,
) -> Result<(), RegistryError> {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
let schema = port.schema();
if schema.get("properties").is_none() {
return Err(ToolError::Port(
"配置 schema 不可用:装配层没有提供 SettingPort::schema()".to_string(),
));
}
let writable = schema
.get("properties")
.and_then(Value::as_object)
.map_or(0, serde_json::Map::len);
Ok(ToolOutput::ok(
json!({
"scope": "ai_writable_schema",
"writable_key_count": writable,
"schema": schema,
}),
format!("读取配置 schema({writable} 个 AI 可写键)"),
))
});
registry.register(ToolSpec::read_only(
GET_CONFIG_SCHEMA,
"读取启动器配置的 JSON Schema(只含 AI 可写键)。每个键带类型、取值范围、说明与 `x-ai-sensitivity` 敏感等级;凭据类键不会出现。写配置前先看它。",
object(&[], &[]),
handler,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockSettings;
fn ctx() -> ToolCtx {
ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
}
}
#[test]
fn returns_schema_without_credentials() {
let port: Arc<dyn SettingPort> = Arc::new(MockSettings::new());
let mut registry = ToolRegistry::new();
register(&mut registry, port).expect("注册应成功");
let spec = registry.get(GET_CONFIG_SCHEMA).expect("工具存在").clone();
let out = (spec.handler)(&ctx(), &json!({})).expect("应返回 schema");
let text = out.content.to_string();
assert!(out.content["writable_key_count"].as_u64().unwrap_or(0) > 0);
assert!(text.contains("x-ai-sensitivity"));
assert!(!text.contains("ai.api_key"), "凭据键不得进 schema:{text}");
}
}
+388
View File
@@ -0,0 +1,388 @@
//! `list_game_sessions` / `read_game_log`(02 文档 §3.2「能读取某次游戏崩溃日志」)。
//!
//! 数据源是 M18 的会话事件流(`DevDocs/SubAgentsTalk/launch-接口约定.md`):
//! - 列表读 `index.jsonl`(每会话一行摘要,同 id 取最后一行);
//! - 日志读 `<session-id>.jsonl`,**按 `seq` 切片 + 按 `kind` 过滤**,不是按行号。
//!
//! 两条防 token 爆炸的硬约束:
//! 1. 单次最多 [`MAX_LOG_EVENTS`] 条事件(默认 [`DEFAULT_LOG_EVENTS`]);
//! 2. 单条 payload 里的字符串超过 [`MAX_PAYLOAD_CHARS`] 截断。
//!
//! 脱敏:launch 落盘前已经做过一遍(`<home>` / `<redacted:token>`),本工具再跑一遍
//! [`crate::redact::redact_text`] 作为第二道防线(幂等,旧会话/第三方 mod 输出可能漏网)。
use std::sync::Arc;
use serde_json::{json, Map, Value};
use crate::port::{GameSessionPort, LogEvent, LogPage, LogQuery};
use crate::redact::redact_text;
use crate::registry::schema::{enum_string_array, integer, non_empty_string, object, string};
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 列出游戏会话。
pub const LIST_GAME_SESSIONS: &str = "list_game_sessions";
/// 读一个会话的日志。
pub const READ_GAME_LOG: &str = "read_game_log";
/// 单次返回的事件上限(防 token 爆炸的硬闸)。
pub const MAX_LOG_EVENTS: usize = 500;
/// 默认返回条数。
pub const DEFAULT_LOG_EVENTS: usize = 100;
/// 单条 payload 字符串的展示上限(字符)。
pub const MAX_PAYLOAD_CHARS: usize = 800;
/// 列表默认/最大条数。
pub const DEFAULT_SESSION_LIMIT: usize = 10;
/// 列表最大条数。
pub const MAX_SESSION_LIMIT: usize = 50;
/// `launch-接口约定.md` §3 的事件类型(schema 里给模型做枚举;未知 kind 仍可透传)。
pub const KNOWN_KINDS: [&str; 13] = [
"session_start",
"java_selected",
"cmd_built",
"natives_extracted",
"proc_start",
"stdout",
"stderr",
"window_detected",
"exit",
"crash_found",
"diagnosis",
"killed",
"session_end",
];
/// 注册两个日志工具。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn GameSessionPort>,
) -> Result<(), RegistryError> {
registry.register(list_sessions_spec(Arc::clone(&port)))?;
registry.register(read_log_spec(port))
}
fn list_sessions_spec(port: Arc<dyn GameSessionPort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let limit = args
.get("limit")
.and_then(Value::as_u64)
.map_or(DEFAULT_SESSION_LIMIT, |n| n as usize)
.clamp(1, MAX_SESSION_LIMIT);
let instance_id = args.get("instance_id").and_then(Value::as_str);
let sessions = port
.list_sessions(limit, instance_id)
.map_err(ToolError::Port)?;
if sessions.is_empty() {
return Ok(ToolOutput::ok(
json!({ "sessions": [], "count": 0 }),
"没有游戏会话记录(还没启动过游戏,或数据目录不对)".to_string(),
));
}
let crashed = sessions
.iter()
.filter(|s| s.crash_found || s.silent_failure || s.exit_code.is_some_and(|c| c != 0))
.count();
let summary = format!(
"最近 {} 个游戏会话,其中 {crashed} 个崩溃/异常退出;最近一次 {}({})",
sessions.len(),
sessions[0].session_id,
sessions[0].started_at.as_deref().unwrap_or("启动时间未知")
);
Ok(ToolOutput::ok(
json!({ "sessions": sessions, "count": sessions.len(), "problem_count": crashed }),
summary,
))
});
ToolSpec::read_only(
LIST_GAME_SESSIONS,
"列出最近的游戏会话(启动时间、退出码、是否崩溃、规则引擎首选结论)。用于定位要读哪一次会话的日志。",
object(
&[
("limit", integer("返回条数", 1, MAX_SESSION_LIMIT as i64)),
("instance_id", string("只看某个实例的会话;省略则全部实例")),
],
&[],
),
handler,
)
}
fn read_log_spec(port: Arc<dyn GameSessionPort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let session_id = args
.get("session_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))?
.to_string();
let kinds: Vec<String> = args
.get("kinds")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let max_events = args
.get("max_events")
.and_then(Value::as_u64)
.map_or(DEFAULT_LOG_EVENTS, |n| n as usize)
.clamp(1, MAX_LOG_EVENTS);
let query = LogQuery {
from_seq: args.get("from_seq").and_then(Value::as_u64),
to_seq: args.get("to_seq").and_then(Value::as_u64),
kinds,
max_events,
};
let page = port
.read_log(&session_id, &query)
.map_err(ToolError::Port)?;
Ok(render_page(&page))
});
ToolSpec::read_only(
READ_GAME_LOG,
"读取某次游戏会话的事件流(stdout/stderr 按行、退出事件、crash-report 摘录)。可按 seq 区间和事件类型过滤;单次最多 500 条、单条最长 800 字符,优先用 kinds 过滤 stderr/exit/crash_found 而不是拉全量。",
object(
&[
("session_id", non_empty_string("会话 id,来自 list_game_sessions")),
("from_seq", integer("起始 seq(含)", 0, 9_007_199_254_740_991)),
("to_seq", integer("结束 seq(含)", 0, 9_007_199_254_740_991)),
(
"kinds",
enum_string_array(&KNOWN_KINDS, "只要这些事件类型;省略则全部", 13),
),
("max_events", integer("最多返回多少条", 1, MAX_LOG_EVENTS as i64)),
],
&["session_id"],
),
handler,
)
}
/// 把端口返回的一页日志压成工具结果(含摘要,模型据此决定要不要继续翻页)。
fn render_page(page: &LogPage) -> ToolOutput {
let events: Vec<Value> = page.events.iter().map(render_event).collect();
let kind_summary = page
.kind_counts
.iter()
.map(|(kind, count)| format!("{kind}×{count}"))
.collect::<Vec<_>>()
.join(", ");
let (content, summary) = if events.is_empty() {
(
json!({
"session_id": page.session_id,
"total_events": page.total_events,
"matched_events": 0,
"truncated": false,
"kind_counts": page.kind_counts,
"first_ts": page.first_ts,
"last_ts": page.last_ts,
"events": [],
"hint": "没有符合过滤条件的事件;把 kinds/from_seq/to_seq 放宽再试",
}),
format!(
"会话 {} 没有匹配的事件(共 {} 条)",
page.session_id, page.total_events
),
)
} else {
let truncated_note = if page.truncated {
"(已截断)"
} else {
""
};
(
json!({
"session_id": page.session_id,
"total_events": page.total_events,
"matched_events": page.matched_events,
"truncated": page.truncated,
"kind_counts": page.kind_counts,
"first_ts": page.first_ts,
"last_ts": page.last_ts,
"events": events,
}),
format!(
"会话 {}:共 {} 条事件,返回 {} 条{truncated_note};类型分布 {kind_summary}",
page.session_id,
page.total_events,
events.len(),
),
)
};
ToolOutput::ok(content, summary)
}
fn render_event(event: &LogEvent) -> Value {
json!({
"seq": event.seq,
"ts": event.ts,
"t_ms": event.t_ms,
"kind": event.kind,
"payload": shrink(&event.payload),
})
}
/// 递归脱敏 + 截断(第二道脱敏防线)。
fn shrink(value: &Value) -> Value {
match value {
Value::String(text) => Value::String(cap(&redact_text(text))),
Value::Array(items) => Value::Array(items.iter().map(shrink).collect()),
Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
for (key, item) in map {
out.insert(key.clone(), shrink(item));
}
Value::Object(out)
}
other => other.clone(),
}
}
fn cap(text: &str) -> String {
let length = text.chars().count();
if length <= MAX_PAYLOAD_CHARS {
return text.to_string();
}
let head: String = text.chars().take(MAX_PAYLOAD_CHARS).collect();
format!("{head}…(已截断 {} 字符)", length - MAX_PAYLOAD_CHARS)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockSessions;
use serde_json::json;
const SESSION: &str = "20261029T124501-9f3c2a";
fn mock() -> MockSessions {
let (summary, events) = MockSessions::crashing_session(SESSION);
let (older, older_events) = MockSessions::crashing_session("20261028T100000-aaaaaa");
let mut sessions = MockSessions::new();
sessions.add(summary, events);
sessions.add(older, older_events);
sessions
}
fn registry() -> ToolRegistry {
let mut registry = ToolRegistry::new();
register(&mut registry, Arc::new(mock())).expect("注册应成功");
registry
}
/// 走**真实注册表 → 真实 handler**,不复制实现(否则测的是复制品)。
fn call(tool: &str, args: Value) -> ToolOutput {
let registry = registry();
let spec = registry.get(tool).expect("工具应存在").clone();
(spec.handler)(
&ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
},
&args,
)
.expect("工具应成功")
}
#[test]
fn lists_sessions_newest_first() {
let out = call(LIST_GAME_SESSIONS, json!({ "limit": 5 }));
assert_eq!(out.content["count"], json!(2));
assert_eq!(out.content["sessions"][0]["session_id"], json!(SESSION));
assert_eq!(out.content["problem_count"], json!(2));
assert!(out.summary.contains("2 个崩溃/异常退出"));
}
#[test]
fn list_respects_limit() {
let out = call(LIST_GAME_SESSIONS, json!({ "limit": 1 }));
assert_eq!(out.content["count"], json!(1));
}
#[test]
fn filters_by_kind_and_keeps_summary() {
let out = call(
READ_GAME_LOG,
json!({ "session_id": SESSION, "kinds": ["stderr"] }),
);
assert_eq!(out.content["events"].as_array().unwrap().len(), 1);
assert_eq!(out.content["kind_counts"]["stdout"], json!(1));
assert!(out.summary.contains("共 5 条事件"));
}
#[test]
fn truncates_when_more_events_than_cap() {
let out = call(
READ_GAME_LOG,
json!({ "session_id": SESSION, "max_events": 2 }),
);
assert_eq!(out.content["events"].as_array().unwrap().len(), 2);
assert_eq!(out.content["truncated"], json!(true));
assert_eq!(out.content["matched_events"], json!(5));
}
#[test]
fn masks_user_dir_in_payload() {
let out = call(
READ_GAME_LOG,
json!({ "session_id": SESSION, "kinds": ["crash_found"] }),
);
let payload = &out.content["events"][0]["payload"];
assert_eq!(
payload["path"],
json!(r"C:\Users\***\.minecraft\crash-reports\crash-1.txt")
);
}
#[test]
fn reports_empty_result_with_hint() {
let out = call(
READ_GAME_LOG,
json!({ "session_id": SESSION, "kinds": ["window_detected"] }),
);
assert_eq!(out.content["events"], json!([]));
assert!(out.content["hint"].is_string());
}
#[test]
fn caps_long_payload_text() {
let long = "x".repeat(MAX_PAYLOAD_CHARS + 10);
assert!(cap(&long).contains("已截断 10 字符"));
}
#[test]
fn unknown_session_is_a_port_error() {
let registry = registry();
let spec = registry.get(READ_GAME_LOG).expect("工具应存在").clone();
let err = (spec.handler)(
&ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
},
&json!({ "session_id": "nope" }),
)
.unwrap_err();
assert_eq!(err.code(), "port_error");
assert!(err.message().contains("不存在"));
}
}
+248
View File
@@ -0,0 +1,248 @@
//! 对话循环集成测试:mock provider 驱动**完整链路**(provider → 工具编排 → 审计 → 事件)。
//!
//! 这里测"循环的骨架":多轮工具调用、步数上限、流式增量、禁用态、provider 失败。
//! 安全边界(白名单/确认/YOLO/审计标记)在 `tools_security.rs`。
mod support;
use std::sync::Arc;
use serde_json::json;
use support::{
block_on, list_args, tool_call_reply, Harness, MockProvider, AGENT_SESSION, SESSION,
};
use unlucky_agent::audit::AuditOutcome;
use unlucky_agent::brain::{BrainError, ChatMessage, ChatReply, StopReason, ToolCall};
use unlucky_agent::event::AgentEvent;
#[test]
fn single_turn_without_tools_returns_text() {
let harness = Harness::new(vec![ChatReply::text("你好,需要我看看最近的崩溃吗?")]);
let mut history = Vec::new();
let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap();
assert_eq!(outcome.text, "你好,需要我看看最近的崩溃吗?");
assert_eq!(outcome.steps, 1);
assert_eq!(outcome.tool_calls, 0);
assert_eq!(outcome.stopped_reason, StopReason::Completed);
assert_eq!(history.len(), 2, "应只有 user + assistant");
assert!(harness.audit.is_empty(), "没调工具就不该有审计记录");
}
#[test]
fn system_prompt_leads_every_request_and_tools_are_projected() {
let harness = Harness::new(vec![ChatReply::text("好的")]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "看看配置"),
)
.unwrap();
let requests = harness.provider.requests();
assert_eq!(requests.len(), 1);
let request = &requests[0];
assert!(
matches!(&request.messages[0], ChatMessage::System { content } if content.contains("Unlucky-Launcher")),
"system prompt 必须在首位"
);
assert_eq!(request.model, "mock-model");
let names: Vec<&str> = request.tools.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
vec![
"get_config",
"set_config",
"get_config_schema",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
],
"注册表的双投影载荷必须原样交给 provider"
);
// 双投影契约:provider 收到的 schema 与注册表定义逐字节相等。
assert_eq!(request.tools, harness.registry.definitions());
}
#[test]
fn tool_call_round_trip_feeds_result_back_and_finishes() {
let harness = Harness::new(vec![
tool_call_reply("call_1", "list_game_sessions", list_args()),
ChatReply::text("最近一次会话崩溃了,要不要跑诊断?"),
]);
let mut history = Vec::new();
let outcome = block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "最近崩了吗"),
)
.unwrap();
assert_eq!(outcome.tool_calls, 1);
assert_eq!(outcome.steps, 2);
assert_eq!(outcome.stopped_reason, StopReason::Completed);
let requests = harness.provider.requests();
assert_eq!(requests.len(), 2);
let tool_message = requests[1]
.messages
.iter()
.find_map(|m| match m {
ChatMessage::Tool {
call_id,
name,
content,
} => Some((call_id.clone(), name.clone(), content.clone())),
_ => None,
})
.expect("必须有工具结果消息");
assert_eq!(tool_message.0, "call_1");
assert_eq!(tool_message.1, "list_game_sessions");
assert!(
tool_message.2.contains(SESSION),
"工具结果应包含会话 id:{}",
tool_message.2
);
let audits = harness.audit.records();
assert_eq!(audits.len(), 1);
assert_eq!(audits[0].tool, "list_game_sessions");
assert_eq!(audits[0].outcome, AuditOutcome::Ok);
assert!(!audits[0].yolo);
assert!(!audits[0].confirmed);
}
#[test]
fn multiple_tool_calls_in_one_step_all_execute() {
let reply = ChatReply::with_tool_calls(vec![
ToolCall {
id: "c1".to_string(),
name: "list_game_sessions".to_string(),
arguments: list_args(),
},
ToolCall {
id: "c2".to_string(),
name: "diagnose_crash".to_string(),
arguments: json!({ "session_id": SESSION }),
},
]);
let harness = Harness::new(vec![reply, ChatReply::text("诊断完成")]);
let mut history = Vec::new();
let outcome = block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "查一下"),
)
.unwrap();
assert_eq!(outcome.tool_calls, 2);
assert_eq!(harness.audit.len(), 2);
assert_eq!(
harness
.audit
.records()
.iter()
.map(|r| r.tool.as_str())
.collect::<Vec<_>>(),
vec!["list_game_sessions", "diagnose_crash"]
);
}
#[test]
fn step_limit_stops_runaway_loop() {
// 每一步都要求调工具,永远不会给最终回答。
let script: Vec<ChatReply> = (0..16)
.map(|i| tool_call_reply(&format!("c{i}"), "get_config", json!({})))
.collect();
let harness = Harness::new(script);
let mut history = Vec::new();
let outcome = block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "死循环"),
)
.unwrap();
assert_eq!(outcome.stopped_reason, StopReason::StepLimit);
assert_eq!(outcome.steps, unlucky_agent::brain::DEFAULT_MAX_STEPS);
assert_eq!(outcome.tool_calls, unlucky_agent::brain::DEFAULT_MAX_STEPS);
}
#[test]
fn streaming_deltas_are_forwarded_as_events() {
let harness = Harness::new(vec![ChatReply::text("日志里是 NoClassDefFoundError")]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "什么错误"),
)
.unwrap();
assert_eq!(
harness.events.streamed_text(),
"日志里是 NoClassDefFoundError"
);
let events = harness.events.events();
assert!(events
.iter()
.any(|e| matches!(e, AgentEvent::TurnStarted { .. })));
assert!(events
.iter()
.any(|e| matches!(e, AgentEvent::AssistantText { .. })));
assert!(events
.iter()
.any(|e| matches!(e, AgentEvent::TurnFinished { .. })));
}
#[test]
fn disabled_provider_fails_fast() {
let harness = Harness::from_provider(Arc::new(MockProvider::disabled()));
let mut history = Vec::new();
let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err();
assert_eq!(err, BrainError::Disabled);
assert!(err.to_string().contains("规则引擎"), "要提示降级能力");
assert!(
harness.provider.requests().is_empty(),
"禁用态不该发起任何请求"
);
assert!(history.is_empty(), "禁用态不该改动历史");
}
#[test]
fn provider_error_surfaces_as_brain_error_and_event() {
let harness = Harness::from_provider(Arc::new(MockProvider::failing(
"连接 http://127.0.0.1:8080 失败",
)));
let mut history = Vec::new();
let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err();
assert_eq!(
err,
BrainError::Provider(unlucky_agent::brain::ProviderError::new(
"连接 http://127.0.0.1:8080 失败"
))
);
assert!(harness
.events
.events()
.iter()
.any(|e| matches!(e, AgentEvent::Error { message } if message.contains("8080"))));
}
#[test]
fn empty_script_yields_empty_completion_turn() {
let harness = Harness::new(vec![]);
let mut history = Vec::new();
let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap();
assert_eq!(outcome.text, "");
assert_eq!(outcome.stopped_reason, StopReason::Completed);
}
+132
View File
@@ -0,0 +1,132 @@
//! **双投影契约测试(MCP 侧)**:注册表 → rmcp `Tool` 必须与 rig 侧逐项相等。
//!
//! research/02 §6.2 建议进 CI 的那条断言,M16 落的是「注册表自身是纯投影」+「rig 适配层
//! 只做直投」,M39 补上 rmcp 侧:
//!
//! 1. `mcp::projection::tool_attrs(&registry)` 的 `(name, description, input_schema)`
//! 与 `registry.definitions()` **逐项相等**(JSON 层面,不是"看起来差不多");
//! 2. `tools/list` 的顺序与注册顺序一致,数量等于注册表长度;
//! 3. 每个 schema 根是 `object`(rig 与 rmcp 的共同前提);
//! 4. `read_only_hint` 与 `Access` 一致(外部客户端据此决定能不能静默调用);
//! 5. 只读子集不会漏出写工具(`agent.mcp.allow_write=false` 的默认形态)。
use serde_json::{json, Value};
use unlucky_agent::mcp::projection::{tool_attr, tool_attrs};
use unlucky_agent::port::Ports;
use unlucky_agent::registry::{Access, ToolDefinition, ToolRegistry};
use unlucky_agent::testing::mock_ports;
use unlucky_agent::tools::build_v1_registry;
fn registry() -> ToolRegistry {
let (ports, _settings) = mock_ports();
build_v1_registry(&ports).expect("注册表应装配成功")
}
fn ports() -> Ports {
mock_ports().0
}
#[test]
fn mcp_projection_equals_registry_definition_item_by_item() {
let registry = registry();
let definitions = registry.definitions();
let attrs = tool_attrs(&registry);
assert_eq!(attrs.len(), definitions.len());
for (attr, definition) in attrs.iter().zip(definitions.iter()) {
assert_eq!(attr.name.as_ref(), definition.name, "工具名必须相等");
assert_eq!(
attr.description.as_deref(),
Some(definition.description.as_str()),
"{} 的 description 必须逐字相等",
definition.name
);
assert_eq!(
Value::Object((*attr.input_schema).clone()),
definition.input_schema,
"{} 的 JSON Schema 必须逐项相等",
definition.name
);
}
}
#[test]
fn projection_order_matches_registration_order() {
let registry = registry();
let projected: Vec<String> = tool_attrs(&registry)
.iter()
.map(|attr| attr.name.to_string())
.collect();
assert_eq!(projected, registry.names(), "两侧顺序必须一致");
}
#[test]
fn every_projected_schema_is_an_object_and_round_trips() {
for attr in tool_attrs(&registry()) {
let schema = Value::Object((*attr.input_schema).clone());
assert_eq!(
schema["type"],
json!("object"),
"{} 的 schema 根必须是 object",
attr.name
);
// rmcp 会把 Tool 序列化进 tools/list 的响应体:必须能反序列化回来。
let json = serde_json::to_string(&attr).expect("Tool 可序列化");
let back: rmcp::model::Tool = serde_json::from_str(&json).expect("Tool 可反序列化");
assert_eq!(back, attr);
}
}
#[test]
fn read_only_hint_tracks_access() {
let registry = registry();
for (attr, spec) in tool_attrs(&registry).iter().zip(registry.specs()) {
let hint = attr
.annotations
.as_ref()
.and_then(|annotations| annotations.read_only_hint)
.expect("每个工具都应有 read_only_hint");
assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name);
}
}
#[test]
fn read_only_subset_projects_without_the_writing_tool() {
let subset = registry().read_only_subset();
assert_eq!(subset.len(), 9);
let names: Vec<String> = tool_attrs(&subset)
.iter()
.map(|attr| attr.name.to_string())
.collect();
assert!(!names.iter().any(|name| name == "set_config"), "{names:?}");
assert!(subset.access_of("set_config").is_none());
}
#[test]
fn projected_attrs_are_pure_functions_of_the_definition() {
let definition = ToolDefinition {
name: "demo".to_string(),
description: "演示".to_string(),
input_schema: json!({ "type": "object", "properties": {}, "required": [] }),
};
let first = tool_attr(&definition, Access::ReadOnly);
let second = tool_attr(&definition, Access::ReadOnly);
assert_eq!(first, second);
// 副作用等级换一下,只有 annotations 变,name/description/schema 不变。
let mutating = tool_attr(&definition, Access::Mutating);
assert_eq!(first.name, mutating.name);
assert_eq!(first.input_schema, mutating.input_schema);
assert_ne!(first.annotations, mutating.annotations);
}
#[test]
fn mock_ports_cover_every_port_the_registry_needs() {
// 装配完整性:五个端口都在,注册表才有这十个工具(少一个端口 = 少工具)。
let ports = ports();
let registry = build_v1_registry(&ports).expect("注册表应装配成功");
assert_eq!(registry.len(), 10);
assert!(unlucky_agent::testing::expected_v1_tool_names()
.iter()
.all(|name| registry.get(name).is_some()));
}
+336
View File
@@ -0,0 +1,336 @@
//! MCP over Streamable HTTP 的端到端测试:真起监听、打裸 JSON-RPC,断言工具返回与审计。
//!
//! 为什么不用 MCP 客户端 crate:客户端依赖会再拖一棵 reqwest 树(且 3.8 的客户端 feature
//! 与我们的服务端 feature 不是一组)。裸 HTTP 反而更贴近「用户用 Claude Code 连上来」
//! 的实际形状——握手、会话头、`tools/list`、`tools/call` 一个都不省。
//!
//! 覆盖的安全语义(02 §3.1 / §4b):
//! - 只读模式下 `set_config` 根本不在工具表里,硬调 → tool-level 错误 + `unknown_tool` 审计;
//! - 普通键(`download.concurrency`,Low)直接写入,审计 `outcome=ok`;
//! - 敏感键(`java.selected_path`,High)在**没有弹窗**的外部场景被拒,文案引导去设置页/YOLO;
//! - 打开 YOLO 后同一个写入成功,审计 `yolo=true`。
use std::net::SocketAddr;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use unlucky_agent::audit::{AuditOutcome, AuditRecord, JsonlAudit};
use unlucky_agent::brain::exec::ToolExecutor;
use unlucky_agent::clock::{FixedClock, SharedClock};
use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate};
use unlucky_agent::event::NoopSink;
use unlucky_agent::mcp::{bind, serve_http_on, McpConfig, RegistryServer};
use unlucky_agent::testing::mock_ports;
use unlucky_agent::tools::build_v1_registry;
const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ull-test","version":"0.1"}}}"#;
struct Harness {
addr: SocketAddr,
audit_dir: std::path::PathBuf,
}
fn audit_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ull-mcp-http-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn server(read_only: bool, yolo: bool, audit: Arc<JsonlAudit>) -> RegistryServer {
let (ports, _settings) = mock_ports();
let registry = Arc::new(build_v1_registry(&ports).expect("注册表应装配成功"));
let exposed = if read_only {
Arc::new(registry.read_only_subset())
} else {
Arc::clone(&registry)
};
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let executor = Arc::new(
ToolExecutor::new(
exposed,
audit,
Arc::new(NoUiGate),
ConfirmPolicy { yolo },
clock,
Arc::new(NoopSink),
)
.with_guidance(DenyGuidance::External),
);
RegistryServer::new(registry, executor, "mcp-http", read_only)
}
async fn spawn(tag: &str, read_only: bool, yolo: bool) -> Harness {
let dir = audit_dir(tag);
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let audit = Arc::new(JsonlAudit::new(&dir, clock));
let server = server(read_only, yolo, audit);
let cfg = McpConfig {
port: 0,
..McpConfig::default()
};
let (addr, listener) = bind(&cfg).await.expect("应能绑定临时端口");
tokio::spawn(async move {
let _ = serve_http_on(server, cfg, addr, listener).await;
});
Harness {
addr,
audit_dir: dir,
}
}
/// 发一条 HTTP POST(`Connection: close` 让读取变成 read_to_end)。
async fn post(
addr: SocketAddr,
body: &str,
session: Option<&str>,
token: Option<&str>,
) -> (u16, Option<String>, String) {
let mut stream = TcpStream::connect(addr).await.expect("连接失败");
let mut head = format!(
"POST /mcp HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\
Accept: application/json, text/event-stream\r\nConnection: close\r\n\
Content-Length: {}\r\n",
body.len()
);
if let Some(session) = session {
head.push_str(&format!("Mcp-Session-Id: {session}\r\n"));
}
if let Some(token) = token {
head.push_str(&format!("Authorization: Bearer {token}\r\n"));
}
head.push_str("\r\n");
stream
.write_all(format!("{head}{body}").as_bytes())
.await
.expect("写请求失败");
stream.flush().await.expect("刷新失败");
let mut raw = Vec::new();
stream.read_to_end(&mut raw).await.expect("读响应失败");
let text = String::from_utf8_lossy(&raw).to_string();
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((text.as_str(), ""));
let status = head
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|code| code.parse::<u16>().ok())
.unwrap_or(0);
let session = head.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("mcp-session-id")
.then(|| value.trim().to_string())
});
(status, session, body.to_string())
}
/// 响应体可能是裸 JSON,也可能是 SSE(`data: {...}`);两种都解出第一条 JSON-RPC 响应。
fn parse_rpc(body: &str) -> Value {
if let Ok(value) = serde_json::from_str::<Value>(body.trim()) {
return value;
}
for line in body.lines() {
let Some(data) = line.strip_prefix("data: ") else {
continue;
};
if let Ok(value) = serde_json::from_str::<Value>(data.trim()) {
return value;
}
}
panic!("响应体既不是 JSON 也不是 SSE:{body}");
}
async fn initialize(addr: SocketAddr) -> Option<String> {
let (status, session, body) = post(addr, INITIALIZE, None, None).await;
assert_eq!(status, 200, "initialize 应成功:{body}");
let response = parse_rpc(&body);
assert_eq!(
response["result"]["serverInfo"]["name"],
json!("unlucky-launcher")
);
// 按 MCP 规范补一条 initialized 通知(无 id、无响应体)。
let _ = post(
addr,
r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
session.as_deref(),
None,
)
.await;
session
}
async fn rpc(
addr: SocketAddr,
session: Option<&str>,
id: u32,
method: &str,
params: Value,
) -> Value {
let body =
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
let (status, _session, text) = post(addr, &body, session, None).await;
assert_eq!(status, 200, "{method} 应成功:{text}");
parse_rpc(&text)
}
fn audit_records(dir: &std::path::Path) -> Vec<AuditRecord> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let text = std::fs::read_to_string(entry.path()).unwrap_or_default();
for line in text.lines() {
if let Ok(record) = serde_json::from_str::<AuditRecord>(line) {
out.push(record);
}
}
}
}
out
}
#[tokio::test(flavor = "multi_thread")]
async fn readonly_server_lists_and_serves_only_read_tools() {
let harness = spawn("readonly", true, false).await;
let session = initialize(harness.addr).await;
let list = rpc(harness.addr, session.as_deref(), 2, "tools/list", json!({})).await;
let tools = list["result"]["tools"].as_array().expect("应有 tools 数组");
let names: Vec<&str> = tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
assert_eq!(names.len(), 9, "{names:?}");
assert!(!names.contains(&"set_config"), "只读模式不得暴露写工具");
assert!(names.contains(&"get_config"));
// 真调一个只读工具:返回真实配置概要(mock 端口)。
let call = rpc(
harness.addr,
session.as_deref(),
3,
"tools/call",
json!({ "name": "get_config", "arguments": {} }),
)
.await;
assert_ne!(call["result"]["isError"], json!(true), "{call}");
let text = call["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("download.concurrency"), "{text}");
// 硬调写工具:tool-level 错误(客户端能看见文案)+ unknown_tool 审计。
let denied = rpc(
harness.addr,
session.as_deref(),
4,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
)
.await;
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
let records = audit_records(&harness.audit_dir);
assert!(records.iter().any(|r| r.tool == "get_config"
&& r.outcome == AuditOutcome::Ok
&& r.agent_session_id == "mcp-http"));
assert!(records
.iter()
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::UnknownTool));
let _ = std::fs::remove_dir_all(&harness.audit_dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn write_server_enforces_sensitive_key_confirmation() {
let harness = spawn("write", false, false).await;
let session = initialize(harness.addr).await;
// 普通键:直接写。
let ok = rpc(
harness.addr,
session.as_deref(),
2,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
)
.await;
assert_ne!(ok["result"]["isError"], json!(true), "{ok}");
// 敏感键:外部客户端没有弹窗 → 拒绝,文案要能指导用户下一步。
let denied = rpc(
harness.addr,
session.as_deref(),
3,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
)
.await;
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
let text = denied["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(
text.contains("YOLO"),
"拒绝文案应引导到 YOLO/设置页:{text}"
);
assert!(text.contains("设置页"), "{text}");
let records = audit_records(&harness.audit_dir);
assert!(records
.iter()
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::AwaitingConfirmation));
assert!(records
.iter()
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Denied && !r.yolo));
let _ = std::fs::remove_dir_all(&harness.audit_dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn yolo_lets_the_same_write_through_and_audits_the_flag() {
let harness = spawn("yolo", false, true).await;
let session = initialize(harness.addr).await;
let written = rpc(
harness.addr,
session.as_deref(),
2,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
)
.await;
assert_ne!(written["result"]["isError"], json!(true), "{written}");
let records = audit_records(&harness.audit_dir);
let yolo_ok = records
.iter()
.find(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Ok);
let record = yolo_ok.expect("应有一条 ok 审计");
assert!(record.yolo, "YOLO 放行必须标记 yolo=true");
assert!(!record.confirmed, "YOLO 自动放行不算用户确认");
let _ = std::fs::remove_dir_all(&harness.audit_dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn bearer_token_is_enforced_when_configured() {
let dir = audit_dir("token");
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let audit = Arc::new(JsonlAudit::new(&dir, clock));
let server = server(true, false, audit);
let cfg = McpConfig {
port: 0,
token: Some("s3cret".to_string()),
..McpConfig::default()
};
let (addr, listener) = bind(&cfg).await.expect("应能绑定");
tokio::spawn(async move {
let _ = serve_http_on(server, cfg, addr, listener).await;
});
let (status, _, _) = post(addr, INITIALIZE, None, None).await;
assert_eq!(status, 401, "没带令牌必须 401");
let (status, _, body) = post(addr, INITIALIZE, None, Some("s3cret")).await;
assert_eq!(status, 200, "带对令牌应通过:{body}");
let _ = std::fs::remove_dir_all(&dir);
}
+163
View File
@@ -0,0 +1,163 @@
//! **双投影契约测试**(research/02 §6.2 建议进 CI 的那一条)。
//!
//! rig 侧(`DynamicTool::new`)与 rmcp 侧(`ToolRoute::new_dyn`)吃的都是
//! `(name, description, serde_json::Value schema)`。本 crate 只维护一份 `ToolSpec`,
//! 所以「两侧相等」在这里等价于三条可断言的性质:
//! 1. [`unlucky_agent::registry::ToolRegistry::definitions`] 是纯投影(多次调用逐字节相等);
//! 2. 每个 schema 根是 `type: object`(两侧都要求对象入参)且拒绝多余参数;
//! 3. 名字唯一,`required` 真的被执行(缺参能被校验器抓到)。
//!
//! 本文件只锁**投影载荷本身**;两侧的一致性各有专测:
//! - rig 侧:`tests/brain_loop.rs` 断言 `provider` 收到的 `request.tools` 与
//! `registry.definitions()` 逐字节相等(`brain/rig.rs` 只做直投);
//! - MCP 侧:`tests/mcp_contract.rs` 断言 rmcp `Tool` 的 `(name, description, schema)` 与
//! 注册表逐项相等(M39 补的那条)。
use serde_json::json;
use unlucky_agent::registry::validate::validate;
use unlucky_agent::registry::{Access, ToolDefinition};
use unlucky_agent::testing::mock_ports;
fn registry() -> unlucky_agent::registry::ToolRegistry {
let (ports, _settings) = mock_ports();
unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功")
}
#[test]
fn ships_exactly_the_v1_tools() {
let registry = registry();
assert_eq!(
registry.names(),
vec![
"get_config",
"set_config",
"get_config_schema",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
]
);
assert_eq!(registry.len(), 10);
}
#[test]
fn definitions_are_pure_projections() {
let registry = registry();
let first = registry.definitions();
let second = registry.definitions();
assert_eq!(first, second, "投影必须是纯函数");
for definition in &first {
// 序列化后再反序列化必须完全一样(rig 与 rmcp 都走 JSON)。
let json = serde_json::to_string(definition).expect("可序列化");
let round_trip: ToolDefinition = serde_json::from_str(&json).expect("可反序列化");
assert_eq!(&round_trip, definition);
assert!(!definition.description.trim().is_empty());
assert_eq!(
definition.input_schema["type"],
json!("object"),
"{} 的 schema 根必须是 object",
definition.name
);
}
}
#[test]
fn only_set_config_is_mutating() {
let registry = registry();
for name in registry.names() {
let expected = if name == "set_config" {
Access::Mutating
} else {
Access::ReadOnly
};
assert_eq!(registry.access_of(name), Some(expected), "{name}");
}
let read_only: Vec<String> = registry
.read_only_definitions()
.into_iter()
.map(|d| d.name)
.collect();
assert_eq!(
read_only,
vec![
"get_config",
"get_config_schema",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
],
"只读 MCP 模式只暴露这九个(写侧只有 set_config)"
);
}
#[test]
fn every_schema_rejects_unknown_arguments() {
for definition in registry().definitions() {
let err = validate(&definition.input_schema, &json!({ "__unexpected": 1 }))
.expect_err(&format!("{} 应拒绝野参数", definition.name));
assert!(
err.iter().any(|e| e.contains("不是可接受的参数名")),
"{}: {err:?}",
definition.name
);
}
}
#[test]
fn required_fields_are_actually_enforced() {
for definition in registry().definitions() {
let required = definition
.input_schema
.get("required")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
if required.is_empty() {
continue;
}
let err = validate(&definition.input_schema, &json!({}))
.expect_err(&format!("{} 应有必填参数", definition.name));
for field in required {
let name = field.as_str().unwrap_or_default();
assert!(
err.iter().any(|e| e.contains(name)),
"{} 的必填 `{name}` 没被校验:{err:?}",
definition.name
);
}
}
}
#[test]
fn integer_bounds_are_declared_and_enforced() {
let registry = registry();
let read_log = registry.get("read_game_log").expect("工具存在");
let max_events = &read_log.input_schema["properties"]["max_events"];
assert_eq!(max_events["minimum"], json!(1));
assert_eq!(max_events["maximum"], json!(500));
assert!(validate(
&read_log.input_schema,
&json!({ "session_id": "s", "max_events": 0 })
)
.is_err());
assert!(validate(
&read_log.input_schema,
&json!({ "session_id": "s", "max_events": 501 })
)
.is_err());
assert!(validate(
&read_log.input_schema,
&json!({ "session_id": "s", "max_events": 500 })
)
.is_ok());
}
+225
View File
@@ -0,0 +1,225 @@
//! 集成测试的公共脚手架:**mock provider** + 迷你 `block_on` + 装配好的 harness。
//!
//! `block_on` 是手写的 20 行:mock provider 的 future 永远立刻 Ready,所以不需要运行时。
//! 这样 agent 的循环测试**零 tokio 依赖**,阶段 B 换成 rig 后才需要真运行时。
#![allow(dead_code)]
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use serde_json::{json, Value};
use unlucky_agent::audit::{AuditSink, MemoryAudit};
use unlucky_agent::brain::{Brain, BrainConfig, BrainDeps};
use unlucky_agent::brain::{
ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderConfig, ProviderError,
ProviderFuture, ToolCall,
};
use unlucky_agent::clock::{FixedClock, SharedClock};
use unlucky_agent::confirm::{AutoApproveGate, ConfirmGate, ConfirmPolicy, NoUiGate};
use unlucky_agent::event::{AgentEventSink, MemoryEventSink};
use unlucky_agent::registry::ToolRegistry;
use unlucky_agent::testing::{mock_ports, MockSettings};
/// 立刻完成的 future 直接 poll;挂起就是测试写错了。
pub fn block_on<F: Future>(future: F) -> F::Output {
let mut future: Pin<Box<F>> = Box::pin(future);
let waker = Waker::noop();
let mut context = Context::from_waker(waker);
match future.as_mut().poll(&mut context) {
Poll::Ready(value) => value,
Poll::Pending => panic!("mock provider 不应挂起:测试里没有执行器"),
}
}
/// 脚本化 provider:按顺序回放 [`ChatReply`],并记录收到的请求。
pub struct MockProvider {
config: ProviderConfig,
script: Mutex<VecDeque<ChatReply>>,
requests: Mutex<Vec<ChatRequest>>,
fail_with: Option<String>,
}
impl MockProvider {
/// 用默认模型名(本地 llama.cpp 形态)回放脚本。
#[must_use]
pub fn new(script: Vec<ChatReply>) -> Self {
Self {
config: ProviderConfig::llamacpp_local("mock-model"),
script: Mutex::new(script.into()),
requests: Mutex::new(Vec::new()),
fail_with: None,
}
}
/// 禁用态(测 `BrainError::Disabled`)。
#[must_use]
pub fn disabled() -> Self {
Self {
config: ProviderConfig::disabled(),
script: Mutex::new(VecDeque::new()),
requests: Mutex::new(Vec::new()),
fail_with: None,
}
}
/// 每次补全都失败(测 provider 错误透传)。
#[must_use]
pub fn failing(message: &str) -> Self {
Self {
fail_with: Some(message.to_string()),
..Self::new(Vec::new())
}
}
/// 收到的请求(断言 system prompt / 工具定义 / 回灌的工具消息)。
#[must_use]
pub fn requests(&self) -> Vec<ChatRequest> {
self.requests.lock().map(|r| r.clone()).unwrap_or_default()
}
}
impl ChatProvider for MockProvider {
fn config(&self) -> &ProviderConfig {
&self.config
}
fn complete<'a>(
&'a self,
request: &'a ChatRequest,
observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a> {
Box::pin(async move {
if let Ok(mut requests) = self.requests.lock() {
requests.push(request.clone());
}
if let Some(message) = &self.fail_with {
return Err(ProviderError::new(message.clone()));
}
let reply = self
.script
.lock()
.ok()
.and_then(|mut script| script.pop_front())
.unwrap_or_default();
// 按 4 字符切块吐增量,模拟流式。
if !reply.text.is_empty() {
for chunk in reply.text.chars().collect::<Vec<_>>().chunks(4) {
observer.on_delta(&chunk.iter().collect::<String>());
}
}
Ok(reply)
})
}
}
/// 装配好的测试环境。
pub struct Harness {
/// 对话循环。
pub brain: Brain,
/// 审计出口(内存)。
pub audit: Arc<MemoryAudit>,
/// 事件出口(内存)。
pub events: Arc<MemoryEventSink>,
/// mock provider(查请求)。
pub provider: Arc<MockProvider>,
/// mock 配置端口(查是否真的写入)。
pub settings: Arc<MockSettings>,
/// 工具注册表。
pub registry: Arc<ToolRegistry>,
}
impl Harness {
/// P0 默认形态:无确认 UI(`NoUiGate`,敏感操作 fail-closed)+ 非 YOLO。
/// 确认请求本身仍会作为 [`unlucky_agent::event::AgentEvent::ConfirmRequested`] 发出。
#[must_use]
pub fn new(script: Vec<ChatReply>) -> Self {
Self::with_gate(script, ConfirmPolicy::default(), |_sink| Arc::new(NoUiGate))
}
/// YOLO:自动放行全部(除破坏性)。
#[must_use]
pub fn yolo(script: Vec<ChatReply>) -> Self {
Self::with_gate(script, ConfirmPolicy { yolo: true }, |_sink| {
Arc::new(AutoApproveGate)
})
}
/// 用指定的(可能失效/禁用的)provider 装配,其余同 [`Harness::new`]。
#[must_use]
pub fn from_provider(provider: Arc<MockProvider>) -> Self {
Self::build(provider, ConfirmPolicy::default(), |_sink| {
Arc::new(NoUiGate)
})
}
/// 自定义闸门 + 策略。
#[must_use]
pub fn with_gate(
script: Vec<ChatReply>,
policy: ConfirmPolicy,
make_gate: impl FnOnce(Arc<dyn AgentEventSink>) -> Arc<dyn ConfirmGate>,
) -> Self {
Self::build(Arc::new(MockProvider::new(script)), policy, make_gate)
}
fn build(
provider: Arc<MockProvider>,
policy: ConfirmPolicy,
make_gate: impl FnOnce(Arc<dyn AgentEventSink>) -> Arc<dyn ConfirmGate>,
) -> Self {
let (ports, settings) = mock_ports();
let registry =
Arc::new(unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功"));
let audit = Arc::new(MemoryAudit::new());
let events = Arc::new(MemoryEventSink::new());
let clock: SharedClock = Arc::new(FixedClock::with_step(1_793_314_353_123, 3));
let gate = make_gate(events.clone());
let deps = BrainDeps::new(
audit.clone() as Arc<dyn AuditSink>,
gate,
policy,
clock,
events.clone() as Arc<dyn AgentEventSink>,
);
let brain = Brain::new(
provider.clone(),
Arc::clone(&registry),
deps,
BrainConfig::default(),
);
Self {
brain,
audit,
events,
provider,
settings,
registry,
}
}
}
/// 构造一条工具调用回复。
#[must_use]
pub fn tool_call_reply(id: &str, name: &str, arguments: Value) -> ChatReply {
ChatReply::with_tool_calls(vec![ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments,
}])
}
/// 便捷:游戏会话 id(与 `MockSessions::crashing_session` 一致)。
pub const SESSION: &str = "20261029T124501-9f3c2a";
/// 便捷:agent 对话会话 id。
pub const AGENT_SESSION: &str = "chat-1";
/// 便捷:`list_game_sessions` 的最小参数。
#[must_use]
pub fn list_args() -> Value {
json!({ "limit": 5 })
}
+365
View File
@@ -0,0 +1,365 @@
//! 安全边界集成测试:白名单拦截、分级确认、YOLO、审计标记。
//!
//! 这些是 02 文档 §3.1「安全边界」+ §4b「YOLO」与 M12 `setting-接口约定.md` §8
//! (`Low/Medium/High` 三档 + `writable=false` 凭据)的可执行版本;
//! 判断口径对照 research/02 §7 R9(写操作的破坏半径)。
mod support;
use std::sync::Arc;
use serde_json::{json, Value};
use support::{block_on, tool_call_reply, Harness, AGENT_SESSION};
use unlucky_agent::audit::AuditOutcome;
use unlucky_agent::brain::ChatReply;
use unlucky_agent::confirm::{ConfirmDecision, ConfirmPolicy, ScriptedGate};
use unlucky_agent::event::AgentEvent;
fn set_config_reply(path: &str, value: Value) -> ChatReply {
tool_call_reply("c1", "set_config", json!({ "path": path, "value": value }))
}
fn tool_results(harness: &Harness) -> Vec<String> {
harness
.provider
.requests()
.last()
.map(|request| {
request
.messages
.iter()
.filter_map(|m| match m {
unlucky_agent::brain::ChatMessage::Tool { content, .. } => {
Some(content.clone())
}
_ => None,
})
.collect()
})
.unwrap_or_default()
}
fn confirm_events(harness: &Harness) -> usize {
harness
.events
.events()
.iter()
.filter(|e| matches!(e, AgentEvent::ConfirmRequested { .. }))
.count()
}
#[test]
fn low_key_writes_silently_without_confirmation() {
let harness = Harness::new(vec![
set_config_reply("appearance.theme_mode", json!("metro_light")),
ChatReply::text("已切换主题"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换成浅色"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(
harness.settings.value_of("appearance.theme_mode"),
Some(json!("metro_light"))
);
assert_eq!(confirm_events(&harness), 0, "Low 键不该打扰用户");
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1);
assert_eq!(records[0].outcome, AuditOutcome::Ok);
assert!(!records[0].confirmed, "静默生效不是「用户确认」");
assert!(!records[0].yolo);
}
#[test]
fn medium_key_also_writes_silently() {
// M12:Medium = 静默生效但审计标记(不弹窗)
let harness = Harness::new(vec![
set_config_reply("download.region", json!("cn")),
ChatReply::text("已切到国内源"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换下载源"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(confirm_events(&harness), 0);
assert_eq!(
harness.audit.records_for("set_config")[0].outcome,
AuditOutcome::Ok
);
}
#[test]
fn high_key_is_fail_closed_without_ui() {
let harness = Harness::new(vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("需要你确认"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(
harness.settings.set_calls(),
0,
"P0 无 UI 时必须 fail-closed"
);
assert_eq!(harness.settings.value_of("java.selected_path"), None);
assert_eq!(confirm_events(&harness), 1, "确认请求必须作为事件发出");
let outcomes: Vec<AuditOutcome> = harness
.audit
.records_for("set_config")
.iter()
.map(|r| r.outcome)
.collect();
assert_eq!(
outcomes,
vec![AuditOutcome::AwaitingConfirmation, AuditOutcome::Denied],
"先记「AI 想改」,再记「被拒绝」"
);
assert!(tool_results(&harness)
.iter()
.any(|content| content.contains("denied")));
assert!(harness
.events
.events()
.iter()
.any(|e| matches!(e, AgentEvent::ToolDenied { .. })));
}
#[test]
fn user_approval_writes_and_marks_confirmed() {
let harness = Harness::with_gate(
vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("已更新"),
],
ConfirmPolicy::default(),
|_sink| {
Arc::new(ScriptedGate::new(
[ConfirmDecision::Approved],
ConfirmDecision::Denied,
))
},
);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1, "确认后只写一次");
let records = harness.audit.records_for("set_config");
let last = records.last().expect("必须有审计记录");
assert_eq!(last.outcome, AuditOutcome::Ok);
assert!(last.confirmed, "用户点了允许");
assert!(!last.yolo);
}
#[test]
fn yolo_skips_confirm_but_audits_flag() {
let harness = Harness::yolo(vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("已更新"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(confirm_events(&harness), 0, "YOLO 不打扰用户");
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1, "YOLO 只留结局记录");
assert!(records[0].yolo, "审计必须标记 yolo");
assert!(!records[0].confirmed, "自动放行不是用户确认");
assert_eq!(records[0].outcome, AuditOutcome::Ok);
}
#[test]
fn whitelist_violation_is_rejected_and_audited() {
let harness = Harness::new(vec![
set_config_reply("java.home.mystery", json!("x")),
ChatReply::text("换个方案"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "改个不存在的键"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1);
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("白名单")),
"模型需要知道为什么被拒:{results:?}"
);
}
#[test]
fn invalid_args_are_rejected_before_any_side_effect() {
// 缺 value(schema required)
let harness = Harness::new(vec![
tool_call_reply(
"c1",
"set_config",
json!({ "path": "appearance.theme_mode" }),
),
ChatReply::text("参数补上"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "改主题"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
assert!(tool_results(&harness)
.iter()
.any(|c| c.contains("缺少必填参数")));
// 多余参数(additionalProperties: false)
let harness = Harness::new(vec![
tool_call_reply(
"c2",
"read_game_log",
json!({ "session_id": "x", "path": "evil" }),
),
ChatReply::text("好"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "读日志"),
)
.unwrap();
let records = harness.audit.records_for("read_game_log");
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
}
#[test]
fn unknown_tool_lists_available_names() {
let harness = Harness::new(vec![
tool_call_reply("c1", "delete_everything", json!({})),
ChatReply::text("抱歉"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "删库跑路"),
)
.unwrap();
let records = harness.audit.records_for("delete_everything");
assert_eq!(records[0].outcome, AuditOutcome::UnknownTool);
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("get_config")),
"{results:?}"
);
}
#[test]
fn locked_credential_key_is_refused_even_under_yolo() {
// M12:ai.api_key 是 writable=false(不在白名单)→ YOLO 也不能放宽可写范围
let harness = Harness::yolo(vec![
set_config_reply("ai.api_key", json!("sk-super-secret-value")),
ChatReply::text("换不了"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 key"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
let args = serde_json::to_string(&records[0].args).unwrap();
assert!(
!args.contains("sk-super-secret-value"),
"审计绝不能落明文密钥:{args}"
);
assert!(args.contains("***"), "{args}");
}
#[test]
fn get_config_refuses_credential_keys() {
// F1:模型可自由给 path,凭据键必须读不到 —— 否则值会进模型上下文发往远端 LLM。
let harness = Harness::new(vec![
tool_call_reply("c1", "get_config", json!({ "path": "ai.api_key" })),
ChatReply::text("读不到"),
]);
// 模拟 M12 真实实现:凭据键在文件里确实有值(writable=false,不在白名单也要能防住读)
harness
.settings
.seed_value("ai.api_key", json!("sk-live-credential-value"));
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "看看我的 key"),
)
.unwrap();
// 1) 工具报错并被审计
let records = harness.audit.records_for("get_config");
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
// 2) 模型拿到的是拒绝文案,不是值
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("凭据")),
"应给出可读拒绝:{results:?}"
);
assert!(
!results
.iter()
.any(|c| c.contains("sk-live-credential-value")),
"工具结果绝不能带凭据:{results:?}"
);
// 3) 事件流与对话历史里也不能出现(P1 事件会落盘 / 历史会持久化)
let leaked_in_events = harness
.events
.events()
.iter()
.any(|e| format!("{e:?}").contains("sk-live-credential-value"));
assert!(!leaked_in_events, "事件流不能带凭据明文");
let leaked_in_history = history
.iter()
.any(|m| format!("{m:?}").contains("sk-live-credential-value"));
assert!(!leaked_in_history, "对话历史不能带凭据明文");
}
+108
View File
@@ -0,0 +1,108 @@
[package]
name = "unlucky-app-iced"
description = "Unlucky-Launcher 桌面壳(iced 0.14 重写版):M3 Expressive 骨架 / 令牌适配 / 截图通道"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 并存期(D26):Slint 版仍是 `crates/app` 的 `unlucky-launcher`,本 crate 出
# `unlucky-launcher-iced`。两个 package 在同一 target 目录产出同名 bin 会冲突,
# 所以**翻牌前不许改回 `unlucky-launcher`**(步骤见 SubAgentsTalk/app-iced-骨架与翻牌.md)。
[[bin]]
name = "unlucky-launcher-iced"
path = "src/main.rs"
[lib]
name = "unlucky_app_iced"
path = "src/lib.rs"
[dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.4" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.4" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", version = "0.2.4" }
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
# 这里给 `thread-pool`(iced 自己的默认执行器,不额外引 tokio 运行时)。
# `wgpu` 由本 crate 的 `gpu` feature 打开,于是「仅 CPU」体积基线用 `--no-default-features` 就能量到。
iced = { version = "=0.14.0", default-features = false, features = [
"tiny-skia",
"crisp",
"advanced",
# M93:背景图要真图直出 ⇒ 需要渲染器的 image 支持。用 `image-without-codecs` 而不是
# `image` 是刻意的:后者会打开 `image/default` 的全套编解码器;前者的编解码能力由本 crate
# 依赖表里那条 `image` 的 features 决定(png / jpeg / bmp),不必把 webp/tiff/exr/gif 拖进来。
"image-without-codecs",
"svg",
"thread-pool",
] }
# 截图落 PNG + 背景图解码(M93):png 是截图通道,jpeg/bmp 是用户真会挑的背景图/壁纸格式
# (M73 的壁纸探测不筛扩展名)。仍不开 default-features——那会拖进 avif/ravif/webp/tiff 一整套。
image = { version = "0.25", default-features = false, features = [
"png",
"jpeg",
"bmp",
] }
# M82:同一个二进制也带 CLI 子命令(`unlucky-launcher-iced instances list` …),
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
unlucky-cli = { path = "../cli", version = "0.2.4" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
unlucky-i18n = { path = "../i18n", version = "0.2.4" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
# `wgpu*` 的 logger(见 `src/renderer/log_bridge.rs`),把这行捞出来。
# 已是 iced 传递依赖树里的版本(0.4.34),不新增任何传递依赖;MIT OR Apache-2.0,许可白名单内。
log = { version = "0.4", features = ["std"] }
# X11 / Wayland 后端与 Linux 主题检测只在 Linux 上启用:macOS 没有 X11/Wayland(走 Metal +
# 系统外观),Windows 走 DX12,这三个 feature 在那边没有意义。同一个包在两处写 features 是
# **取并集**(Cargo 的 feature 解析是加法),所以 Linux 上拿到的仍是上面 5 个 + 这 3 个,
# Windows / macOS 只拿上面那 5 个(`cargo tree --target` 可复核)。
# 诚实边界:这**不**解决「iced 版至今没在 macOS 上编过」这件事——`iced_winit` 已把 `mundy`
# 放在 `cfg(target_os = "linux")` 段里,这三个 feature 在非 Linux 上本来就不拉依赖,这里只是
# 让本仓库的依赖图按平台自述清楚,不再把 Linux 专属能力写成全平台事实。
[target.'cfg(target_os = "linux")'.dependencies]
iced = { version = "=0.14.0", default-features = false, features = [
"x11",
"wayland",
"linux-theme-detection",
] }
[features]
default = ["gpu"]
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
gpu = ["iced/wgpu"]
# M169:把「本地更新源」这条测试通道编进 GUI(默认关)。人工验收更新链路时的构建方式:
# ULL_UPDATE_DIR=<root> ULL_UPDATE_TEST_SOURCE=1 \
# cargo run -p unlucky-app-iced --features update-test-source
# ⚠ `-p` 只认 **package** 名(`unlucky-app-iced`),`unlucky-launcher-iced` 是它在 [[bin]] 里
# 声明的 bin 名——写成 `-p unlucky-launcher-iced` 会 `package(s) … not found`(r1 复审 P2-1)。
# 转发给 presenter 与内嵌的 cli(两者都依赖 core,feature 取并集);官方发布构建不带它。
update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/update-test-source"]
[lints]
workspace = true
# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。
# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回),
# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。
[build-dependencies]
winresource = "0.1"
+14
View File
@@ -0,0 +1,14 @@
//! `unlucky-launcher-iced` 的 PE 版本资源(M190)。
//!
//! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的
//! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。
//!
//! ⚠ 本文件**只**写版本资源,不碰子系统:PE 的 `Subsystem` 由 rustc 的
//! `windows_subsystem` 属性决定(本分支上还没有那个属性,是 M196 的事),版本资源与子系统是两件事。
//! 判据:`packaging/check_pe_version.py` 会把它读到的 `subsystem` 打出来,加资源前后必须一致。
include!("../../packaging/pe_version.rs");
fn main() {
emit_pe_version_resource("unlucky-launcher-iced");
}
+37
View File
@@ -0,0 +1,37 @@
//! 页面骨架截图入口(M84 `PAGEFRAME` 的验收通道)。
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- \
//! --screen=instances --width=1180 --height=760 \
//! --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 参数解析与 iced 接线都在 `unlucky_app_iced::pages::harness` 里:**本 example 只是入口**,
//! 它也刻意不接 `cli.rs`(那边归 M75)。详细口径见 `pages::harness` 的模块文档。
use std::process::ExitCode;
use unlucky_app_iced::pages::harness::{self, Request};
fn main() -> ExitCode {
let request = match Request::parse(std::env::args().skip(1)) {
Ok(request) => request,
Err(message) => {
eprintln!("[cli] {message}");
eprintln!(
"用法:pages_demo [--screen=instances|download|settings|logs] [--width <px>] \
[--height <px>] [--theme=light|dark] [--screenshot-theme=light|dark] \
[--screenshot <png>] [--renderer=auto|gpu|cpu] [--software-renderer]"
);
return ExitCode::from(2);
}
};
match harness::run(request) {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("[run] {message}");
ExitCode::FAILURE
}
}
}
+261
View File
@@ -0,0 +1,261 @@
//! 「导入已有版本」向导(M223 GUIADOPT):实例页页头的入口 + 一张盖在页面上的向导卡。
//!
//! ## 四段(与 presenter 的 `AdoptState` 一一对应)
//!
//! | 段 | 画什么 | 从哪来 | 文件 |
//! |---|---|---|---|
//! | Pick | 候选版本 + 目标档 + 实例名 | `AdoptEvent::Scan` | [`pick`] |
//! | Preview | 落点 / 隔离档 / **`target_notes()` 原文** | `AdoptEvent::Planned` | [`panes`] |
//! | Failed | 稳定码 + 人读原因 + **`problems[]` 逐条** | `AdoptEvent::Failed` | [`panes`] |
//! | Done | 实例名 / 运行目录 / 提示原文 | `AdoptEvent::Done` | [`panes`] |
//!
//! 「现在画哪一段」的判据只有一处:presenter 的 `AdoptState::stage`,外面套一条收窄
//! [`Form::stage`]——`Preview` 只在用户**点过预检**(`Form::checked`)之后才算数。收窄的理由:
//! 共享档 / 版本目录档一被点选就要显示后果原文(§5.2),那要用一次**零写盘**的预检换;
//! 但那次结果不该把用户直接甩到确认页(实例名还没填完)。
//!
//! 卡壳与页脚在 [`shell`](定高可滚 / 收高两种几何),三段视图在 [`pick`] 与 [`panes`],
//! 等宽与正文小工具在 `text`,D30 出图种子在 `shot`(`ULL_ADOPT_STATE`,只在 `--screenshot` 下生效)。
//!
//! ## 三条纪律(契约 §5 的 1/2/3)
//!
//! 1. **运行目录不派生**:Preview / Done 上那个路径**原样**来自回程事件(缺省档可能带 `-2` 后缀,
//! 只有 core 知道最后用了哪个)。本模块没有任何「拼一个目录名」的代码。
//! 2. **后果话术不重写**:共享档 / 版本目录档的后果句在 Preview 段**逐条**画 `plan.notes`
//! (core `target_notes()` 的原文)。Pick 段只放一句短提示(选之前就得知道会碰别人的目录),
//! 真正的后果原文在确认按钮之前一定会出现。
//! 3. **不折叠问题**:Failed 段把 `problems` 一条一行画出来,标题只说「导入失败」,原因全在下面。
//!
//! ## 为什么是「盖一层」而不是实例页里的一段
//!
//! 向导有四段状态、每段内容都不一样,塞进 list-detail 的正文列会把版式判据(424 散文宽、
//! 列表独立滚动)搅乱。盖一层与 M187 的模态同一手法(遮罩 + 居中卡),但**不是**
//! `DialogQueue` 那种「标题 + 正文 + 两个按钮」的窄形态:向导要画候选列表、单选档位与输入框。
//!
//! ## 表单字段归 `App`,事件真相归 presenter
//!
//! [`Form`] 只装「用户正在填的东西」(选中哪个版本、展示名、目标档、有没有在等回程);
//! 候选、预检、失败、完成全部来自 `AppState.adopt`(presenter 的 `AdoptState`)。两边各管一半,
//! 于是「画出来的落点」与「真正写盘的落点」永远是同一份事实。
mod panes;
mod pick;
mod shell;
/// D30 出图用的状态夹具(只在 `--screenshot` 下按环境变量生效)。
pub(crate) mod shot;
mod text;
#[cfg(test)]
mod tests;
use iced::widget::{container, mouse_area, opaque, Space};
use iced::{Alignment, Background, Element, Length};
use unlucky_design::spacing::Space as Sp;
use unlucky_i18n::{Catalog, Lang};
use unlucky_presenter::dto::{
AdoptCandidateView, AdoptPlanView, AdoptStage, AdoptState, AdoptTargetView,
};
use crate::app::components::feedback::dialog::scrim;
use crate::theme::Ui;
use super::message::Message;
/// 向导卡宽度:比模态卡(560)宽一档——候选行要放「版本 id + 状态」,目标档是三枚 chip。
const CARD_WIDTH: f32 = 640.0;
/// 卡片的最大高(窗口更矮时整卡按窗口走:内容在中间区滚)。
const CARD_MAX_HEIGHT: f32 = 620.0;
/// 正在等哪一条命令的回程。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Busy {
/// 空闲(可以点)
#[default]
Idle,
/// 正在扫描 `versions/`
Scanning,
/// 正在预检(零写盘)
Checking,
/// 正在真正登记
Importing,
}
impl Busy {
/// 有没有在等回程。
#[must_use]
pub const fn waiting(self) -> bool {
!matches!(self, Self::Idle)
}
}
/// 向导的表单态(**归 `App`**,页面借一帧)。
#[derive(Debug, Default)]
pub struct Form {
/// 面板开着吗
pub open: bool,
/// 选中的版本 id(`None` = 还没选)
pub selected: Option<String>,
/// 展示名(空 = 用版本 id)
pub name: String,
/// 运行目录档(缺省 [`AdoptTargetView::InstanceDir`],**不是** isolated)
pub target: AdoptTargetView,
/// 在等在飞的那一条命令
pub busy: Busy,
/// 用户已经点过「预检」(没点过就留在 Pick 段,哪怕后台已经顺手算过一次落点)。
///
/// 为什么要有这一位:共享档 / 版本目录档一旦被点选,界面就要立刻显示 `target_notes()` 的
/// **后果原文**(契约 §5.2)——那是 core 唯一会产出这段文字的地方,所以点选时顺手发一次
/// **零写盘**的预检。但那一次结果不该把用户甩到「可以导入」的确认页:确认必须由显式的
/// `Check` 触发。于是 `checked` 记住「这次预检是不是用户要的」。
pub checked: bool,
}
impl Form {
/// 打开面板(回到 Pick 段、清掉上一轮的选中与名字)。
pub fn open(&mut self) {
*self = Self {
open: true,
busy: Busy::Scanning,
..Self::default()
};
}
/// 关掉面板(全清)。
pub fn close(&mut self) {
*self = Self::default();
}
/// 回程到了:不在等任何东西了。
pub fn settled(&mut self) {
self.busy = Busy::Idle;
}
/// 当前该画哪一段:`Preview` 只在用户**点过预检**之后才算数,其余按 `AdoptState::stage()`。
///
/// 这条收窄是为了「点选共享档 ⇒ 立刻看到后果原文」而不把用户直接甩到确认页:
/// 自动预检的结果落在 Pick 段里当**提示**读,用户点「预检」之后才进确认段。
#[must_use]
pub fn stage(&self, adopt_state: &AdoptState) -> AdoptStage {
match adopt_state.stage() {
AdoptStage::Preview if !self.checked => AdoptStage::Pick,
other => other,
}
}
/// 后台算出来、且**对得上当前选择**的预检(对不上 = 过期的结果,一律不画)。
///
/// 两个判据缺一不可:
/// - **目标档一致**:改了档之后旧 plan 的 `notes` 说的是别的目录;
/// - **版本 id 一致**:换了版本之后旧 plan 的 `notes` 说的是别的版本目录
/// (版本目录档的后果原文里带 `versions/<id>`,拿它当新版本的后果就是撒谎)。
///
/// 没有选中版本时也返回 `None`:那一刻没有任何版本可言,画谁的预检都不成立。
/// 这两条都不是「选中即重扫」带来的——`Select` 只改本地字段、不发命令(落点见
/// `app/update/adopt.rs`),所以过期判定只能在这里做。
#[must_use]
pub fn fresh_plan<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptPlanView> {
let selected = self.selected.as_deref()?;
adopt_state
.plan
.as_ref()
.filter(|plan| plan.target == self.target && plan.version_id == selected)
}
/// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。
#[must_use]
pub fn candidate<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptCandidateView> {
let id = self.selected.as_deref()?;
adopt_state
.scan
.as_ref()?
.candidates
.iter()
.find(|candidate| candidate.version_id == id)
}
}
/// 向导消息(`crate::app::Message::Adopt` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdoptMessage {
/// 打开向导(实例页页头「导入」)
Open,
/// 关掉(点遮罩 / 关闭按钮)
Close,
/// 选中一个候选版本
Select(String),
/// 改展示名
Name(String),
/// 改运行目录档
Target(AdoptTargetView),
/// 预检(零写盘)
Check,
/// 确认登记(唯一写盘入口)
Confirm,
/// 从 Preview / Failed 退回候选列表(等价于重新扫一次)
Back,
}
/// 向导消息 → app 消息。
fn adopt(message: AdoptMessage) -> Message {
Message::Adopt(message)
}
// --------------------------------------------------------------- 视图
/// 把向导叠在页面之上(没打开时原样返回页面树)。
pub(super) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
form: &'a Form,
adopt_state: &'a AdoptState,
lang: Lang,
) -> Element<'a, Message> {
if !form.open {
return base;
}
let catalog = lang.catalog();
let backdrop = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(scrim(ui))),
..container::Style::default()
}),
)
.on_press(adopt(AdoptMessage::Close));
// 透明的一层只负责居中;吃点击的 `opaque` 只包卡片(点卡内不关,点遮罩才关)。
let centered = container(opaque(shell::card(ui, form, adopt_state, catalog)))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, backdrop, centered].into()
}
/// 中间区(按段交给 `pick` / `panes`)。
pub(super) fn middle<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
stage: AdoptStage,
catalog: Catalog,
) -> Element<'a, Message> {
match stage {
AdoptStage::Pick => pick::pick(
ui,
form,
adopt_state.scan.as_ref(),
form.fresh_plan(adopt_state),
catalog,
),
AdoptStage::Preview => panes::preview(ui, form.fresh_plan(adopt_state), catalog),
AdoptStage::Failed => panes::failed(ui, adopt_state.failure.as_ref(), catalog),
AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog),
}
}
+127
View File
@@ -0,0 +1,127 @@
//! Preview / Failed / Done 三段。
//!
//! | 段 | 画什么 | 判据 |
//! |---|---|---|
//! | Preview | 生效运行目录(**回程给的**)+ 隔离档 + `notes` 原文逐条 | `AdoptEvent::Planned` |
//! | Failed | 稳定码 + `problems[]` 逐条 | `AdoptEvent::Failed` |
//! | Done | 实例名 + 运行目录 + `notes` 原文逐条 | `AdoptEvent::Done` |
//!
//! 三段都不自己算路径、不自己写后果句:`run_dir` 与 `notes` 是 core 的返回值原样。
//! 缺省档会带 `-2`/`-3` 后缀(两个不同展示名可能落到同一个物理目录),**只有 core 知道**
//! 最后用了哪个——页面对它再做一次派生就会与真正启动时用的目录分叉。
use iced::widget::column;
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space as Sp;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptDoneView, AdoptFailureView, AdoptPlanView};
use crate::theme::Ui;
use super::super::message::Message;
use super::text::{field_line, isolation_label, mono, problem_line, wrapped};
/// 预检结果:落点 + 隔离档 + 后果原文。
pub(super) fn preview<'a>(
ui: &'a Ui,
plan: Option<&'a AdoptPlanView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(plan) = plan else {
return wrapped(
ui,
catalog.text(Key::AdoptScanning).to_string(),
ui.color(Role::OnSurfaceVariant),
);
};
let mut body = column![
field_line(ui, catalog.text(Key::AdoptInstanceName), &plan.name),
field_line(
ui,
catalog.text(Key::AdoptPlanRunDir),
&plan.run_dir.display().to_string()
),
field_line(
ui,
catalog.text(Key::AdoptPlanIsolation),
&isolation_label(plan.isolation, catalog),
),
]
.spacing(ui.space(Sp::X2))
.width(Length::Fill);
// 后果话术:core `target_notes()` 的原文,一条一行、一个字不改
for note in &plan.notes {
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
}
body.into()
}
/// 失败:稳定码 + **problems 逐条**。
pub(super) fn failed<'a>(
ui: &'a Ui,
failure: Option<&'a AdoptFailureView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(failure) = failure else {
return wrapped(
ui,
catalog.text(Key::AdoptScanning).to_string(),
ui.color(Role::OnSurfaceVariant),
);
};
let mut body = column![mono(ui, failure.code.clone())]
.spacing(ui.space(Sp::X2))
.width(Length::Fill);
if failure.problems.is_empty() {
body = body.push(wrapped(
ui,
failure.message.clone(),
ui.color(Role::OnSurfaceVariant),
));
return body.into();
}
body = body.push(wrapped(
ui,
catalog.text(Key::AdoptProblemCount).to_string(),
ui.color(Role::OnSurfaceVariant),
));
for problem in &failure.problems {
body = body.push(problem_line(ui, problem));
}
body.into()
}
/// 完成:实例名 + 运行目录 + 提示原文。
pub(super) fn done<'a>(
ui: &'a Ui,
done: Option<&'a AdoptDoneView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(done) = done else {
return wrapped(ui, String::new(), ui.color(Role::OnSurfaceVariant));
};
let mut body = column![
field_line(ui, catalog.text(Key::AdoptInstanceName), &done.name),
field_line(
ui,
catalog.text(Key::AdoptPlanRunDir),
&done.run_dir.display().to_string()
),
field_line(
ui,
catalog.text(Key::AdoptPlanIsolation),
&isolation_label(done.isolation, catalog),
),
]
.spacing(ui.space(Sp::X2))
.width(Length::Fill);
for note in &done.notes {
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
}
body.into()
}
+228
View File
@@ -0,0 +1,228 @@
//! Pick 段:候选列表 + 目标档 + 实例名。
//!
//! **可导入的候选才可点**:不可导入的行把 `problems` 逐条列在下面但不接点击——点它只会换来
//! 一条注定被 core 拒绝的命令(`adopt_not_adoptable`),而用户从行上已经看到了原因。
//!
//! 目标档三枚 chip 的**短提示**在选中时立刻换(共享/版本目录档用 `Error` 色);
//! **后果原文**不在这里——它在 Preview 段逐条画 `plan.notes`(core `target_notes()` 的原文)。
use iced::widget::{button, column, container, row};
use iced::{Alignment, Background, Border, Color, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptCandidateView, AdoptPlanView, AdoptScanView, AdoptTargetView};
use crate::app::components::inputs::{text_field, Field};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::text::{label, problem_line, target_label, wrapped};
use super::{adopt, AdoptMessage, Form};
/// Pick 段整体。
pub(super) fn pick<'a>(
ui: &'a Ui,
form: &'a Form,
scan: Option<&'a AdoptScanView>,
plan: Option<&'a AdoptPlanView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(scan) = scan else {
// 扫描还没回来:一行进度文案(不做假进度条)
return wrapped(
ui,
catalog.text(Key::AdoptScanning).to_string(),
ui.color(Role::OnSurfaceVariant),
);
};
let mut body = column![].spacing(ui.space(Sp::X3)).width(Length::Fill);
if scan.is_empty() {
let dir = scan.versions_dir.display().to_string();
body = body.push(wrapped(
ui,
catalog.render(Key::AdoptEmpty, &[("dir", &dir)]),
ui.color(Role::OnSurfaceVariant),
));
} else {
body = body.push(label(ui, catalog.text(Key::AdoptSelectLabel)));
body = body.push(candidate_list(ui, form, scan, catalog));
}
body = body.push(frame::divider(ui).map(Message::Page));
body = body.push(label(ui, catalog.text(Key::AdoptTargetLabel)));
body = body.push(target_row(ui, form, catalog));
// 后果原文(契约 §5.2):共享档 / 版本目录档一被点选就显示 core `target_notes()` 的原文——
// 这一条来自**零写盘**的自动预检(见 `AdoptMessage::Target` 的落点),不是界面另写的一句。
if let Some(plan) = plan {
for note in &plan.notes {
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
}
}
body = body.push(text_field(
ui,
Field::new(form.name.as_str()).label(catalog.text(Key::AdoptNameLabel)),
|value| adopt(AdoptMessage::Name(value)),
));
body.into()
}
/// 候选列表(可滚,定高:列表再长页脚按钮也留在卡里)。
fn candidate_list<'a>(
ui: &'a Ui,
form: &'a Form,
scan: &'a AdoptScanView,
catalog: Catalog,
) -> Element<'a, Message> {
let rows = column(
scan.candidates
.iter()
.map(|candidate| candidate_row(ui, form, candidate, catalog))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Sp::X1))
.width(Length::Fill);
rows.into()
}
/// 一条候选。
fn candidate_row<'a>(
ui: &'a Ui,
form: &'a Form,
candidate: &'a AdoptCandidateView,
catalog: Catalog,
) -> Element<'a, Message> {
let selected = form.selected.as_deref() == Some(candidate.version_id.as_str());
let mut items = column![row![
frame::text_role(
ui,
TypeRole::BodyLarge,
selected,
candidate.version_id.as_str(),
ui.color(Role::OnSurface),
)
.map(Message::Page),
container(status(ui, candidate, selected, catalog))
.width(Length::Fill)
.align_x(Alignment::End),
]
.width(Length::Fill)
.align_y(Alignment::Center)]
.spacing(ui.space(Sp::X1))
.width(Length::Fill);
// 问题逐条(不折叠):候选阶段就把「为什么不能导入」说清楚
for problem in &candidate.problems {
items = items.push(problem_line(ui, problem));
}
let body = container(items)
.width(Length::Fill)
.padding(ui.space(Sp::X2))
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Medium),
..Border::default()
},
..container::Style::default()
});
if !candidate.adoptable() {
// 不可导入的行**不可点**(也就不存在「点了才被告知不行」这条路)
return body.into();
}
button(body)
.width(Length::Fill)
.padding(0)
.style(|_theme, _status| button::Style {
background: None,
text_color: Color::TRANSPARENT,
border: Border::default(),
shadow: iced::Shadow::default(),
snap: true,
})
.on_press(adopt(AdoptMessage::Select(candidate.version_id.clone())))
.into()
}
/// 候选右侧那枚状态(可导入 / N 个问题 / 已被某实例使用)。
fn status<'a>(
ui: &'a Ui,
candidate: &AdoptCandidateView,
selected: bool,
catalog: Catalog,
) -> Element<'a, Message> {
if !candidate.adoptable() {
let count = candidate.problems.len().to_string();
return wrapped(
ui,
catalog.render(Key::AdoptBlocked, &[("count", &count)]),
ui.color(Role::Error),
);
}
if let Some(name) = candidate.registered.first() {
return wrapped(
ui,
catalog.render(Key::AdoptRegistered, &[("name", name)]),
ui.color(Role::OnSurfaceVariant),
);
}
if selected {
return chip(ui, Chip::current(catalog.text(Key::AdoptReady)));
}
wrapped(
ui,
catalog.text(Key::AdoptReady).to_string(),
ui.color(Role::OnSurfaceVariant),
)
}
/// 三枚目标 chip(缺省档在最前;共享 / 版本目录档必须**明确点选**)。
fn target_row<'a>(ui: &'a Ui, form: &'a Form, catalog: Catalog) -> Element<'a, Message> {
let chips = AdoptTargetView::ALL
.into_iter()
.map(|target| {
chip(
ui,
Chip::filter(
target_label(target, catalog),
form.target == target,
adopt(AdoptMessage::Target(target)),
),
)
})
.collect::<Vec<_>>();
column![
row(chips).spacing(ui.space(Sp::X2)).width(Length::Fill),
target_hint(ui, form.target, catalog),
]
.spacing(ui.space(Sp::X1))
.width(Length::Fill)
.into()
}
/// 目标档的一句话说明(点选后立刻换)。**不是后果原文**:原文在 Preview 段逐条画
/// `plan.notes`(core `target_notes()`)。
fn target_hint<'a>(ui: &'a Ui, target: AdoptTargetView, catalog: Catalog) -> Element<'a, Message> {
let key = match target {
AdoptTargetView::InstanceDir => Key::AdoptTargetInstanceDirHint,
AdoptTargetView::Shared => Key::AdoptTargetSharedHint,
AdoptTargetView::VersionDir => Key::AdoptTargetVersionDirHint,
};
let color = if target.touches_foreign_dir() {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
};
wrapped(ui, catalog.text(key).to_string(), color)
}
+198
View File
@@ -0,0 +1,198 @@
//! 向导卡的外壳与页脚(M223 从 `app/adopt.rs` 搬出,守 400 行硬线)。
//!
//! | 函数 | 职责 |
//! |---|---|
//! | [`card`] | 卡壳(标题 + 说明 + 中间区 + 页脚)、定高/收高两种几何、遮罩上的那一张卡 |
//! | [`title_text`] / [`subtitle`] | 四段的标题与一句说明(文案全部来自 i18n 目录) |
//! | [`footer`] | 按段给按钮组合;在等回程时主按钮禁用 |
//!
//! ## 这个文件不是「纯搬移」
//!
//! 它**起于**一次搬移:`app/adopt.rs` 落笔 412 行越过 `components::gate` 的 400 行硬线,
//! 于是把卡壳 / 标题 / 页脚搬到这里。但**同一个提交里**这三块还改过行为与几何,逐条如下
//! (复审对 diff 时按这几条看,别按「一行未改」看):
//!
//! | 改了什么 | 为什么 |
//! |---|---|
//! | `card` 拆成「定高 + 中间区可滚」(Pick / Failed)与「按内容收高」(Preview / Done)两种几何 | 原来只给 `max_height`,内容一高就被裁:页脚按钮与实例名整排不可见(出图时抓到) |
//! | `footer` 的「关闭」只在 Pick / Preview 出现 | Failed / Done 的主按钮就是关闭,再给一枚文字关闭就是两枚一模一样的按钮 |
//! | `subtitle` 的 Preview 文案改成「版本名 · 目标档」 | 原来打「隔离档 · 目标档」,同屏两个「共用目录」 |
//!
//! 调用方仍是 `app/adopt.rs` 的 `layer` 与 `middle`。
use iced::widget::{column, container, row, scrollable, Space};
use iced::{Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptStage, AdoptState};
use crate::app::components::buttons::{self, Button};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::text::{target_label, wrapped};
use super::{adopt, middle, AdoptMessage, Form, CARD_MAX_HEIGHT, CARD_WIDTH};
/// 卡片本体:标题 + 一句说明 + 中间(按段) + 页脚按钮。
pub(super) fn card<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
catalog: Catalog,
) -> Element<'a, Message> {
let stage = form.stage(adopt_state);
let middle = middle(ui, form, adopt_state, stage, catalog);
let head = column![
frame::text_role(
ui,
TypeRole::HeadlineSmall,
true,
title_text(stage, catalog),
ui.color(Role::OnSurface),
)
.map(Message::Page),
wrapped(
ui,
subtitle(stage, adopt_state, catalog),
ui.color(Role::OnSurfaceVariant)
),
frame::divider(ui).map(Message::Page),
]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
let tail = column![
frame::divider(ui).map(Message::Page),
footer(ui, form, adopt_state, stage, catalog),
]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
// 列表段(Pick / Failed)**定高 + 中间区可滚**:内容再长,页脚按钮也不会被裁掉
// (第一版就是被裁掉的)。短内容段(Preview / Done)按内容高收,不留一大片空白。
let list_stage = matches!(stage, AdoptStage::Pick | AdoptStage::Failed);
let card = if list_stage {
let body = column![
head,
scrollable(middle)
.height(Length::Fill)
.style(frame::scroll_style(ui)),
tail,
]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
container(body)
.width(Length::Fixed(CARD_WIDTH))
.height(Length::Fixed(CARD_MAX_HEIGHT))
} else {
let body = column![head, middle, tail]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
container(body)
.width(Length::Fixed(CARD_WIDTH))
.max_height(CARD_MAX_HEIGHT)
};
card.padding(ui.space(Sp::X6))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: ui.outlined(ShapeScale::ExtraLarge),
..container::Style::default()
})
.into()
}
/// 段标题(四段各一句)。
pub(super) fn title_text(stage: AdoptStage, catalog: Catalog) -> &'static str {
match stage {
AdoptStage::Pick => catalog.text(Key::AdoptTitle),
AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle),
AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle),
AdoptStage::Done => catalog.text(Key::AdoptDoneTitle),
}
}
/// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。
pub(super) fn subtitle(stage: AdoptStage, adopt_state: &AdoptState, catalog: Catalog) -> String {
match stage {
AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(),
// 预检:版本名 · 目标档(隔离档在正文里单独一行,重复一遍只会更吵)
AdoptStage::Preview => adopt_state
.plan
.as_ref()
.map(|plan| format!("{} · {}", plan.name, target_label(plan.target, catalog)))
.unwrap_or_default(),
AdoptStage::Failed => adopt_state
.failure
.as_ref()
.map(|failure| failure.message.clone())
.unwrap_or_default(),
AdoptStage::Done => catalog.text(Key::AdoptDoneHint).to_string(),
}
}
/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。
pub(super) fn footer<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
stage: AdoptStage,
catalog: Catalog,
) -> Element<'a, Message> {
let busy = form.busy.waiting();
let primary: Element<'a, Message> = match stage {
AdoptStage::Pick => {
let ready = form.candidate(adopt_state).is_some_and(|c| c.adoptable()) && !busy;
if ready {
buttons::filled(
ui,
Button::new(catalog.text(Key::AdoptPreview), adopt(AdoptMessage::Check)),
)
} else {
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptPreview)))
}
}
AdoptStage::Preview if !busy => buttons::filled(
ui,
Button::new(
catalog.text(Key::AdoptConfirm),
adopt(AdoptMessage::Confirm),
),
),
AdoptStage::Preview => {
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptConfirm)))
}
// 失败 / 完成:主按钮就是关掉(Done 不用「去列表看」——实例已经在列表里了)
AdoptStage::Failed | AdoptStage::Done => buttons::filled(
ui,
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
),
};
let mut actions = row![].spacing(ui.space(Sp::X2)).width(Length::Fill);
// Preview / Failed 给一条「退回候选列表」(= 重新扫一次,代价只有一次读目录)
if matches!(stage, AdoptStage::Preview | AdoptStage::Failed) {
actions = actions.push(buttons::text(
ui,
Button::new(catalog.text(Key::AdoptBack), adopt(AdoptMessage::Back)),
));
}
actions = actions.push(container(Space::new()).width(Length::Fill));
// 失败/完成段的主按钮就是「关闭」,再给一枚文字「关闭」就是两枚一模一样的按钮
if matches!(stage, AdoptStage::Pick | AdoptStage::Preview) {
actions = actions.push(buttons::text(
ui,
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
));
}
actions = actions.push(primary);
column![actions].width(Length::Fill).into()
}
+205
View File
@@ -0,0 +1,205 @@
//! 出图用的向导状态夹具(D30 视觉验收的输入)。
//!
//! ## 为什么要有它
//!
//! 向导是**四段有状态**的界面,而截图通道只跑一次 `view()`(第 3 帧落盘),没有真人点按钮。
//! 组件库的做法是 `--screen=gallery` + `ForcedState`,页面层的做法是 `pages/instances/demo.rs`
//! 的 `DEMO`——同一类要求,同一类做法:**给渲染器一份确定的状态**,而不是让截图去赌异步时序。
//!
//! ## 哪些是真、哪些是夹具(复审请照这条核对)
//!
//! | 内容 | 来源 |
//! |---|---|
//! | 候选 / 问题 / 失败原因 | **夹具**(合成的 id 与问题文本,只为把版面撑到真实形态) |
//! | 运行目录 / 隔离档 | 夹具(固定路径,便于两张图逐像素对照) |
//! | **后果原文** | **真**:`unlucky_core::game::adopt::target_notes()` 现算,一个字不抄 |
//! | 文案(标题/标签/按钮) | **真**:`unlucky_i18n` 目录 |
//! | 渲染 | **真**:`--software-renderer`(tiny-skia)1180×760,与产品路径同一棵控件树 |
//!
//! 也就是说:这些图证明**版面与文案**,不证明 core 的判据(判据的证据在 `host/adopt/tests.rs`
//! 与 `core/tests/adopt/`)。
//!
//! ## 怎么用
//!
//! ```text
//! ULL_ADOPT_STATE=pick|pick-selected|empty|preview|shared|failed|done \
//! unlucky-launcher-iced.exe --software-renderer --screenshot <path> [--screenshot-theme dark]
//! ```
//!
//! 只在 `--screenshot` 已经给了的情况下生效(交互模式读这个环境变量没有意义,直接忽略)。
use std::path::PathBuf;
use unlucky_core::game::adopt::{target_notes, AdoptTarget};
use unlucky_presenter::dto::{
AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView,
AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptState, AdoptTargetView,
};
use super::{Busy, Form};
/// 夹具里的版本目录(只是**画**出来的路径,不碰盘)。
const VERSIONS_DIR: &str = "D:/minecraft/.minecraft/versions";
/// 夹具里的数据区运行目录。
const INSTANCE_RUN_DIR: &str = "D:/ULL/.ULL/data/instances/Better-MC";
/// 夹具里的游戏根。
const GAME_ROOT: &str = "D:/minecraft/.minecraft";
/// 环境变量名(见模块文档)。
pub(crate) const ENV: &str = "ULL_ADOPT_STATE";
/// 按名字造一份「打开着的向导」;不认识的名字返回 `None`(不影响正常启动)。
pub(crate) fn seed(name: &str) -> Option<(Form, AdoptState)> {
let name = name.trim().to_ascii_lowercase();
let mut form = Form {
open: true,
..Form::default()
};
let mut state = AdoptState::default();
match name.as_str() {
// 扫描完成、还没选版本(列表里三态齐全:可导入 / 已登记 / 有问题)
"pick" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
form.busy = Busy::Idle;
}
// 扫描完成、**已选中一个可导入的版本**(选中行底色 + 预检按钮启用态)
"pick-selected" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
form.selected = Some("1.20.1".to_string());
form.busy = Busy::Idle;
}
// 游戏根下真没有版本(空态)
"empty" => {
state.apply(AdoptEvent::Scan(Box::new(AdoptScanView {
versions_dir: PathBuf::from(VERSIONS_DIR),
candidates: Vec::new(),
})));
form.busy = Busy::Idle;
}
// 选中缺省档(InstanceDir)并点过预检
"preview" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Planned(Box::new(plan_view(
AdoptTargetView::InstanceDir,
AdoptIsolationView::Custom,
INSTANCE_RUN_DIR,
))));
form.selected = Some("1.20.1".to_string());
form.checked = true;
form.busy = Busy::Idle;
}
// 选中共享档(Shared):后果原文由 core `target_notes()` 现算
"shared" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Planned(Box::new(plan_view(
AdoptTargetView::Shared,
AdoptIsolationView::Shared,
GAME_ROOT,
))));
form.selected = Some("1.20.1".to_string());
form.target = AdoptTargetView::Shared;
form.checked = true;
form.busy = Busy::Idle;
}
// 预检失败:两条问题逐条列出(不许折叠成一句)
"failed" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_not_adoptable".to_string(),
message: "版本 1.19.4 不能登记:清单不是合法 JSON".to_string(),
problems: vec![
AdoptProblemView {
code: "manifest_invalid_json".to_string(),
message: format!(
"清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3"
),
},
AdoptProblemView {
code: "missing_parent".to_string(),
message: "版本 1.19.4 继承的父版本 1.19.3 不在盘上".to_string(),
},
],
})));
form.selected = Some("1.19.4".to_string());
form.checked = true;
form.busy = Busy::Idle;
}
// 登记完成
"done" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
instance_id: "2f1c0a5e-7f4b-4c1d-9a2e-6b8d5c3a1f70".to_string(),
name: "Better MC".to_string(),
version_id: "1.20.1-fabric-0.15.0".to_string(),
isolation: AdoptIsolationView::Custom,
run_dir: PathBuf::from(INSTANCE_RUN_DIR),
dir_created: true,
notes: target_notes(&AdoptTarget::InstanceDir, &PathBuf::from(INSTANCE_RUN_DIR)),
})));
form.selected = Some("1.20.1-fabric-0.15.0".to_string());
form.checked = true;
form.busy = Busy::Idle;
}
_ => return None,
}
Some((form, state))
}
/// 一份扫描结果(三态齐全)。
fn scan_view() -> AdoptScanView {
AdoptScanView {
versions_dir: PathBuf::from(VERSIONS_DIR),
candidates: vec![
AdoptCandidateView {
version_id: "1.20.1".to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems: Vec::new(),
},
AdoptCandidateView {
version_id: "1.20.1-fabric-0.15.0".to_string(),
jar_present: false,
inherits: vec!["1.20.1-fabric-0.15.0".to_string(), "1.20.1".to_string()],
registered: vec!["Better MC".to_string()],
problems: Vec::new(),
},
AdoptCandidateView {
version_id: "1.19.4".to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems: vec![AdoptProblemView {
code: "manifest_invalid_json".to_string(),
message: format!(
"清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3"
),
}],
},
],
}
}
/// 一份预检结果。**`notes` 由 core 现算**(共享档/版本目录档的后果原文只有一处)。
fn plan_view(
target: AdoptTargetView,
isolation: AdoptIsolationView,
run_dir: &str,
) -> AdoptPlanView {
let core_target = match target {
AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir,
AdoptTargetView::Shared => AdoptTarget::Shared,
AdoptTargetView::VersionDir => AdoptTarget::VersionDir,
};
AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target,
isolation,
run_dir: PathBuf::from(run_dir),
notes: target_notes(&core_target, &PathBuf::from(run_dir)),
}
}
+287
View File
@@ -0,0 +1,287 @@
//! `app::adopt` 的用态用例(M223):表单态 + 四段投影。
//!
//! 抓得住:
//! - `Form` 的生命周期(open 清上一轮 / close 全清 / settled 解除等待);
//! - `candidate()` 在选中项不在列表里时返回 `None`(界面自动退回「请选择」);
//! - 四段的文案真源(标题/后果原文/问题逐条)能从 `AdoptState` 投影出来。
//!
//! 抓不住:**像素**(那是 D30 的截图通道);也不抓 core 的判据(那是 `host/adopt/tests.rs`)。
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{
AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView,
AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptStage, AdoptState, AdoptTargetView,
};
use super::*;
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
AdoptCandidateView {
version_id: version_id.to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems,
}
}
fn state_with_scan() -> AdoptState {
let mut state = AdoptState::default();
state.apply(AdoptEvent::Scan(Box::new(AdoptScanView {
versions_dir: std::path::PathBuf::from("/game/versions"),
candidates: vec![candidate("1.20.1", Vec::new())],
})));
state
}
/// 打开 ⇒ 关闭:上一轮的选中与名字都要清掉(否则下次打开画的是上一次的草稿)。
#[test]
fn open_and_close_reset_the_form() {
let mut form = Form::default();
form.open();
assert!(form.open);
assert_eq!(form.busy, Busy::Scanning);
assert_eq!(form.target, AdoptTargetView::InstanceDir);
form.selected = Some("1.20.1".to_string());
form.name = "生存".to_string();
form.close();
assert!(!form.open);
assert!(form.selected.is_none());
assert!(form.name.is_empty());
// 再开一次也不该带着上一轮的选中项
form.open();
assert!(form.selected.is_none());
}
/// `settled` 只解除等待,不动用户填的东西(回程不该清表单)。
#[test]
fn settling_keeps_what_the_user_typed() {
let mut form = Form::default();
form.open();
form.selected = Some("1.20.1".to_string());
form.name = "生存".to_string();
form.settled();
assert_eq!(form.busy, Busy::Idle);
assert_eq!(form.selected.as_deref(), Some("1.20.1"));
assert_eq!(form.name, "生存");
}
/// 选中项不在候选列表里 ⇒ `candidate()` 是 `None`(界面不能对着一个不存在的版本画「预检」)。
#[test]
fn candidate_is_none_when_the_selection_vanished() {
let state = state_with_scan();
let mut form = Form {
selected: Some("1.20.1".to_string()),
..Form::default()
};
assert!(form.candidate(&state).is_some());
form.selected = Some("9.9.9".to_string());
assert!(form.candidate(&state).is_none());
}
/// 四段的标题与后果原文都从目录来(不写死英文字面量、不重写 core 的后果句)。
#[test]
fn the_four_stages_project_from_the_event_state() {
let catalog = Lang::ZhCn.catalog();
let mut state = state_with_scan();
// Pick
assert_eq!(state.stage(), AdoptStage::Pick);
assert_eq!(
shell::title_text(state.stage(), catalog),
catalog.text(Key::AdoptTitle),
"标题走目录"
);
assert!(shell::subtitle(state.stage(), &state, catalog).contains("不复制"));
// Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改
let note =
"运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string();
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::Shared,
isolation: AdoptIsolationView::Shared,
run_dir: std::path::PathBuf::from("/game"),
notes: vec![note.clone()],
})));
assert_eq!(state.stage(), AdoptStage::Preview);
assert_eq!(
state.plan.as_ref().map(|plan| plan.notes.first().cloned()),
Some(Some(note.clone())),
"后果原文必须原样留着"
);
// Failed:problems 逐条(不折叠)
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_not_adoptable".to_string(),
message: "版本 1.19.4 不能登记".to_string(),
problems: vec![
AdoptProblemView {
code: "manifest_missing".to_string(),
message: "没有清单".to_string(),
},
AdoptProblemView {
code: "missing_parent".to_string(),
message: "缺父版本 1.20.1".to_string(),
},
],
})));
assert_eq!(state.stage(), AdoptStage::Failed);
assert_eq!(
state.failure.as_ref().map(|f| f.problems.len()),
Some(2),
"两条问题一条都不许吞"
);
assert_eq!(
shell::title_text(state.stage(), catalog),
catalog.text(Key::AdoptFailureTitle)
);
// Done
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
instance_id: "id-1".to_string(),
name: "1.20.1".to_string(),
version_id: "1.20.1".to_string(),
isolation: AdoptIsolationView::Shared,
run_dir: std::path::PathBuf::from("/game"),
dir_created: false,
notes: vec![note],
})));
assert_eq!(state.stage(), AdoptStage::Done);
assert_eq!(
shell::title_text(state.stage(), catalog),
catalog.text(Key::AdoptDoneTitle)
);
}
/// 中英两套目录的向导文案都不是占位(`text()` 回落到 key 名就是这个用例要抓的假绿)。
#[test]
fn both_catalogs_have_the_wizard_wording() {
for lang in [Lang::En, Lang::ZhCn] {
let catalog = lang.catalog();
for key in [
Key::AdoptTitle,
Key::AdoptSubtitle,
Key::AdoptScanning,
Key::AdoptEmpty,
Key::AdoptSelectLabel,
Key::AdoptReady,
Key::AdoptTargetLabel,
Key::AdoptTargetInstanceDir,
Key::AdoptTargetShared,
Key::AdoptTargetVersionDir,
Key::AdoptNameLabel,
Key::AdoptPreview,
Key::AdoptConfirm,
Key::AdoptBack,
Key::AdoptClose,
Key::AdoptPlanTitle,
Key::AdoptPlanRunDir,
Key::AdoptPlanIsolation,
Key::AdoptFailureTitle,
Key::AdoptProblemCount,
Key::AdoptDoneTitle,
Key::AdoptDoneHint,
] {
let value = catalog.text(key);
assert!(!value.is_empty(), "{key:?} 在 {lang:?} 是空的");
assert_ne!(
value,
format!("{key:?}"),
"{key:?} 在 {lang:?} 没进目录(回落成了 key 名)"
);
}
}
// 共享档的短提示必须把「会写进别人的 .minecraft」说清楚(选之前就得知道)
let hint = Lang::En.catalog().text(Key::AdoptTargetSharedHint);
assert!(hint.contains(".minecraft"), "{hint}");
}
/// 「点选共享档 ⇒ 立刻看到后果原文」但**不**把用户甩到确认页:
/// 预检结果到了也只是 Pick 段里的提示,直到用户点过「预检」(`checked`) 才进 Preview。
///
/// 抓得住:把 `Form::stage` 写回直接取 `AdoptState::stage()`(点一次共享档就跳到确认页,
/// 实例名还没来得及改);或忘了 `fresh_plan` 的目标一致性判据(改档之后画上一档的后果原文)。
#[test]
fn a_foreign_target_shows_its_notes_without_jumping_to_confirm() {
let mut state = state_with_scan();
let note =
"运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string();
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::Shared,
isolation: AdoptIsolationView::Shared,
run_dir: std::path::PathBuf::from("/game"),
notes: vec![note.clone()],
})));
let mut form = Form {
open: true,
selected: Some("1.20.1".to_string()),
target: AdoptTargetView::Shared,
..Form::default()
};
// 自动预检回来但用户没点「预检」:仍是 Pick 段,notes 当作提示可读
assert_eq!(form.stage(&state), AdoptStage::Pick);
assert_eq!(
form.fresh_plan(&state)
.map(|plan| plan.notes.first().cloned()),
Some(Some(note.clone())),
"后果原文必须能取到"
);
// 点过「预检」之后才进确认段
form.checked = true;
assert_eq!(form.stage(&state), AdoptStage::Preview);
// 换了目标档:上一档的预检作废(不再当后果原文画)
form.target = AdoptTargetView::VersionDir;
assert!(form.fresh_plan(&state).is_none(), "过期的预检不许画");
}
/// **P2-2**:`fresh_plan` 必须按**版本 id** 过期,不只是目标档。
///
/// 场景:先选 1.20.1 + 版本目录档(notes 说 `versions/1.20.1`),再改选 1.19.4——目标档没变,
/// 旧的预检结果若不按版本过滤就会继续当「这个版本的后果原文」画出来,那是一句假话。
///
/// 抓得住:`fresh_plan` 只看 `plan.target == self.target`(第一次修法就是这样漏的)。
/// 抓不住:core 真的会不会为 1.19.4 算出同一段 notes(那是 `target_notes()` 的活)。
#[test]
fn fresh_plan_expires_when_the_selected_version_changes() {
let mut state = state_with_scan();
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::VersionDir,
isolation: AdoptIsolationView::Isolated,
run_dir: std::path::PathBuf::from("/game/versions/1.20.1"),
notes: vec!["运行目录 = 版本目录 /game/versions/1.20.1".to_string()],
})));
let mut form = Form {
open: true,
selected: Some("1.20.1".to_string()),
target: AdoptTargetView::VersionDir,
checked: true,
..Form::default()
};
assert!(
form.fresh_plan(&state).is_some(),
"对得上版本与目标档时应当可读"
);
// 改选另一个版本:目标档没变,但旧预检说的是别的版本的目录
form.selected = Some("1.19.4".to_string());
assert!(
form.fresh_plan(&state).is_none(),
"换了版本之后旧预检必须过期(否则画的是 1.20.1 的后果原文)"
);
// 没选版本时同样不该画(`None` = 没有任何版本的预检可言)
form.selected = None;
assert!(form.fresh_plan(&state).is_none());
}
+88
View File
@@ -0,0 +1,88 @@
//! 向导里的小工具:等宽行、可换行正文、标签、问题行、档位人话。
//!
//! 单独一个文件只为守 400 行硬线(AGENTS §3.1):三段视图(`pick` / `panes`)与卡壳
//! (`adopt.rs`)都要用它们,放哪儿都会让那一处越线。
use iced::widget::text::LineHeight;
use iced::widget::{column, text};
use iced::{Color, Element, Font, Length, Pixels};
use unlucky_design::color::Role;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptIsolationView, AdoptProblemView, AdoptTargetView};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
/// 段内的次级标签(`LabelMedium` + `OnSurfaceVariant`)。
pub(super) fn label<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
value,
ui.color(Role::OnSurfaceVariant),
)
.map(Message::Page)
}
/// 等宽一行(路径 / 机读码要能逐字读)。
pub(super) fn mono<'a>(ui: &'a Ui, value: String) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodySmall);
text(value)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(Font {
family: iced::font::Family::Name(ui.families().mono),
..Font::DEFAULT
})
.color(ui.color(Role::OnSurface))
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into()
}
/// 一段可换行的正文(长路径只按词折行会把卡顶破,所以按词或字形折)。
pub(super) fn wrapped<'a>(ui: &'a Ui, value: String, color: Color) -> Element<'a, Message> {
frame::text_role_owned(ui, TypeRole::BodySmall, false, value, color).map(Message::Page)
}
/// 「标签 + 值」一行(值用等宽)。
pub(super) fn field_line<'a>(ui: &'a Ui, label_text: &'a str, value: &str) -> Element<'a, Message> {
column![label(ui, label_text), mono(ui, value.to_string())]
.spacing(0.0)
.width(Length::Fill)
.into()
}
/// 一条问题:稳定码(等宽) + 人读原因(`Error` 色)。**一条都不许折叠**。
pub(super) fn problem_line<'a>(ui: &'a Ui, problem: &'a AdoptProblemView) -> Element<'a, Message> {
column![
mono(ui, problem.code.clone()),
wrapped(ui, problem.message.clone(), ui.color(Role::Error)),
]
.spacing(0.0)
.width(Length::Fill)
.into()
}
/// 隔离档的人话(沿用实例页已有的两条;`custom` 就是缺省档,直接说「在 ULL 数据区新建」)。
pub(super) fn isolation_label(isolation: AdoptIsolationView, catalog: Catalog) -> String {
match isolation {
AdoptIsolationView::Isolated => catalog.text(Key::InstancesIsolationIsolated).to_string(),
AdoptIsolationView::Shared => catalog.text(Key::InstancesIsolationShared).to_string(),
AdoptIsolationView::Custom => catalog.text(Key::AdoptTargetInstanceDir).to_string(),
}
}
/// 目标档的短标签。
pub(super) fn target_label(target: AdoptTargetView, catalog: Catalog) -> &'static str {
match target {
AdoptTargetView::InstanceDir => catalog.text(Key::AdoptTargetInstanceDir),
AdoptTargetView::Shared => catalog.text(Key::AdoptTargetShared),
AdoptTargetView::VersionDir => catalog.text(Key::AdoptTargetVersionDir),
}
}
+133
View File
@@ -0,0 +1,133 @@
//! App 侧的外观动作:**下发整窗 α / 写回配置 / 打取证日志**。
//!
//! 三件事各自只有一条路,且都从这里出去(`update` 只负责把它们按消息串起来):
//!
//! | 动作 | 唯一落点 | 为什么 |
//! |---|---|---|
//! | 下发整窗 α | [`App::apply_window_alpha`] → `app::window_alpha::dispatch` | 判据在 `platform`、FFI 在 `platform/win32`,这里只做「取 α → 交给执行侧 → 记日志」 |
//! | 写回配置 | [`App::persist_appearance`](8 个 UI 字段)+ `AppCommand::SetSetting`(另外**三个**只读键) | 每个键**只有一个写者**:`AppearancePrefs` 里没有 `window_opacity` / `palette_source` / `window_corner`,它们只能走 schema 指令 |
//! | 取证日志 | [`App::log_appearance`] | 一行里同时有请求值、生效值、下发字节与 FFI 结局 |
//!
//! ## 为什么「写回」要分两条路
//!
//! `SettingsBridge::persist` 收的是 `AppearancePrefs`(背景图四件 + 面板 α + 玻璃 + 强调色),
//! 它的实现是「先读文件当前值,再覆盖我们改的那几个字段」——所以用它写**它覆盖的键**最省事,
//! 也不会把用户的自定义强调色冲掉。而 `appearance.window_opacity` / `appearance.palette_source` /
//! `appearance.window_corner` 不在那个结构里(它们是「只读下层键」,见
//! `presenter::prefs::AppearanceBase` 的**四个**字段:整窗 α / 标题栏三态 / 配色来源 / 窗口圆角),
//! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。
//! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。
use crate::app::diag::diag;
use unlucky_presenter::dto::AppCommand;
use crate::pages::appearance::Change;
use super::state::App;
use super::window_alpha::{self, Dispatch};
impl App {
/// 把当前整窗 α 交给执行侧(补 `WS_EX_LAYERED` + `SetLayeredWindowAttributes`)并记一行日志。
///
/// 没有句柄时**也打日志**(`hwnd=none`):不能让「α 没生效」这件事只体现为「屏幕上没变化」。
pub(super) fn apply_window_alpha(&self, context: &str) {
let Some(hwnd) = self.hwnd else {
self.log_appearance(context, None);
return;
};
let alpha = crate::platform::effective_window_alpha(
self.appearance.backend,
f64::from(self.appearance.window_opacity),
);
let dispatch = window_alpha::dispatch(hwnd, alpha);
self.log_appearance(context, Some(&dispatch));
}
/// 取证日志:`[appearance] <状态行> layered=… outcome=…`。
///
/// 数值来自 [`crate::pages::appearance::State::log_line`](请求值 / 生效值 / 下发字节),
/// 尾巴来自执行侧。**这一行就是「透明度到底设成了多少」的证据**——RDP 上的观感不可信。
pub(super) fn log_appearance(&self, context: &str, dispatch: Option<&Dispatch>) {
let mut line = self.appearance.log_line(context);
if let Some(dispatch) = dispatch {
line.push(' ');
line.push_str(&dispatch.describe());
} else {
line.push_str(" layered=skipped outcome=no-window");
}
diag!("{line}");
}
/// 设置页改了一项外观。
pub(super) fn on_appearance(&mut self, change: Change) {
let needs_window = self.appearance.apply(change.clone());
// 配色来源 / 背景图可能变了 ⇒ `Ui` 可能要改用解析出的方案。
self.rebuild_ui();
self.view_dirty = true;
self.refresh_view();
match change.key() {
// 三个只读下层键(整窗 α / 配色来源 / 窗口圆角):走 schema 指令
// (`SettingsBridge` 的结构里没有它们;标题栏三态还没有 UI 写者,不在此列)。
Some(_)
if matches!(
change,
Change::WindowOpacity(_) | Change::PaletteSource(_) | Change::Corner(_)
) =>
{
if let (Some(key), Some(value)) = (change.key(), change.value()) {
self.dispatch(AppCommand::SetSetting {
key: key.to_owned(),
value,
});
}
}
// 其余键:走 bridge(M250 起强调色也在这条路上,取的是上一次合法值)。
Some(_) => self.persist_appearance(),
None => {}
}
if needs_window {
self.apply_window_alpha("change");
} else {
self.log_appearance("change", None);
}
// M116:圆角档位变了要**重新判定 + 重新下发**(与整窗 α 是两条独立的管线)。
self.apply_window_corner("change");
}
/// 重读配置后同步外观(热切:外部 CLI / AI 改完 1 秒内生效)。
pub(super) fn reload_appearance(&mut self) {
let (prefs, _theme) = self.bridge.load();
let base = self.bridge.load_base();
self.appearance.reload(&prefs, &base);
self.appearance_prefs = prefs;
self.rebuild_ui();
self.view_dirty = true;
self.apply_window_alpha("reload");
// M116:外部(CLI / AI)改了 `appearance.window_corner` 也要热生效。
self.apply_window_corner("reload");
}
/// 把外观写回 `launcher.json`(本页控件管的那批字段 + 强调色;其余键原样保留)。
fn persist_appearance(&mut self) {
let mut prefs = self.appearance_prefs.clone();
prefs.bg_image_enabled = self.appearance.bg_enabled;
prefs.bg_image = self.appearance.bg_path.clone();
prefs.bg_fill = self.appearance.bg_fill;
prefs.bg_dim = self.appearance.bg_dim;
prefs.panel_alpha = self.appearance.panel_alpha;
prefs.glass = self.appearance.glass;
// M250:强调色也是「UI 写入字段」了——取**上一次合法值**(非法手输不写盘,见 `accent::Accent`)。
prefs.accent_color = self.appearance.accent.persist_value().to_owned();
self.bridge.persist(&prefs, self.theme_mode);
self.appearance_prefs = prefs;
match self.bridge.path() {
Some(path) => diag!("[settings] 外观已写回 {}", path.display()),
None => diag!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
}
}
}
+177
View File
@@ -0,0 +1,177 @@
//! 本进程**实际**用的是哪个渲染后端(D36 的「CPU 档」判据)。
//!
//! ## 为什么不能直接看 `--renderer` 的请求值
//!
//! `Options::backend` 是**请求**(默认 `Auto`)。真正落到哪个渲染器由 `probe::apply` 决定,
//! 它把结论写进进程环境变量 `ICED_BACKEND`(iced 只认这个)。于是默认参数 + 没有独显的机器上:
//! 请求是 `Auto`,实际是 tiny-skia(CPU)——如果面板 α / 玻璃的降级判据只看请求值,
//! D36 明写的「CPU 档面板 α 强制 1.0」**恰好在它最该生效的场景失效**(复审 r1 P2-2)。
//!
//! 所以判据统一走 [`effective_backend`]:它把 `ICED_BACKEND` 翻译回 [`Backend`]。
//! 这里是**唯一**的翻译点,别在别处再读一遍环境变量。
//!
//! ## M97 之后这条链是准的(以前不是)
//!
//! 以前 `ICED_BACKEND` 可能是**候选列表**(`wgpu,tiny-skia`),运行期 wgpu 静默回退到
//! tiny-skia 时这个变量还停在 wgpu ⇒ 本模块按 **GPU 档**判 ⇒ **半透明在 CPU 渲染器上照开**。
//! 也就是说「静默降级」不只让日志说谎,还让 D36 的降级判据一起说谎。
//!
//! 现在 `ICED_BACKEND` **永远是单项**([`crate::renderer::Kind`]),而且 `app::boot` 的重试
//! 在降级重开前会把它改成 `tiny-skia`,于是:**真降级到 CPU 的那一次,面板 α 会正确地强制 1.0**。
//!
//! ## 已知边界(诚实登记)
//!
//! `iced` 0.14 没有公开的「当前后端」查询,所以本模块读的仍是**我们请求的那一项**。
//! 单项 + 重试([`crate::renderer`] 判据表第 8 行)把「请求 ≠ 实际」的窗口压到只剩一种:
//! 重试那次自己也失败——那时候进程会带着具名错误退出,不会带着错的档位继续画。
use crate::renderer::Kind;
use crate::Backend;
/// `ICED_BACKEND` 的取值 → 后端(纯函数,好测)。
///
/// 逗号列表按**首项**解释:兼容 M97 之前写下的值(以及外部手设的实验值),
/// 但本 crate 不再产出那种值(守门测试见 `probe::tests::apply_writes_exactly_one_backend`)。
#[must_use]
pub(super) fn parse_kind(env: &str) -> Option<Kind> {
match env.trim().split(',').next()?.trim() {
"tiny-skia" => Some(Kind::TinySkia),
"wgpu" => Some(Kind::Wgpu),
_ => None,
}
}
/// `ICED_BACKEND` → 实际档位(纯函数,好测)。
///
/// | `ICED_BACKEND` | 含义(`probe::apply` 的产物) | 结论 |
/// |---|---|---|
/// | `tiny-skia` | 只有 CPU(`--renderer=cpu` / 未编 wgpu / 探测无硬件 / wgpu 失败后重开) | [`Backend::Cpu`] |
/// | `wgpu` | 判据表第 3、6 行:起步就是 GPU(起不来会打印具名错误并重开成 `tiny-skia`) | [`Backend::Gpu`] |
/// | 未设 / 别的值 | 不是本 crate 写的(外部手设 / M97 之前的旧进程) | 请求值(不猜) |
#[must_use]
pub(super) fn backend_from_env(request: Backend, env: Option<&str>) -> Backend {
env.and_then(parse_kind).map_or(request, Kind::tier)
}
/// 本进程实际生效的后端(判据 = `probe::apply` 写下的 `ICED_BACKEND`)。
#[must_use]
pub(super) fn effective_backend(request: Backend) -> Backend {
backend_from_env(request, std::env::var("ICED_BACKEND").ok().as_deref())
}
/// 本进程实际生效的**后端名**(`None` = 变量没写 / 不认识)。
///
/// 与 [`effective_backend`] 的区别:那个给「档位」,这个给「名字」,用于**取证日志**
/// (`[shot] … renderer=wgpu`)。两张图分辨不出 GPU/CPU 的时候,靠的就是这一行。
#[must_use]
pub(super) fn effective_kind() -> Option<Kind> {
std::env::var("ICED_BACKEND")
.ok()
.as_deref()
.and_then(parse_kind)
}
#[cfg(test)]
mod tests {
use super::backend_from_env;
use crate::Backend;
use unlucky_presenter::prefs::{PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN};
/// 抓得住:`probe::apply` 现在产出的**每一种单项值**都被翻译成正确的档位,
/// 且 M97 之前写下的逗号列表仍按首项解释(向后兼容)。
/// 抓不住:运行期重试降级那一刻的时序(那由 `app::boot` 的重试保证,见 `renderer` 判据表第 8 行)。
#[test]
fn every_iced_backend_value_maps_to_a_tier() {
// 本 crate 现在只产单项(`probe::tests::apply_writes_exactly_one_backend` 守这一条)。
assert_eq!(
backend_from_env(Backend::Auto, Some("tiny-skia")),
Backend::Cpu,
"M97 判据表第 5 行:探测无硬件 ⇒ CPU 档"
);
assert_eq!(
backend_from_env(Backend::Auto, Some("wgpu")),
Backend::Gpu,
"M97 判据表第 6 行:有硬件 ⇒ GPU 档"
);
assert_eq!(
backend_from_env(Backend::Cpu, Some("tiny-skia")),
Backend::Cpu,
"强制 CPU 也是 CPU 档"
);
// 兼容 M97 之前的逗号列表(外部手设 / 旧进程留下):按首项解释。
assert_eq!(
backend_from_env(Backend::Auto, Some("tiny-skia,wgpu")),
Backend::Cpu
);
assert_eq!(
backend_from_env(Backend::Auto, Some("wgpu,tiny-skia")),
Backend::Gpu
);
assert_eq!(
backend_from_env(Backend::Gpu, Some("wgpu,tiny-skia")),
Backend::Gpu
);
// 未设 / 空白:保持请求值(不是本 crate 写的,不猜)。
assert_eq!(backend_from_env(Backend::Auto, None), Backend::Auto);
assert_eq!(backend_from_env(Backend::Auto, Some(" ")), Backend::Auto);
// 不认识的取值不猜:保留请求值。
assert_eq!(
backend_from_env(Backend::Auto, Some("metal")),
Backend::Auto
);
}
/// **复审 r1 P2-2 的守门**:默认档(`Auto`)在无独显机器上必须落进 CPU 档,
/// 从而让「面板 α / 玻璃强制 1.0」在默认路径上真的生效。
///
/// 抓得住:判据走 `effective_backend()` 这条链(谁把 `State::new` 改回只吃请求值,这条会红)。
/// 抓不住:整窗 α 在 CPU 档仍要生效(那是另一条判据,见 `alpha` 模块真值表)。
#[test]
fn auto_with_a_software_renderer_forces_the_panel_opaque() {
use crate::pages::appearance::alpha::{effective_glass, effective_panel_alpha};
let backend = backend_from_env(Backend::Auto, Some("tiny-skia"));
assert_eq!(backend, Backend::Cpu);
assert_eq!(
effective_panel_alpha(backend, 0.55),
1.0,
"D36:CPU 档面板 1.0"
);
assert!(!effective_glass(backend, true), "D36:CPU 档玻璃强制关");
// 但整窗 α 不受限(D36 对 D27 的修订)。
assert!(crate::platform::effective_window_alpha(backend, 0.8).is_some());
// 反方向:GPU 档不做这个降级(否则「强制」就没意义了)。
let gpu = backend_from_env(Backend::Auto, Some("wgpu"));
assert_eq!(effective_panel_alpha(gpu, 0.55), 0.55);
assert!((PANEL_ALPHA_MIN - 0.4).abs() < 1e-9 && (WINDOW_ALPHA_MIN - 0.08).abs() < 1e-9);
}
/// **M97 的新守门**:wgpu 失败重开成 `tiny-skia` 之后,档位必须跟着变成 CPU 档。
///
/// 这是修之前做不到的一条:那时变量停在 `wgpu,tiny-skia`,静默回退到 tiny-skia 之后
/// 判据仍然读 `Gpu` ⇒ 半透明在 CPU 渲染器上照开。现在 `app::boot` 重开前会改写这个值,
/// 这条测试钉的是「改写成 tiny-skia 之后判据确实翻转」。
/// **两个后端取值各自对应哪个档位、面板 α 各是多少。**
///
/// ⚠️ 这条**不**守「降级后档位翻转」——它只是把两个字面量喂给纯函数,`respawn` 一行都没执行。
/// 复审 M97 r1 p2-1 就是靠这一点把 `respawn` 里的 `.env("ICED_BACKEND", …)` 删掉而测试全绿的。
/// 真正的翻转由 `app::boot::tests::fallback_env_carries_the_backend_and_the_gate`
/// (respawn 到底传了什么)钉住——那条红了才是「翻转坏了」。
#[test]
fn each_backend_value_maps_to_its_tier_and_alpha() {
use crate::pages::appearance::alpha::effective_panel_alpha;
let gpu = backend_from_env(Backend::Auto, Some("wgpu"));
assert_eq!(gpu, Backend::Gpu);
assert_eq!(effective_panel_alpha(gpu, 0.55), 0.55);
let cpu = backend_from_env(Backend::Auto, Some("tiny-skia"));
assert_eq!(cpu, Backend::Cpu);
assert_eq!(
effective_panel_alpha(cpu, 0.55),
1.0,
"D36:CPU 档面板 α 强制 1.0"
);
}
}
+263
View File
@@ -0,0 +1,263 @@
//! 启动编排:**探测 → 字体 → 打印诊断 → 起 iced**(四步,别把它撑回一坨)。
//!
//! ## 三条纪律(每条都对应一次返工)
//!
//! 1. **后端起不来时的两条路策略不同**(判据表第 8/9 行):交互启动换进程重开、取证启动
//! (`--screenshot`)拒绝出图 —— 实现与测试都在 [`fallback`]。
//! 2. **窗口设置只有一个产地**([`diagnostics::window_settings`]),`[window]` / `[caps]` 两行日志
//! 从**同一个** `&window::Settings` 取字段。M98 r1 p2-1 的教训:把 `decorations=false` 写进
//! 格式化串,等于让日志与真实行为可以各走各的(踩坑记录 U1)。
//! 3. **没测过的就写 `skipped`**,不许拿 `0ms` 冒充实测([`diagnostics::report_font`] 的 `probe`)。
//!
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
use crate::app::diag::diag;
use std::time::{Duration, Instant};
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use crate::cli::Options;
use crate::fonts;
use crate::probe;
use crate::renderer::{self, acceptance, Decision};
use crate::theme::Families;
use super::message::Message;
use super::state::{App, STARTED};
use super::{MIN_SIZE, WINDOW_SIZE};
mod diagnostics;
mod fallback;
mod termux_gpu;
mod termux_window;
use fallback::{on_failure, respawn, OnFailure};
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
pub(crate) fn run(options: Options) -> iced::Result {
let _ = STARTED.set(Instant::now());
let boot_started = Instant::now();
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
termux_gpu::enable();
let boot = boot_probe(&options);
let decision = boot.decision;
diagnostics::report_boot(&options, &boot.probe, &decision);
// ② 字体(`fonts` 自己打两行既有日志;新加的那行在 `report_font` 里)
let font_started = Instant::now();
let resolved = fonts::resolve();
let font_ms = font_started.elapsed();
diag!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
diag!("{}", resolved.describe());
if resolved.has_synthetic() {
diag!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
);
}
let families = resolved.families();
// ③ 打印(主题来源 + 字体回退链 + 启动耗时)
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。
// **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
if options.screenshot.is_some() && options.shot_flavor().is_none() {
diag!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
}
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
diagnostics::report_theme(&options, flavor);
diagnostics::report_font(
resolved.platform,
boot.probe_ms,
font_ms,
boot_started.elapsed(),
);
// ④ 起 iced(失败按判据表第 8/9 行分流;具名原因在分流之前就打到同一个 stderr 上了)
let launch_started = Instant::now();
let result = match launch(&options, families, flavor, decision.primary) {
Err(iced::Error::GraphicsCreationFailed(error)) => {
renderer::report_failure(decision.primary, &error);
match on_failure(decision, options.screenshot.as_ref()) {
Some(OnFailure::Respawn(fallback)) => {
respawn(decision.primary, fallback, &error.to_string())
}
Some(OnFailure::Refuse) => {
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
diag!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options
.screenshot
.as_ref()
.map_or_else(String::new, |path| path.display().to_string())
);
diag!(
"[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。"
);
Err(iced::Error::GraphicsCreationFailed(error))
}
None => Err(iced::Error::GraphicsCreationFailed(error)),
}
}
other => other,
};
let launch_elapsed = launch_started.elapsed();
diagnostics::report_outcome(
&decision,
result.as_ref().err().map(|error| {
renderer::named_graphics_error(error).unwrap_or_else(|| error.to_string())
}),
acceptance::failure_via(decision.fallback.is_some(), options.screenshot.is_some()),
if options.screenshot.is_some() {
"screenshot"
} else {
"interactive"
},
launch_elapsed,
);
result
}
/// 按一次后端启动 iced。`backend` 决定这次写进 `ICED_BACKEND` 的唯一一项。
fn launch(
options: &Options,
families: Families,
flavor: SchemeFlavor,
backend: renderer::Kind,
) -> iced::Result {
std::env::set_var("ICED_BACKEND", backend.env_value());
let boot_options = options.clone();
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
// Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先),
// 失败就退回 1180×760 的验收尺寸。
let (default_size, default_source) = termux_window::default_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height),
)
.unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default"));
let (size, size_source) =
acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height));
let size_source = if size_source == "default" {
default_source
} else {
size_source
};
let settings = diagnostics::window_settings(size);
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
diagnostics::report_window(&settings, options.scale_factor, size_source);
let application = iced::application(
move || {
(
App::new(boot_options.clone(), families, flavor),
Task::done(Message::Boot),
)
},
App::update,
App::view,
)
.title(App::title)
.theme(App::theme)
.subscription(App::subscription)
.default_font(default_font(families))
.window(settings);
match options.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
None => application.run(),
}
}
/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + **探测耗时(没测就是 `None`)**。
///
/// 为什么把 `Probe` 带出来:`[env]` 的 `gpu=` / `hardware=` 必须是**这一次真枚举到的**结果
/// (再枚举一遍多花 0.65~1.2 s,还可能给出不同状态)。判据与诊断看同一份数据。
struct Boot {
decision: Decision,
probe: probe::Probe,
probe_ms: Option<Duration>,
}
/// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。
fn boot_probe(options: &Options) -> Boot {
// 「上一步失败后重开」的那一次:别再探(会得到同一个结论,然后无限重开)。兜底必须是 None
// ——由 `decision::fallback_round` 单点保证。
if let Some(kind) = renderer::forced_fallback() {
let decision = renderer::fallback_round(kind);
std::env::set_var("ICED_BACKEND", kind.env_value());
diag!(
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
renderer::FALLBACK_ENV,
kind.env_value(),
kind.env_value()
);
diag!("[probe] {}", decision.describe());
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
// ⇒ 日志写 `probe=skipped`,不写 0ms)。
let (probe, probe_ms) = if acceptance::enabled() {
let started = Instant::now();
let probe = probe::Probe::run();
(probe, Some(started.elapsed()))
} else {
(probe::Probe::default(), None)
};
return Boot {
decision,
probe,
probe_ms,
};
}
// 只有 `auto` 需要探测(`cpu`/`gpu` 的决策与硬件无关,枚举 13 个适配器本机要 0.65~1.2 s)。
// 验收模式例外:`[env]` 那行要 GPU 名,不管请求是哪个后端。
let needs_probe = options.backend == crate::Backend::Auto || acceptance::enabled();
let probe_started = Instant::now();
let probe = if needs_probe {
probe::Probe::run()
} else {
probe::Probe::default()
};
let probe_ms = needs_probe.then(|| probe_started.elapsed());
// `ULL_PREFLIGHT=1` 时先自己验一遍 wgpu(判据表第 7 行);默认不跑,见 preflight 模块文档。
let preflight = renderer::preflight::run_if_requested();
let decision = probe::apply_with_preflight(options.backend, &probe, preflight.as_ref());
if cfg!(feature = "gpu") {
match probe_ms {
Some(elapsed) => diag!(
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
elapsed.as_secs_f64() * 1000.0
),
None => diag!(
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
options.backend.name()
),
}
}
Boot {
decision,
probe,
probe_ms,
}
}
/// 默认字体(正文档解析出来的族)。
fn default_font(families: Families) -> iced::Font {
iced::Font {
family: iced::font::Family::Name(families.text),
..iced::Font::DEFAULT
}
}
#[cfg(test)]
#[path = "boot/tests.rs"]
mod tests;
+115
View File
@@ -0,0 +1,115 @@
//! 启动阶段的**验收诊断编排**:把 `app/boot.rs` 的 `run()` 收成「探测 → 字体 → 打印 → 起 iced」。
//!
//! ## 为什么单独一个文件(M98 r1 p2-1 的根因)
//!
//! 第一版把 `[window] requested … resizable=true decorations=false transparent=false` 直接写在
//! 格式化串里,而 `boot.rs` 给窗口的 `settings` 是另一处常量 —— 两者**没有任何绑定**。定向变异
//! 把 `settings.decorations` 改成 `true`,371 个测试全绿,而验收包里 `[caps] decorations=true` 与
//! `[window] decorations=false` 会互相矛盾(踩坑记录 U1:常量冒充观测结果)。
//!
//! 现在:窗口设置由 [`window_settings`] **一处**产生,日志由 [`report_window`] 从**同一个
//! `&window::Settings`** 取字段。钉死它的守门测试在 `boot/tests.rs`(`window_settings_*`)。
//!
//! 另一件事:所有「跑没跑」都要能如实说。降级重开那一轮没计时就写 `probe=skipped`,
//! 不许拿 `0ms` 冒充一次实测(M98 r1 p3-1)。
use std::time::Duration;
use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform as FontPlatform;
use crate::cli::Options;
use crate::probe::Probe;
use crate::renderer::{acceptance, Decision};
use super::MIN_SIZE;
use crate::app::APP_TITLE;
/// 产品窗口设置(**唯一产地**:`launch()` 用它开窗,`[window]` / `[caps]` 从它取字段)。
///
/// `ULL_WINDOW_SIZE` 的覆盖在 `boot.rs` 解析成 `size` 后从这里进来 —— 日志与真窗口永远同源。
pub(super) fn window_settings(size: (f32, f32)) -> window::Settings {
window::Settings {
size: iced::Size::new(size.0, size.1),
min_size: Some(MIN_SIZE),
// 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。
//
// M182:这一行**同时**是「macOS 四角是直角」的原因——winit 见到 `decorations: false`
// 会给出 `NSWindowStyleMask::Borderless`(没有 `Titled`),而窗口服务器只给 titled 窗
// 做圆角裁剪。链条与「要不要迁移原生标题栏」的口径写在 `platform::macos` 的模块文档里,
// 改动这一行之前先读那一页(迁移必须连同撤掉自绘三键一起做,否则两套控件打架)。
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
// 字段,写它只有 `Get/SetWindowLongPtrW`(`unsafe`,全仓只允许出现在
// `platform/win32/transparency.rs`)。所以 α 下发在拿到句柄之后(`Message::WindowHandle`)。
transparent: false,
..window::Settings::default()
}
}
/// `[window] requested` + `[caps]` 两行,**都从同一个 `settings` 取**(不给手抄字面量的机会)。
pub(super) fn report_window(settings: &window::Settings, scale: Option<f32>, size_source: &str) {
let facts = acceptance::WindowFacts::from_settings(settings);
acceptance::report_window(&facts, scale, size_source, Some(APP_TITLE));
acceptance::report_caps(
std::env::current_exe().ok().as_deref(),
facts.transparent,
facts.resizable,
facts.decorations,
);
}
/// 第一段:`[acceptance] on` + `[backend] plan` +(验收模式)`[env]` / `[i18n]`。
pub(super) fn report_boot(options: &Options, probe: &Probe, decision: &Decision) {
if acceptance::enabled() {
acceptance::announce();
}
acceptance::report_backend_plan(decision, options.backend);
if acceptance::enabled() {
acceptance::report_startup(probe, options.scale_factor, options.screenshot.is_some());
}
}
/// 第二段:`[font] chain` + `[perf] boot`(都要 `ULL_ACCEPTANCE=1`)。
///
/// `probe = None` = 这一轮**没有探测**(既不是 `auto` 也没开验收)⇒ 打 `probe=skipped`。
pub(super) fn report_font(
platform: FontPlatform,
probe: Option<Duration>,
font: Duration,
to_run: Duration,
) {
if acceptance::enabled() {
acceptance::report_font_chain(platform);
acceptance::report_perf_boot(probe, font, to_run);
}
}
/// 第三段:`[theme]`(恒开:它解释的是「这次主题凭什么是这个」)。
pub(super) fn report_theme(options: &Options, flavor: SchemeFlavor) {
let (source, follows) = acceptance::theme_source(
options.screenshot_theme.is_some(),
options.theme.is_some(),
options.screenshot.is_some(),
);
let explicit = (!follows).then_some(if flavor.is_dark() { "dark" } else { "light" });
acceptance::report_theme(source, explicit, follows);
}
/// 第四段:`[backend] final`(恒开)+ `[perf] session`(验收模式)。
///
/// `error` = 具名失败原因(`None` = 起来了);`via` 见 `renderer::acceptance::failure_via`。
/// `refuse` / `none` 两条路**什么都没跑起来** ⇒ `final=none`;只有 `respawn` 才有「换到哪个
/// 后端」可言(那一格是兜底名,子进程自己还会再打一遍自己的 plan/final)。
pub(super) fn report_outcome(
decision: &Decision,
error: Option<String>,
via: &str,
mode: &str,
elapsed: Duration,
) {
acceptance::report_outcome(decision, error.as_deref(), via, mode, elapsed);
}
+121
View File
@@ -0,0 +1,121 @@
//! 起步后端失败后的**兜底/重开**一簇:判据、进程环境交接、`respawn`、退出码透传。
//!
//! 从 `app/boot.rs` 拆出来的理由:这一簇**与启动编排零耦合**,但已有 5 条测试,而且它是
//! M97 判据表第 8/9 行的落点。`boot.rs` 那边只负责「什么时候调它」。
//!
//! ## 为什么必须换进程(别改回进程内重试)
//!
//! Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程
//! 只能建一次事件循环。
use crate::app::diag::diag;
use crate::renderer::Decision;
use crate::renderer::{self, acceptance, Kind};
/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum OnFailure {
/// 交互启动:安全网优先,换进程用这个后端重开。
Respawn(Kind),
/// 取证启动:红着退出,**不出图**。
Refuse,
}
/// 纯判据:起步后端失败时走哪条路。
///
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`);返回 `None` = 没有兜底可用
/// (`--renderer=cpu` / 未编 wgpu),原样把错误交回去。抽成纯函数只为一个理由:这条分支以前
/// **没有任何测试**,`respawn` / `Refuse` 两条路可以被静默改反(M97 r1 p2-1 就是这么测出来的)。
pub(super) fn on_failure(
decision: Decision,
evidence: Option<&std::path::PathBuf>,
) -> Option<OnFailure> {
let fallback = decision.fallback?;
Some(match evidence {
Some(_) => OnFailure::Refuse,
None => OnFailure::Respawn(fallback),
})
}
/// 降级重开要传给子进程的环境变量(**两条缺一不可**)。
///
/// ① `ICED_BACKEND`:让子进程直接用 `fallback` 跑。
/// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测
/// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。
///
/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:两条都是静默失效型
/// (漏 ① 重开又跑一遍 wgpu、漏 ② 无限重开),线上极难看出来。
pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
[
("ICED_BACKEND", fallback.env_value()),
(renderer::FALLBACK_ENV, fallback.env_value()),
]
}
/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。
///
/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let exe = std::env::current_exe().unwrap_or_else(|err| {
diag!("[gpu] 降级重开失败:拿不到自身路径({err})");
std::process::exit(exit_code(None));
});
diag!(
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
下一次的日志从 `[probe] 本次是降级重开` 开始。",
fallback.name(),
failed.name()
);
// `[backend] final` 一行把「哪个后端失败了、去了哪、为什么」收在一起(可 grep 的单一结论行)。
// 这里 `final=` 装的是**兜底**(进程即将带着它重开);`via=respawn` 说明它是换进程去的。
acceptance::report_backend_failed(failed, Some(fallback), Some(fallback), "respawn", error);
let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status();
// 退出码**透传**子进程的:上层脚本/CI 靠它判断成败。写错的失效方式是「安静地给出一个错
// 结论」——不崩、不静默降级、不无限重开,只是让 CI 把失败当成功。
let code = match status {
Ok(status) => exit_code(status.code()),
Err(err) => {
diag!("[gpu] 降级重开失败:{err}");
exit_code(None)
}
};
std::process::exit(code)
}
/// 子进程退出码 → 本进程退出码。
///
/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** panic、也不是 0——上层判据是
/// 「非零 = 失败」,「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
pub(super) fn exit_code(code: Option<i32>) -> i32 {
/// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。
const RESPAWN_FAILED: i32 = 2;
code.unwrap_or(RESPAWN_FAILED)
}
/// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。
///
/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的是让测试能喂假命令
/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测。
///
/// 三路 stdio **显式**继承:子进程日志必须和父进程同一路输出,否则验收只抓到父进程那两行具名
/// 错误。`Command` 没有 stdio 的 getter(stable 没有),所以这条只能靠真跑一次证明。
pub(super) fn fallback_command(
program: &std::path::Path,
args: impl IntoIterator<Item = std::ffi::OsString>,
fallback: Kind,
) -> std::process::Command {
// 子进程就是本 GUI exe 自己(PE Subsystem=2,本来就不会分到控制台),所以这一处今天闪不了;
// 走 `quiet` 是为了让「app-iced 里除 opener 外不留裸构造」这条规则整体可被守卫钉住——将来
// 某天把降级重开换成控制台辅助程序时,不会从这里漏出一块黑框(M217)。
let mut command = unlucky_core::platform::child::quiet(program);
command.args(args).envs(fallback_env(fallback));
command
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
command
}
@@ -0,0 +1,58 @@
//! Termux 上自动启用随附的 Vulkan ICD(glibc 侧 turnip,`ull_freedreno.json`)。
//!
//! 背景:Termux 软件源里的 glibc Mesa(24.2.6)不认识 Adreno 840,硬件 GPU 需要用
//! `packaging/docs/termux-glibc-build.md` §5 里自编的 Mesa 26(KGSL)。驱动装在
//! `$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json` 时,这里在探测之前把它设进
//! `VK_ICD_FILENAMES` / `VK_DRIVER_FILES`,让 `auto` 也能选到 GPU。
//!
//! 只在 **glibc 构建**(`target_os = "linux"`)上生效:Termux 原生(bionic)构建的
//! `target_os = "android"`,glibc 驱动对它没有意义。用户显式设了这两个变量时不覆盖。
use std::path::{Path, PathBuf};
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
/// 随附 ICD 的相对路径(相对 `$PREFIX`)。
const ICD_REL: &str = "glibc/share/vulkan/icd.d/ull_freedreno.json";
/// 在 GPU 探测之前调用:装了就启用,没装就什么都不做。
pub(super) fn enable() {
if !cfg!(target_os = "linux") {
return;
}
if std::env::var_os("VK_ICD_FILENAMES").is_some()
|| std::env::var_os("VK_DRIVER_FILES").is_some()
{
return;
}
let Some(prefix) = termux::prefix(&ProcessEnv) else {
return;
};
let icd = icd_path(&prefix);
if icd.is_file() {
std::env::set_var("VK_ICD_FILENAMES", &icd);
std::env::set_var("VK_DRIVER_FILES", &icd);
}
}
/// `$PREFIX` 下随附 ICD 的路径(纯函数,测试用)。
fn icd_path(prefix: &Path) -> PathBuf {
prefix.join(ICD_REL)
}
#[cfg(test)]
mod tests {
use super::icd_path;
#[test]
fn icd_path_is_under_the_glibc_prefix() {
let path = icd_path(std::path::Path::new("/data/data/com.termux/files/usr"));
assert_eq!(
path,
std::path::PathBuf::from(
"/data/data/com.termux/files/usr/glibc/share/vulkan/icd.d/ull_freedreno.json"
)
);
}
}
@@ -0,0 +1,117 @@
//! Termux / termux-x11 下的窗口尺寸自适应(横屏优先)。
//!
//! 验收尺寸(1180×760)是 D30 的硬要求,不能改;但手机显示器可能横屏远大于它
//! (例如 2066×1272)或竖屏略小。这里只在**运行时**把「默认尺寸」换成按当前 X
//! 屏幕算出的最大等比矩形;`ULL_WINDOW_SIZE` 覆盖仍然优先(见 `acceptance::window_size`)。
//!
//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
/// 屏幕四周留白(像素)。
const MARGIN: f32 = 32.0;
/// 最大放大倍数:大屏上也不把窗口撑成巨屏。
const MAX_SCALE: f32 = 2.0;
/// `xdpyinfo` 里尺寸那一行的前缀。
const DIMENSIONS_PREFIX: &str = "dimensions:";
/// Termux 上的默认窗口尺寸:按 X 屏幕等比放大/缩小,失败返回 `None`(用调用方默认)。
///
/// `default` / `min` 由调用方传入([`super::WINDOW_SIZE`] / [`super::MIN_SIZE`]),
/// 这里不复制常量。
#[must_use]
pub(super) fn default_size(
default: (f32, f32),
min: (f32, f32),
) -> Option<((f32, f32), &'static str)> {
if !termux::is_termux(&ProcessEnv) || std::env::var_os("DISPLAY").is_none() {
return None;
}
let screen = x_screen_size()?;
Some((fit_screen(default, min, screen), "termux-screen"))
}
/// 在 `screen` 里放一个保持 `default` 宽高比的最大矩形(保留留白,不突破 `min`)。
#[must_use]
pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32)) -> (f32, f32) {
if screen.0 <= 0.0 || screen.1 <= 0.0 || default.0 <= 0.0 || default.1 <= 0.0 {
return default;
}
let available = (
(screen.0 - MARGIN * 2.0).max(1.0),
(screen.1 - MARGIN * 2.0).max(1.0),
);
let scale = (available.0 / default.0)
.min(available.1 / default.1)
.min(MAX_SCALE);
let width = (default.0 * scale).max(min.0).min(screen.0);
let height = (default.1 * scale).max(min.1).min(screen.1);
(width, height)
}
/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。
fn x_screen_size() -> Option<(f32, f32)> {
// 非 Windows 上 `quiet` 是原样返回(POSIX 没有「spawn 就新开一个终端」这回事);走它是为了
// 不在整棵 `crates/` 的子进程守卫里开一个「Termux 专用」例外(M217)。
let output = unlucky_core::platform::child::quiet("xdpyinfo")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let line = text
.lines()
.find(|line| line.trim_start().starts_with(DIMENSIONS_PREFIX))?;
let value = line.split_whitespace().nth(1)?;
let (width, height) = value.split_once('x')?;
Some((width.parse().ok()?, height.parse().ok()?))
}
#[cfg(test)]
mod tests {
use super::fit_screen;
const DEFAULT: (f32, f32) = (1180.0, 760.0);
const MIN: (f32, f32) = (880.0, 560.0);
fn close(actual: (f32, f32), expected: (f32, f32)) {
assert!(
(actual.0 - expected.0).abs() < 0.5 && (actual.1 - expected.1).abs() < 0.5,
"expected {expected:?}, got {actual:?}"
);
}
#[test]
fn landscape_screen_scales_up_keeping_aspect() {
// 2066×1272:留白后 2002×1208,高度是瓶颈 → 1875.6×1208。
close(
fit_screen(DEFAULT, MIN, (2066.0, 1272.0)),
(1875.58, 1208.0),
);
}
#[test]
fn portrait_screen_still_fits_width() {
// 1272×2066:宽度是瓶颈 → 1208×778。
close(fit_screen(DEFAULT, MIN, (1272.0, 2066.0)), (1208.0, 778.03));
}
#[test]
fn huge_screen_is_capped_at_two_times() {
close(fit_screen(DEFAULT, MIN, (4000.0, 3000.0)), (2360.0, 1520.0));
}
#[test]
fn tiny_screen_never_goes_below_min_or_overflow() {
let (width, height) = fit_screen(DEFAULT, MIN, (800.0, 600.0));
assert_eq!(width, 800.0, "宽度受屏幕限制");
assert_eq!(height, 560.0, "高度不小于最小尺寸");
}
#[test]
fn invalid_screen_falls_back_to_default() {
assert_eq!(fit_screen(DEFAULT, MIN, (0.0, 0.0)), DEFAULT);
}
}
+271
View File
@@ -0,0 +1,271 @@
//! `app/boot.rs` 的单元测试 + **变异验证说明**(M97 r1)。
//!
//! 放独立文件(同 `pages/harness/tests.rs`)是为了让 `boot.rs` 留在 400 行内。
//! ## 变异验证怎么做(M97 r1 的教训,改测试前先读这段)
//!
//! 这批测试的价值不是「跑绿了」,而是「把实现改坏会红」。做变异时**只在目标函数体内替换**,
//! 并且把测试里的同一字面量排除在外:
//!
//! ```text
//! # // 示意(真实脚本是一次性的,没入库)
//! def in_body(path, marker, changes):
//! """按函数体范围替换。**不能**全局 sed:那会连测试期望里的同一字面量一起改掉,
//! 于是实现和断言被换成同一个错值 ⇒ 测试照样绿 ⇒ 验证的是变异脚本自己。"""
//! ```
//!
//! M97 r1 第一次跑变异时,M1/M2/M3 三个变异「照样绿」——不是测试弱,是脚本用全局 `sed`
//! 把断言一起改了。这类假绿最阴:变异验证本来是我们判断「测试够不够」的终极手段,
//! 手段本身假绿时,它还会附赠「已验证」的强信心。
use super::fallback::*;
use super::*;
use crate::renderer::acceptance::WindowFacts;
use crate::renderer::decision;
use crate::renderer::Kind;
use std::ffi::{OsStr, OsString};
use std::path::Path;
fn probe(device_type: &'static str) -> crate::Probe {
crate::Probe {
adapters: vec![crate::probe::AdapterSummary {
name: "adapter".to_owned(),
device_type,
}],
hardware: !matches!(device_type, "Cpu" | "Other"),
}
}
/// **降级重开到底传了什么**(M97 r1 p2-1 的守门)。
///
/// 抓得住:`fallback_env` 少写/写错任一条——两条都是静默失效型:
/// 漏 `ICED_BACKEND` ⇒ 子进程又去试 wgpu(白开一次窗);漏 `ULL_GPU_FALLBACK` ⇒ **无限重开**。
/// 抓不住:`Command` 本身没被建起来(那要真起进程,见 §「Windows 实测」)。
#[test]
fn fallback_env_carries_the_backend_and_the_gate() {
for fallback in [Kind::TinySkia, Kind::Wgpu] {
let env = fallback_env(fallback);
assert_eq!(
env.len(),
2,
"就这两条,多一条少一条都要在这里改文档:{env:?}"
);
assert!(
env.contains(&("ICED_BACKEND", fallback.env_value())),
"子进程必须直接用 {fallback:?} 跑,实际 {env:?}"
);
assert!(
env.contains(&(renderer::FALLBACK_ENV, fallback.env_value())),
"缺防无限重开的闸门 {FALLBACK_ENV:?},实际 {env:?}",
FALLBACK_ENV = renderer::FALLBACK_ENV
);
}
}
/// 闸门的另一半:子进程那一轮的决策**不能再有兜底**,否则失败后继续 respawn ⇒ 无限递归。
///
/// 抓得住:`fallback_round` 的 `fallback` 被写成 `Some(..)`(自杀式循环)。
/// 抓不住:`forced_fallback()` 没被 `boot_probe` 调用(那是环境变量的读,见下一条的说明)。
#[test]
fn fallback_round_never_degrades_again() {
for kind in [Kind::TinySkia, Kind::Wgpu] {
let decision = decision::fallback_round(kind);
assert_eq!(decision.primary, kind);
assert_eq!(
decision.fallback, None,
"降级重开那一次不许再有兜底,否则会无限递归重开"
);
assert_eq!(decision.reason, "fallback-after-failure");
}
}
/// 判据表第 8 行 vs 第 9 行:**交互降级 / 取证拒绝**,两条路的策略不同。
///
/// 抓得住:`--screenshot` 分支被改回「也降级」(那正是 M97 要杀的「取证工具自己换后端」)。
/// 抓不住:`screenshot` 传进来的时机(那是 `run()` 的接线,见 Windows 实测记录)。
#[test]
fn evidence_start_refuses_instead_of_degrading() {
let auto = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), true);
let evidence = std::path::PathBuf::from("out.png");
assert_eq!(
on_failure(auto, Some(&evidence)),
Some(OnFailure::Refuse),
"取证启动 + wgpu 起不来 ⇒ 拒绝出图"
);
assert_eq!(
on_failure(auto, None),
Some(OnFailure::Respawn(Kind::TinySkia)),
"交互启动 + wgpu 起不来 ⇒ 换进程降级"
);
}
/// **构造侧**:argv 原样透传、两组 env 都在、program 就是传进来的那个。
///
/// 抓得住:重开时丢掉用户参数(`--screenshot` / `--screen` / `--data-dir` 全没了,
/// 子进程会按默认参数跑,那是最难查的一种「重开后行为变了」)、env 少传(M97 r1 的 p2-1)。
/// 抓不住:stdio 继承(`Command` 没有 stdio getter,只能真跑一次,见 `fallback_command` 的注释)。
#[test]
fn fallback_command_passes_program_argv_and_envs() {
let args = ["--renderer=auto", "--screenshot", "out.png"];
let command = fallback_command(
Path::new("unlucky-launcher-iced"),
args.iter().map(OsString::from),
Kind::TinySkia,
);
assert_eq!(
command.get_program(),
OsStr::new("unlucky-launcher-iced"),
"program 必须是传进来的那个(生产里是 current_exe)"
);
assert_eq!(
command.get_args().collect::<Vec<_>>(),
args.iter().map(OsStr::new).collect::<Vec<_>>(),
"argv 必须原样透传,一条不漏、顺序不变"
);
let envs: Vec<_> = command.get_envs().collect();
for (key, value) in fallback_env(Kind::TinySkia) {
assert!(
envs.contains(&(OsStr::new(key), Some(OsStr::new(value)))),
"重开命令缺 {key}={value},实际 {envs:?}"
);
}
}
/// **执行侧**:子进程退出 7 → 父进程拿到 7(不是 0、不是自己造的值)。
///
/// 抓得住:`exit_code` 被改成恒 0 / 恒 1 / 写成 `status.success().into()`。
/// 抓不住:生产里 `current_exe()` 那个 program(测试用假命令,见 `fallback_command` 注释)。
#[test]
fn fallback_command_really_passes_the_child_exit_code() {
#[cfg(windows)]
let (program, args) = ("cmd", vec!["/c", "exit", "7"]);
#[cfg(unix)]
let (program, args) = ("sh", vec!["-c", "exit 7"]);
let status = fallback_command(
Path::new(program),
args.iter().map(OsString::from),
Kind::TinySkia,
)
.status()
.expect("假子进程必须能起来");
assert_eq!(
exit_code(status.code()),
7,
"子进程退出 7,父进程必须透传 7;{status:?}"
);
}
/// 子进程**被信号杀死**(`code()` 为 `None`):走兜底码,不 panic、不当成功。
///
/// 这是纯函数测试,所以在 Windows 上也能跑(真造一个信号死亡的 `ExitStatus`
/// 需要 `ExitStatusExt`,只有 Unix 有;而这里测的正是「拿不到码」那一刻的行为)。
#[test]
fn signal_death_falls_back_to_a_nonzero_code() {
assert_eq!(exit_code(Some(0)), 0, "成功必须透传 0");
assert_eq!(exit_code(Some(7)), 7);
let fallback = exit_code(None);
assert_ne!(fallback, 0, "拿不到退出码绝不能当成功");
assert_eq!(fallback, 2, "兜底码与 main 的「参数或运行错误」一致");
}
/// 没有兜底可用的两行(强制 CPU / 未编 wgpu):失败就原样把错误交回去,不许 respawn。
#[test]
fn no_fallback_means_no_respawn() {
let cpu = decision::decide(crate::Backend::Cpu, &probe("IntegratedGpu"), true);
let evidence = std::path::PathBuf::from("out.png");
assert_eq!(cpu.fallback, None);
assert_eq!(on_failure(cpu, None), None);
assert_eq!(
on_failure(cpu, Some(&evidence)),
None,
"强制 CPU 也不该走到「拒绝降级」那条提示上(它压根没想上 GPU)"
);
let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false);
assert_eq!(on_failure(not_compiled, None), None);
}
/// **窗口设置就是验收日志里那三个字段的来源**(M98 r1 p2-1 的守门)。
///
/// 抓得住:把 `decorations` 改回 `true`(自绘三键退回系统装饰)、把 `resizable` / `transparent`
/// 打开 —— 这三种改动都会让 `[window]` 与 `[caps]` 两行与代码自相矛盾,而在此之前**371 个测试
/// 全绿**(复审 M6 变异实证)。现在它们必须红在这里。
/// 抓不住:winit 是否真按这些设置建窗(那要真机看窗口,见判据表「谁能验证」列)。
#[test]
fn window_settings_match_what_the_acceptance_logs_claim() {
let settings = diagnostics::window_settings((1180.0, 760.0));
assert!(
!settings.decorations,
"M93 自绘三键:窗口不能带系统装饰(否则 macOS 上会出现两套标题栏)"
);
assert!(
settings.resizable,
"验收要验「能否缩放」,窗口必须声明可缩放"
);
assert!(
!settings.transparent,
"整窗透明只在 win32 拿到句柄后下发(D36),窗口创建侧不许打开它"
);
assert_eq!(
settings.min_size,
Some(MIN_SIZE),
"最小尺寸档(880×560)是矩阵的一格,不能被改动"
);
// 日志确实从上面这个 settings 取值(而不是另抄一份字面量)
let facts = WindowFacts::from_settings(&settings);
let line = crate::renderer::acceptance::window_text(
&facts,
None,
"default",
Some(super::super::APP_TITLE),
);
assert!(line.contains("resizable=true"), "{line}");
assert!(line.contains("decorations=false"), "{line}");
assert!(line.contains("transparent=false"), "{line}");
assert!(line.contains("size=1180x760"), "{line}");
assert!(line.contains("min=880x560"), "{line}");
// 抽字段这一步必须**忠实**:拿一份处处相反的 settings 进来,facts 必须跟着反
// (抓的是「from_settings 里又写死一份常量」这条回退路 —— M98 r1 M7 变异实证它当年漏抓)
let odd = iced::window::Settings {
size: iced::Size::new(640.0, 480.0),
min_size: None,
decorations: true,
resizable: false,
transparent: true,
..iced::window::Settings::default()
};
assert_eq!(
WindowFacts::from_settings(&odd),
WindowFacts {
size: (640.0, 480.0),
min: None,
resizable: false,
decorations: true,
transparent: true,
},
"from_settings 必须逐字段搬运,不能在内部再写死一份常量"
);
// 反向:把事实翻个身,日志必须跟着变(证明没有第二份真值)
let flipped = WindowFacts {
decorations: true,
..facts
};
let line = crate::renderer::acceptance::window_text(
&flipped,
None,
"default",
Some(super::super::APP_TITLE),
);
assert!(
line.contains("decorations=true"),
"日志必须跟着 settings 走:{line}"
);
}
+169
View File
@@ -0,0 +1,169 @@
//! 启动期诊断行的**双写**:stderr + `<.ULL>/logs/launcher.log`(M188)。
//!
//! ## 为什么要有这一层
//!
//! 双击启动的 GUI 进程**没有 stderr 去向**(Windows 上根本没有控制台)。此前 `[font]` /
//! `[probe]` / `[theme]` / `[appearance]` / `[gpu]` 这些诊断只走 `eprintln!`,于是「启动器视角
//! 到底发生了什么」在事故之后一个字都不剩——用户投诉的「只有一个安装日志」有一半是它。
//!
//! 现在这些行同时进 `launch::launcher_log`:
//!
//! ```text
//! diag!("[font] 求值耗时 {:.0} ms", ms) → stderr: [font] 求值耗时 12 ms
//! → launcher.log: 2026-… [font] 求值耗时 12 ms
//! ```
//!
//! scope 就是行首那个 `[tag]`,所以日志行与老用法**长得一样**,`grep '\[gpu\]'` 仍然好使。
//!
//! ## 哪些行进日志、哪些只进 stderr
//!
//! 判据(M188 复审后收紧成这一句):**逐帧度量不接;显式取证开关与一次性事故行可接。**
//!
//! | 进日志 | 只进 stderr |
//! |---|---|
//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | **验收与出图矩阵的逐帧度量**:`[window]` / `[perf]` / `[caps]` / `[theme] source=`(`renderer::acceptance/**`,一次启动上百行) |
//! | **一次性事故行**:`[gpu] <backend> 初始化失败(具名)` / `[gpu] 失败详情(具名)`(`renderer::report_failure`)、`[gpu] iced_wgpu: …Selected: AdapterInfo…`(`log_bridge`)、`[probe] request=… -> ICED_BACKEND=…`(`probe::apply_with_preflight`)、`[gpu-preflight]`(`ULL_PREFLIGHT=1`) | 组件几何探针的断言输出(`components/**` 的 `[feedback-probe]` / gallery 页面里的 `[gallery]`) |
//! | 用户动作的结果(打开目录、写设置失败、更新忽略/应用、语言没落盘) | 每条日志行的正文里再套一层括号(不是标签,见 [`split_scope`]) |
//! | 显式开关下的一次性行:`[tick]`(`ULL_TICK_TRACE=1`,≈1 行/秒 ≈ 4.8 小时才滚一次)、`[shot]` 的通道提示 | — |
//!
//! 为什么逐帧行不许接:`launcher.log` 有 1 MiB 上限(滚成 `launcher.1.log`,只留一份),
//! 逐帧写会在几分钟内把「事故现场」冲掉。反过来,`report_failure` 那两行**就是**事故本体——
//! 双击启动没有 stderr,把它们留在 stderr 等于在最该有日志的路径上什么都没有(M188 复审 P2-1)。
/// 行首没有 `[tag]` 时的默认 scope。
pub const APP_SCOPE: &str = "app";
/// 记一行诊断:stderr 照旧,另外进启动器日志(best-effort,写不进去也不影响启动)。
pub(crate) fn emit(line: &str) {
eprintln!("{line}");
let (scope, message) = split_scope(line);
unlucky_core::launch::launcher_log::record(scope, message);
}
/// `[font] 求值耗时 12 ms` → (`font`, `求值耗时 12 ms`)。
///
/// scope 只认「短、无空白、非空」的方括号前缀:`[a b]` 这种是正文而不是标签,别把整行拆坏。
pub(crate) fn split_scope(line: &str) -> (&str, &str) {
let Some(rest) = line.strip_prefix('[') else {
return (APP_SCOPE, line);
};
let Some((scope, tail)) = rest.split_once(']') else {
return (APP_SCOPE, line);
};
if scope.is_empty() || scope.len() > 24 || scope.contains(char::is_whitespace) {
return (APP_SCOPE, line);
}
(scope, tail.trim_start())
}
/// 双写一行诊断(`diag!("[scope] 正文")`,参数同 `eprintln!`)。
macro_rules! diag {
($($arg:tt)*) => {{
crate::app::diag::emit(&format!($($arg)*));
}};
}
pub(crate) use diag;
/// 串行化「会改 `launcher_log` 全局目录」的用例。
///
/// `core::launch::launcher_log` 是**进程级单例**(目录 + 缓冲),而 app-iced 的测试二进制里
/// 有两条用例会 `set_dir` 到自己的临时目录(本文件的滚动闸、`app/dialog/tests.rs` 的启动事实闸)。
/// 它们必须串行,否则互相把对方的落点改走——那是两条闸互相打架,不是产品问题。
#[cfg(test)]
pub(crate) static LOG_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::split_scope;
#[test]
fn the_leading_bracket_becomes_the_scope() {
assert_eq!(
split_scope("[font] 求值耗时 12 ms"),
("font", "求值耗时 12 ms")
);
assert_eq!(split_scope("[gpu]"), ("gpu", ""));
// 正文里的方括号不是标签
assert_eq!(
split_scope("[page] 占位动作 [x] pressed"),
("page", "占位动作 [x] pressed")
);
}
#[test]
fn lines_without_a_tag_fall_back_to_the_default_scope() {
assert_eq!(split_scope("没有标签的一行"), ("app", "没有标签的一行"));
assert_eq!(
split_scope("[a b] 空白不是标签"),
("app", "[a b] 空白不是标签")
);
}
/// 守门②:**启动器日志的滚动真的会发生**,写超上限之后文件仍然有界。
///
/// ## 为什么要「重试」
///
/// `launcher_log` 是**进程级单例**(目录 + 缓冲),而同进程里还有一条用例
/// (`app/dialog/tests.rs` 的 `boot_facts_are_recorded_into_the_launcher_log`)会
/// `set_dir` 到它自己的临时目录。两边撞上时我这几行的落点就换了人,所以每一轮都要
/// **从头跑一遍**(登记 → 造超限 → 追加 → 看有没有滚),并且用「我这份超限文件有没有被
/// rename」判断这一轮是不是真的跑在我目录上。重试不是放宽断言:每轮该发生的都得真发生。
///
/// 变异证明:删掉 `core::launch::launcher_log::append` 里那段「超限就 rename 成 launcher.1.log」,
/// 每一轮都不会出现 `launcher.1.log`,本条在重试用尽后红。
#[test]
fn the_launcher_log_rolls_over_and_stays_bounded() {
use unlucky_core::launch::launcher_log;
let _guard = super::LOG_STATE_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let dir = std::env::temp_dir().join(format!("ull-launcher-log-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建夹具目录");
let path = dir.join(launcher_log::FILE_NAME);
let rolled = dir.join("launcher.1.log");
let oversize = usize::try_from(launcher_log::MAX_BYTES).expect("上限能塞进 usize") + 16;
for _ in 0..50 {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&rolled);
launcher_log::set_dir(&dir);
if launcher_log::path() != Some(path.clone()) {
continue; // 这一轮被别人的 set_dir 抢先了
}
launcher_log::record("boot", "第一行");
std::fs::write(&path, vec![b'x'; oversize]).expect("造一份超限日志");
if launcher_log::path() != Some(path.clone()) {
continue;
}
launcher_log::record("boot", "滚过之后这一行必须在新文件里");
if launcher_log::path() != Some(path.clone()) {
continue; // 追加落到了别人的目录,这一轮不算
}
// 到这儿为止,追加**确实**落在本目录:超限就必须滚。
assert!(rolled.is_file(), "超限之后旧档必须滚成 launcher.1.log");
let size = std::fs::metadata(&path).expect("新文件").len();
assert!(
size < launcher_log::MAX_BYTES,
"滚完之后新文件必须远小于上限:{size}"
);
let text = std::fs::read_to_string(&path).expect("读新文件");
assert!(text.contains("滚过之后这一行"), "{text}");
// 只留一份历史:再滚一次仍然是 `launcher.1.log`,不会长出 launcher.2.log。
std::fs::write(&path, vec![b'y'; oversize]).expect("再造一份超限日志");
launcher_log::record("boot", "第二次滚动");
assert!(rolled.is_file());
assert!(!dir.join("launcher.2.log").exists(), "历史只留一份");
assert!(std::fs::metadata(&path).expect("新文件").len() < launcher_log::MAX_BYTES);
std::fs::remove_dir_all(&dir).ok();
return;
}
panic!("50 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来");
}
}
+390
View File
@@ -0,0 +1,390 @@
//! 模态对话框(M187 接通 `DialogQueue`)+ 启动失败卡片。
//!
//! ## 为什么先补这一层
//!
//! 接线审计(M177)的结论很硬:`DialogQueue` / `DialogRequest` 在 app-iced 里**零引用**,
//! 所有弹窗都画不出来。于是「像 PCL2 / HMCL 那样,启动失败时弹一个带崩溃说明的框」这件事
//! 在此之前根本无处落脚。本模块是最小可用的一层:
//!
//! ```text
//! presenter AppState::dialogs(唯一队列)
//! │ current()
//! ▼
//! dialog::current(lang, request, card) ← 纯视图模型(测试打这里)
//! │
//! ▼
//! dialog::view::layer(ui, base, modal) ← 模态遮罩 + 卡片 + 主/次按钮
//! ```
//!
//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。
//!
//! ## 崩溃卡片额外带什么
//!
//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**:
//! 退出码、一句人话原因、**文件路径**、可展开的崩溃说明、以及三个按钮(导出 / 打开文件夹 /
//! 复制路径)。这些东西不进 `DialogRequest`——它是 presenter 的公共 DTO,塞进 UI 专用字段
//! 会把「谁拥有证据」搅浑。做法是:队列管**弹窗**,[`CrashCard`] 管**这次失败的事实**,
//! 两者按 `DialogId` 绑定,见 [`App::open_failure_dialog`]。
//!
//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它,
//! 不重算:**界面不读盘、不猜原因**。
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogId, DialogKind, DialogSource};
use crate::app::state::App;
pub(super) mod export;
#[cfg(test)]
mod tests;
pub(super) mod view;
/// 崩溃卡片:一次失败的**事实**(不含任何结论)。
#[derive(Debug, Clone)]
pub struct CrashCard {
/// 现场。
pub report: CrashReport,
/// 「详细崩溃说明」是否展开。
pub expanded: bool,
/// 与哪一条队列请求绑定(关掉它时把卡片一起清掉)。
pub dialog: DialogId,
/// 上一次动作的结论(导出到哪 / 打不开文件夹的原因)。
///
/// 为什么写在卡片里而不是只推 Toast:app-iced 的 Toast 通道**至今没有任何渲染点**
/// (`state.toasts` 只被推入与过期,`view` 里一次都没读)。导出这种「必须看见落到哪」的
/// 结果不能挂在一条不显示的通道上。
pub outcome: Option<String>,
}
/// 弹窗消息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
/// 主按钮。
Primary,
/// 次按钮。
Secondary,
/// 点遮罩 / Esc。
Dismiss,
/// 展开 / 收起详细说明。
Toggle,
/// 打开日志文件夹。
OpenFolder,
/// 复制全部路径。
CopyPaths,
/// 复制第 `index` 条路径(`Modal::paths` 的下标)。
CopyPath(usize),
}
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct Modal {
/// 标题。
pub title: String,
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
pub body: Vec<String>,
/// 主按钮文字。
pub primary: String,
/// 次按钮文字(`None` = 不画)。
pub secondary: Option<String>,
/// 是否有次按钮(`Progress` 没有)。
pub blocking_progress: bool,
/// 崩溃卡片(普通弹窗为 `None`)。
pub card: Option<CardView>,
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
pub notice: Option<String>,
}
/// 崩溃卡片的展示内容。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct CardView {
/// 展开 / 收起按钮的文字。
pub toggle: String,
/// 当前的展开态。
pub expanded: bool,
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
pub paths: Vec<(String, String)>,
/// 详细说明的小标题(`None` = 没有可展开的内容)。
pub detail_title: Option<String>,
/// 详细说明的正文行。
pub detail_lines: Vec<String>,
/// 三个按钮的文字。
pub actions: [String; 3],
}
impl Modal {
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
#[must_use]
#[cfg(test)]
pub fn body_text(&self) -> String {
self.body.join("\n")
}
}
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
#[must_use]
pub(super) fn current(
lang: Lang,
request: &DialogRequestView<'_>,
card: Option<&CrashCard>,
) -> Modal {
let catalog = Catalog::of(lang);
let mut modal = Modal {
title: request.title.to_string(),
body: vec![request.body.to_string()],
primary: request.primary.to_string(),
secondary: request.secondary.map(str::to_string),
blocking_progress: request.blocking_progress,
card: None,
notice: None,
};
if let Some(card) = card {
let detail = detail_of(&catalog, card);
modal.body = failure_body(&catalog, &card.report);
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
modal.card = Some(CardView {
toggle: catalog
.text(if card.expanded {
Key::CrashDialogHideDetail
} else {
Key::CrashDialogShowDetail
})
.to_string(),
expanded: card.expanded,
paths: path_rows(&catalog, &card.report),
detail_title: detail.0,
detail_lines: detail.1,
actions: [
catalog.text(Key::CrashDialogExport).to_string(),
catalog.text(Key::CrashDialogOpenFolder).to_string(),
catalog.text(Key::CrashDialogCopyPaths).to_string(),
],
});
modal.notice = card.outcome.clone();
}
modal
}
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
pub(super) struct DialogRequestView<'a> {
/// 标题。
pub title: &'a str,
/// 正文。
pub body: &'a str,
/// 主按钮。
pub primary: &'a str,
/// 次按钮。
pub secondary: Option<&'a str>,
/// 进度框不可点主按钮。
pub blocking_progress: bool,
}
impl<'a> DialogRequestView<'a> {
/// 从 presenter 的请求取视图。
pub(super) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
Self {
title: &request.title,
body: &request.body,
primary: request.kind.primary_label(),
secondary: request.kind.secondary_label(),
blocking_progress: request.kind.is_blocking_progress(),
}
}
}
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
///
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
let mut body = Vec::new();
if let Some(code) = report.exit_code {
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
}
if let Some(stage) = &report.stage {
let label = stage_label(catalog, stage);
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
} else if report.exit_code == Some(0) {
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
} else {
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
}
body.push(
match report.evidence() {
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
}
.to_string(),
);
body
}
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
let key = match slug {
"version_manifest" => Key::LaunchStageVersionManifest,
"inheritance" => Key::LaunchStageInheritance,
"instance_config" => Key::LaunchStageInstanceConfig,
"isolation" => Key::LaunchStageIsolation,
"identity" => Key::LaunchStageIdentity,
_ => Key::LaunchStageSpawn,
};
catalog.text(key)
}
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
let mut rows = Vec::new();
if let Some(log) = &report.session_log {
rows.push((
catalog.text(Key::CrashDialogSessionLog).to_string(),
log.display().to_string(),
));
}
if let Some(dir) = &report.launcher_logs {
rows.push((
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
dir.display().to_string(),
));
}
if let Some(path) = report.latest_log_path() {
rows.push((
catalog.text(Key::CrashDialogGameLog).to_string(),
path.display().to_string(),
));
}
if let Some(dir) = report.crash_reports_dir() {
rows.push((
catalog.text(Key::CrashDialogCrashReports).to_string(),
dir.display().to_string(),
));
}
rows
}
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
let report = &card.report;
if let Some(artifact) = report
.artifacts
.iter()
.find(|artifact| artifact.source == "crash_report")
{
let mut lines = Vec::new();
if let Some(excerpt) = &artifact.excerpt {
lines.extend(excerpt.lines().map(str::to_string));
} else {
lines.push(artifact.path.display().to_string());
}
let title = format!(
"{} {}",
catalog.text(Key::LogsDiagnosisTitle),
artifact.path.display()
);
return (Some(title), lines);
}
if report.stderr_tail.is_empty() {
return (None, Vec::new());
}
let title = catalog.render(
Key::CrashDialogCapturedTail,
&[("count", &report.stderr_tail.len().to_string())],
);
(Some(title), report.stderr_tail.clone())
}
// --------------------------------------------------------------- 与 App 的接线
impl App {
/// 队列当前条 → 视图模型(`view()` 与测试共用同一个入口)。
pub(super) fn dialog_modal(&self) -> Option<Modal> {
let request = self.state.dialogs.current()?;
let view = DialogRequestView::of(request);
let card = self
.dialog_card
.as_ref()
.filter(|card| card.dialog == request.id);
Some(current(self.lang(), &view, card))
}
/// 收到一次启动收尾:失败就弹框(成功不打扰)。
///
/// 只在**真的失败**时弹:`LogOutcome::Ok` 且没有阶段失败 ⇒ 什么都不做,与既有 Toast 口径一致。
pub(super) fn open_failure_dialog(
&mut self,
finished: &unlucky_presenter::dto::LaunchFinished,
) {
let failed = finished.outcome != unlucky_presenter::dto::LogOutcome::Ok
|| finished.failure.is_some();
if !failed {
return;
}
let Some(report) = unlucky_core::launch::crash_report::failing_launch() else {
return;
};
if !finished.session.is_empty() && report.session != finished.session {
// 登记的是另一场(并发启动):不拿它去解释这一场。
return;
}
let catalog = self.lang().catalog();
let crashed = report.started();
let title = catalog.text(if crashed {
Key::CrashDialogTitleCrashed
} else {
Key::CrashDialogTitleFailed
});
let summary = self.dialog_summary(&report);
let at = self.lang();
let id = self.state.dialogs.push(
DialogSource::UserAction,
title,
summary,
DialogKind::Confirm {
primary: Catalog::of(at).text(Key::CrashDialogExport).to_string(),
secondary: Catalog::of(at).text(Key::CrashDialogClose).to_string(),
},
);
self.dialog_card = Some(CrashCard {
report,
// 默认展开:正文已经说「下面是捕获的最后若干行」,收着就成了空口承诺。
expanded: true,
dialog: id,
outcome: None,
});
self.view_dirty = true;
}
/// 队列标题下的那一行摘要(弹窗正文由 [`current`] 按卡片重算,这里给它一份独立可读的短句)。
fn dialog_summary(&self, report: &CrashReport) -> String {
let catalog = self.lang().catalog();
match report.exit_code {
Some(code) => catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]),
None => catalog.text(Key::CrashDialogTitleFailed).to_string(),
}
}
/// 关掉当前弹窗(主/次按钮、遮罩、`Esc` 共用)。
pub(super) fn close_dialog(&mut self) {
let closed = self.state.dialogs.current().map(|request| request.id);
self.state.dialogs.close_current();
if self
.dialog_card
.as_ref()
.is_some_and(|card| closed == Some(card.dialog))
{
self.dialog_card = None;
}
self.view_dirty = true;
}
/// 展开 / 收起详细说明。
pub(super) fn toggle_dialog_detail(&mut self) {
if let Some(card) = &mut self.dialog_card {
card.expanded = !card.expanded;
self.view_dirty = true;
}
}
}
+217
View File
@@ -0,0 +1,217 @@
//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。
//!
//! 与 [`super`] 分开是因为职责不同:那边管**弹窗与事实**,这边管**把事实带出界面**。
//! 打包本体在 `unlucky_core::launch::crash_bundle`(脱敏、上限、清单都在那一处),
//! 本文件只负责「放哪、叫什么名字、怎么打开」。
use std::path::PathBuf;
use unlucky_core::launch::crash_bundle::{self, BundleEntry, BundleSpec};
use unlucky_core::launch::crash_report::CrashReport;
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
/// 一次导出的结论(**成功也带事实**:路径、字节数、有没有文件被截断)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Exported {
/// 给用户看的那一句(已本地化)。
pub text: String,
/// 是否成功。
pub ok: bool,
/// 有几个文件被截断(`>0` 时界面要明说「证据是残的」)。
pub truncated: usize,
}
/// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。
///
/// 返回的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」);
/// 有文件被截断时再补一句——别让用户以为包里的证据是完整的。
pub(crate) fn export(report: &CrashReport, lang: Lang) -> Exported {
let catalog = Catalog::of(lang);
let Some(logs) = &report.launcher_logs else {
return Exported {
text: catalog.render(
Key::CrashDialogExportFailed,
&[("error", "no launcher log dir")],
),
ok: false,
truncated: 0,
};
};
let stamp =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now())
.replace([':', '.', 'T'], "-")
.trim_end_matches('Z')
.to_string();
let name = if report.session.is_empty() {
&report.instance
} else {
&report.session
};
let target = crash_bundle::default_target_dir(logs).join(format!("crash-{name}-{stamp}.zip"));
match crash_bundle::write_bundle(&spec_of(report), &target) {
Ok(bundle) => {
let mut text = catalog.render(
Key::CrashDialogExportDone,
&[
("bytes", &bundle.bytes.to_string()),
("path", &bundle.path.display().to_string()),
],
);
if bundle.truncated > 0 {
text.push('\n');
text.push_str(&catalog.render(
Key::CrashDialogExportTruncated,
&[("count", &bundle.truncated.to_string())],
));
}
Exported {
text,
ok: true,
truncated: bundle.truncated,
}
}
Err(error) => Exported {
text: catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]),
ok: false,
truncated: 0,
},
}
}
/// 现场 → 诊断包清单(**进包的东西必须与弹窗上列出的路径一致**)。
fn spec_of(report: &CrashReport) -> BundleSpec {
let mut entries: Vec<BundleEntry> = Vec::new();
let push = |entries: &mut Vec<BundleEntry>, group: &'static str, path: &std::path::Path| {
if path.exists() {
entries.push(BundleEntry::new(group, path));
}
};
if let Some(logs) = &report.launcher_logs {
push(&mut entries, "launcher", &logs.join("launcher.log"));
push(&mut entries, "launcher", &logs.join("launcher.1.log"));
push(&mut entries, "launcher", &logs.join("install.log"));
}
if let Some(session) = &report.session_log {
// 源文件名就是会话 id:钉成 `session.jsonl`,包内名与清单名同时不再泄漏 id,
// 也不会出现「清单写 <redacted:token> 而包里是原始 id」(r1 复审 P2-2)。
if session.exists() {
entries.push(BundleEntry::named("session", session, "session.jsonl"));
}
}
for artifact in &report.artifacts {
// `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。
if artifact.source != "latest_log" {
push(&mut entries, "game", &artifact.path);
}
}
if let Some(latest) = report.latest_log_path() {
push(&mut entries, "game", &latest);
}
if let Some(json) = &report.version_json {
push(&mut entries, "instance", json);
}
let mut secrets = vec![report.session.clone()];
secrets.retain(|secret| !secret.is_empty());
BundleSpec {
instance: report.name.clone(),
version: report.version.clone(),
session: report.session.clone(),
exit_code: report.exit_code,
exit_type: report.exit_type.clone(),
uptime_ms: report.uptime_ms,
command: report.command.clone(),
java: report.java.clone(),
system: report.system.clone(),
notes: report.note_lines(),
missing: report.missing_lines(),
entries,
secrets,
home: std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.ok()
.map(PathBuf::from),
}
}
/// 打开「日志文件夹」:有现场就开实例运行目录,否则开启动器日志目录。
///
/// 平台差异只在这里一处(`explorer` / `open` / `xdg-open`),不往业务代码里散写 `cfg`。
pub(crate) fn open_folder(report: &CrashReport, lang: Lang) -> String {
let catalog = Catalog::of(lang);
let target = report
.run_dir
.clone()
.filter(|dir| dir.is_dir())
.or_else(|| report.launcher_logs.clone().filter(|dir| dir.is_dir()));
let Some(dir) = target else {
return catalog.render(
Key::CrashDialogOpenFailed,
&[("error", "no folder on disk")],
);
};
match open_dir(&dir) {
Ok(()) => String::new(),
Err(error) => catalog.render(Key::CrashDialogOpenFailed, &[("error", &error)]),
}
}
/// 打开一个目录(M188:设置页/日志页的「打开日志文件夹」也走这里,**不再另写一套 `cfg`**)。
///
/// 失败文本带完整路径:调用方把它显示/记日志出来,用户至少能照着路径去找。
pub(crate) fn open_dir(dir: &std::path::Path) -> Result<(), String> {
if !dir.is_dir() {
return Err(format!("{}(目录不存在)", dir.display()));
}
spawn_opener(dir).map_err(|error| format!("{}(打开失败:{error})", dir.display()))
}
/// 平台各自的「打开这个目录」实现。
///
/// ## 为什么这三处**故意**不走 `unlucky_core::platform::child::quiet`(M217)
///
/// 这一处 spawn 的**目的就是让桌面上出现一个可见的东西**(文件管理器窗口 / Finder 窗口 /
/// 桌面环境的文件管理器)。`quiet` 的语义是「把子进程的窗口抑制掉」,用在这里是把目的本身
/// 抑制掉——改错了用户点「打开文件夹」就只看到「打不开」的报错。
///
/// 而且从窗口角度也不需要它:`explorer.exe` / `open` / `xdg-open` 自己都是 GUI 侧程序,由
/// 无控制台的 GUI 父进程 spawn 时 Windows 不会给它们新建控制台。所以这三处在
/// `crates/core/tests/child_quiet_guard.rs` 的 `ALLOWED_ROLES` 里登记成「目录打开器」角色,
/// **不是**漏网。别把它们改成 `quiet`。
#[cfg(target_os = "windows")]
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
// `explorer` 不用 shell:路径里的空格与 `&` 不会被当成命令分隔符。
std::process::Command::new("explorer")
.arg(dir)
.spawn()
.map(|_| ())
.map_err(|error| error.to_string())
}
#[cfg(target_os = "macos")]
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
std::process::Command::new("open")
.arg(dir)
.spawn()
.map(|_| ())
.map_err(|error| error.to_string())
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
std::process::Command::new("xdg-open")
.arg(dir)
.spawn()
.map(|_| ())
.map_err(|error| error.to_string())
}
/// 「复制路径」的一段文本(每条一行,标签 + 路径)。
pub(crate) fn paths_text(report: &CrashReport) -> String {
report
.path_lines()
.into_iter()
.map(|(label, path)| format!("{label}: {}", path.display()))
.collect::<Vec<_>>()
.join("\n")
}
+344
View File
@@ -0,0 +1,344 @@
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
//!
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
//!
//! | 用例 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`queue_drives_the_modal_through_refresh_view`] | 队列里有请求 ⇒ `refresh_view` 之后 `view()` 拿到的模型里就有它(把消费点删掉即红) | 像素:`view()` 是否真的把控件塞进 iced 树(由 `DevDocs/artifacts/m187/` 的截图守) |
//! | [`runtime_chinese_reaches_the_body`] | 运行时拼出来的中文(`render` / `format!` 的 owned 串)确实在正文里 | 字形是否栅格化出来(同上,wgpu / tiny-skia 并排图) |
//! | [`two_failure_shapes_say_two_different_things`] | 没有 crash-report 时**不会**说成「有崩溃报告」,而且必须贴出捕获行 | 那句话读起来顺不顺(review) |
//!
//! 「owned 文字在 wgpu 下丢字」这条 M190 的怀疑在本 branch 上**没有复现**(A/B 图见
//! `DevDocs/artifacts/m187/ab-wgpu.png` 与 `ab-tinyskia.png`):同一串中文走 `text_role` 与
//! `text_role_owned` 在两种后端下逐字相同。所以这里不编一条「为了变绿而改渲染路径」的断言。
use std::path::PathBuf;
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use super::{current, path_rows, CrashCard, DialogRequestView};
use crate::app::state::App;
use crate::cli::Options;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn headless() -> App {
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
App::with_presenter(
Options::default(),
FAMILIES,
unlucky_design::color::SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
fn report(with_crash_report: bool, tail: Vec<String>) -> CrashReport {
let run_dir = PathBuf::from("/instances/survival");
let artifacts = if with_crash_report {
vec![ArtifactFact {
source: "crash_report".to_string(),
path: run_dir.join("crash-reports/crash-2026-11-08.txt"),
excerpt: Some("Description: Initializing game".to_string()),
}]
} else {
Vec::new()
};
CrashReport {
instance: "survival".to_string(),
name: "生存".to_string(),
version: "1.20.4-fabric".to_string(),
session: "9f2c".to_string(),
session_log: Some(PathBuf::from("/sessions/game/9f2c.jsonl")),
version_json: Some(PathBuf::from("/game/versions/1.20.4/1.20.4.json")),
launcher_logs: Some(PathBuf::from("/logs")),
run_dir: Some(run_dir),
artifacts,
exit_code: Some(1),
exit_type: "application_error".to_string(),
uptime_ms: 1_000,
command: "javaw -Xmx4096m".to_string(),
java: "javaw.exe (21)".to_string(),
system: "os=windows".to_string(),
stage: None,
stage_detail: None,
stderr_tail: tail,
}
}
/// 守卫①:队列消费。请求进队列 ⇒ `refresh_view` 之后模型里有它;空队列 ⇒ 没有。
///
/// **变异证明**:把 `refresh_view` 末尾那行 `self.dialog_model = self.dialog_modal();` 删掉,
/// 本用例第二段立刻红(`dialog_model` 恒 `None`)。
#[test]
fn queue_drives_the_modal_through_refresh_view() {
let mut app = headless();
app.refresh_view();
assert!(
app.dialog_model.is_none(),
"空队列不该画任何弹窗(现状:队列零引用,弹窗从不渲染)"
);
let title = app.lang().catalog().text(Key::CrashDialogTitleFailed);
app.state.dialogs.push(
DialogSource::UserAction,
title,
"正文一行",
DialogKind::Confirm {
primary: "主要".to_string(),
secondary: "次要".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
let modal = app.dialog_model.as_ref().expect("队列里有请求就必须有模型");
assert_eq!(modal.title, title);
assert_eq!(modal.primary, "主要");
assert_eq!(modal.secondary.as_deref(), Some("次要"));
}
/// 守卫②:**运行时拼出来的中文**必须真的进正文(owned 串走 `Catalog::render` / `format!`)。
///
/// **变异证明**:把 `failure_body` 里的 `catalog.render(Key::CrashDialogExitCode, …)` 换成固定
/// 英文,本用例第一处断言立刻红。
#[test]
fn runtime_chinese_reaches_the_body() {
let lang = Lang::ZhCn;
let card = CrashCard {
report: report(false, vec!["java.lang.RuntimeException: boom".to_string()]),
expanded: true,
dialog: 1,
outcome: None,
};
let mut queue = DialogQueue::new();
queue.push(
DialogSource::UserAction,
"游戏崩溃",
"",
DialogKind::Confirm {
primary: String::new(),
secondary: String::new(),
},
);
let request = queue.current().expect("当前条");
let view = DialogRequestView::of(request);
let modal = current(lang, &view, Some(&card));
let body = modal.body_text();
// 退出码那句是 `render`(带参数)拼出来的:必须带中文与数字。
assert!(body.contains("退出码 1"), "{body}");
assert!(body.contains("游戏进程起来了"), "{body}");
// 没有 crash-report 时必须说「没生成」+「下面是捕获的行」。
assert!(body.contains("没有生成崩溃报告"), "{body}");
assert!(body.contains("捕获"), "{body}");
// 英文界面里同样的三句不许出现中文(反向守卫:i18n 不是只往一个方向写)。
let en = current(Lang::En, &view, Some(&card));
assert!(!en.body_text().contains('崩'), "{}", en.body_text());
}
/// 守卫③:两种失败**说两句不同的话**,而且都可以导出(事实齐全)。
#[test]
fn two_failure_shapes_say_two_different_things() {
let lang = Lang::ZhCn;
let with = report(true, vec!["stack line".to_string()]);
let without = report(false, vec!["Error: could not find java.dll".to_string()]);
assert_ne!(with.evidence(), without.evidence());
let with_card = CrashCard {
report: with.clone(),
expanded: true,
dialog: 1,
outcome: None,
};
let without_card = CrashCard {
report: without.clone(),
expanded: true,
dialog: 1,
outcome: None,
};
let mut queue = DialogQueue::new();
queue.push(
DialogSource::UserAction,
"t",
"",
DialogKind::Error {
primary: "p".to_string(),
hint: None,
},
);
let request = queue.current().unwrap();
let view = DialogRequestView::of(request);
let crash = current(lang, &view, Some(&with_card));
let no_log = current(lang, &view, Some(&without_card));
assert_ne!(crash.body_text(), no_log.body_text());
assert!(crash.body_text().contains("写出了崩溃报告"));
assert!(!no_log.body_text().contains("写出了崩溃报告"));
// 没有 crash-report 的那一态仍然有可展开的细节(贴的是捕获行)与四条路径。
assert!(no_log.card.as_ref().expect("卡片").detail_title.is_some());
assert_eq!(path_rows(&lang.catalog(), &without).len(), 4);
assert!(path_rows(&lang.catalog(), &without)
.iter()
.any(|(_, path)| path.ends_with("latest.log")));
}
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
#[test]
fn closing_the_dialog_clears_the_card() {
let mut app = headless();
crash_report::publish(report(false, vec!["line".to_string()]));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 1_000,
failure: None,
};
app.open_failure_dialog(&finished);
assert!(app.state.dialogs.current().is_some(), "失败必须弹框");
assert!(app.dialog_card.is_some());
app.close_dialog();
assert!(app.state.dialogs.current().is_none());
assert!(app.dialog_card.is_none());
}
/// 成功收尾不打扰用户。
#[test]
fn a_successful_launch_does_not_pop_anything() {
let mut app = headless();
crash_report::publish(report(false, Vec::new()));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::Ok,
exit_code: Some(0),
uptime_ms: 90_000,
failure: None,
};
app.open_failure_dialog(&finished);
assert!(app.state.dialogs.current().is_none());
}
/// P2-1:**卡片那一层只能是卡片大**。
///
/// 抓得住什么:r1 复审实测的那个 bug —— `mouse_area(整窗居中容器)` 的命中区是整窗,
/// 卡片外任何一次点击都先被它捕获,遮罩永远收不到(「点遮罩关闭」形同虚设)。
/// `Widget::size_hint()` 不需要渲染器就能问,所以这条能在单测里跑:卡片层若声明
/// `Fill`/`Fill`,就等于「命中区 = 整窗」。
///
/// **抓不住什么**:端到端指针派发(本仓没有事件 harness,`Element` 的 `layout`/`update`
/// 需要真渲染器)。所以这条守的是**结构属性**,不是「点一下真的关了」。
#[test]
fn the_card_layer_is_card_sized_so_the_backdrop_can_be_clicked() {
use iced::Length;
let mut app = headless();
app.state.dialogs.push(
DialogSource::UserAction,
"t",
"b",
DialogKind::Confirm {
primary: "p".to_string(),
secondary: "s".to_string(),
},
);
let modal = {
let request = app.state.dialogs.current().expect("当前条");
let view = DialogRequestView::of(request);
super::current(app.lang(), &view, None)
};
let plane = super::view::card_plane(app.ui(), &modal);
let hint = iced::advanced::Widget::size_hint(plane.as_widget());
// 任一维是 `Fill` 就意味着「跟随父级 = 整窗」(外层是 Fill 的居中容器),
// 命中区于是变成整窗 —— 那正是 r1 抓到的原实现。
assert_ne!(
hint.width,
Length::Fill,
"卡片层宽度不能 Fill(会撑成整窗)"
);
assert_ne!(
hint.height,
Length::Fill,
"卡片层高度不能 Fill(会撑成整窗)"
);
}
/// P1-1:遮罩必须是**不透明预合成**,而且两档都**压暗**(深色档尤其)。
///
/// 抓得住 r1 实测的那个 p1:`Role::OnSurface @ 32%` 在深色档是「近白 @32%」⇒ 遮罩把整窗
/// 提亮(实测 (9,21,28) → (129,138,145))。这里用亮度直接判方向:遮罩必须比画布暗。
///
/// 抓不住:具体色值对不对(那是令牌层的事)。
#[test]
fn the_backdrop_is_an_opaque_precomposite_and_darkens_both_flavors() {
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
let luma = |color: iced::Color| 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES);
// 断言**视图真正用的那一份**(不是组件层的默认值):改回「OnSurface @32%」这里就红。
let backdrop = super::view::backdrop_color(&ui);
assert_eq!(
backdrop.a, 1.0,
"{flavor:?}:遮罩必须是不透明预合成(D27:CPU 档零半透明)"
);
let canvas = crate::theme::to_iced(
unlucky_design::color::Scheme::baseline(flavor)
.get(MaterialLayer::Canvas.surface_role()),
);
assert!(
luma(backdrop) < luma(canvas),
"{flavor:?}:遮罩必须比画布暗(当前 lane {:.3} vs 画布 {:.3})—— 用 Role::OnSurface 当遮罩会在深色档把整窗提亮",
luma(backdrop),
luma(canvas)
);
assert_eq!(Role::Surface, MaterialLayer::Canvas.surface_role());
}
}
/// P2-4:启动那一拍的**事实**确实进了启动器日志(登记目录后一次刷出)。
///
/// 抓得住:记录函数被拆掉/改空(缓冲里就什么都没有)。
/// 抓不住:`on_boot` 有没有调它 —— 那靠这条用例旁边的调用点与 `host/launch.rs` 的 `set_dir`,
/// 真机第一次崩溃才是最终验收闸门(复审与我的一致口径)。
#[test]
fn boot_facts_are_recorded_into_the_launcher_log() {
// 与 `app/diag.rs` 的滚动闸串行:两条用例都动同一个进程级单例目录。
let _guard = crate::app::diag::LOG_STATE_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let dir = std::env::temp_dir().join(format!("ull-m187-bootlog-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建目录");
unlucky_core::launch::launcher_log::set_dir(&dir);
let app = headless();
crate::app::update::boot_env::record_boot_log(&app);
let path = dir.join(unlucky_core::launch::launcher_log::FILE_NAME);
let text = std::fs::read_to_string(&path).expect("launcher.log");
assert!(text.contains("[boot] app boot"), "{text}");
assert!(text.contains("theme="), "{text}");
assert!(text.contains("window="), "{text}");
}
#[path = "tests/export.rs"]
mod export;
@@ -0,0 +1,85 @@
//! 导出的用例:真的落盘、真的报字节数、真的在有截断时说一句。
//!
//! 从 `tests.rs` 拆出(那个文件贴着 400 行硬线)。共用父模块的 `report()` 夹具。
use unlucky_i18n::Lang;
use super::report;
use crate::app::dialog::export;
/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。
#[test]
fn export_writes_a_bundle_even_without_a_crash_report() {
let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("logs")).expect("建目录");
std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志");
let mut spec_report = report(false, vec!["Error: boom".to_string()]);
spec_report.launcher_logs = Some(root.join("logs"));
spec_report.session_log = None;
spec_report.run_dir = None;
spec_report.version_json = None;
let done = export::export(&spec_report, Lang::ZhCn);
let text = done.text.clone();
assert!(done.ok, "{text}");
assert_eq!(done.truncated, 0);
assert!(text.contains("已导出"), "{text}");
assert!(text.contains("字节"), "{text}");
let exports = root.join("logs").join("exports");
let files: Vec<_> = std::fs::read_dir(&exports)
.expect("导出目录")
.filter_map(Result::ok)
.collect();
assert_eq!(files.len(), 1, "一次导出只该落一个包");
let size = files[0].metadata().expect("元数据").len();
assert!(size > 0, "包不该是空文件");
assert!(
text.contains(&size.to_string()),
"报出来的字节数要与磁盘一致:{text} / {size}"
);
}
/// P3-4:`truncated > 0` 时**界面上那一句**真的出现(r2 复审登记的那条没有用例)。
///
/// 抓得住:把 `Exported.text` 里「有文件被截断」那句删掉/短路(变异证明见报告)。
/// 抓不住:那句话排版好不好看(视觉)。
#[test]
fn a_truncated_export_says_so_in_the_notice() {
use unlucky_core::launch::crash_bundle::MAX_ENTRY_BYTES;
let root = std::env::temp_dir().join(format!("ull-m187-trunc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("logs")).expect("建目录");
std::fs::create_dir_all(root.join("game").join("logs")).expect("建目录");
std::fs::write(root.join("logs").join("launcher.log"), "boot\n").expect("写日志");
// 略超上限的 latest.log:必须用**真实日志行**填充 —— 同一字符的 4 MiB 会被
// 「长不透明串」脱敏规则整段替换成占位符,长度就不超限了(那测的是脱敏不是截断)。
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk\n";
let mut big = String::new();
while big.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 256 {
big.push_str(line);
}
std::fs::write(root.join("game").join("logs").join("latest.log"), &big).expect("写大日志");
let mut report = report(false, Vec::new());
report.launcher_logs = Some(root.join("logs"));
report.run_dir = Some(root.join("game"));
report.session_log = None;
report.version_json = None;
let done = export::export(&report, Lang::ZhCn);
assert!(done.ok, "{}", done.text);
assert_eq!(
done.truncated, 1,
"这份 latest.log 必须真的被截断(>4 MiB)"
);
assert!(
done.text.contains("已截断"),
"界面上没有说「有文件被截断」:{}",
done.text
);
}
+299
View File
@@ -0,0 +1,299 @@
//! 模态层的控件树:**遮罩 + 卡片 + 主/次按钮**,正文与路径全部来自 [`super::Modal`]。
//!
//! ## 命中语义(为什么点遮罩能关、点卡片不关)
//!
//! `iced::widget::Stack` 的 `update` 从上往下(后画的先收)派发,遇到 `shell.is_event_captured()`
//! 立刻停。所以三层的顺序是:
//!
//! ```text
//! stack![ 页面树, 遮罩(mouse_area→Dismiss,整窗), 居中容器(透明)→ opaque(卡片) ]
//! ```
//!
//! 卡片那层用 `opaque` 把卡片内的左键按下**吃掉**([`super::Message`] 不需要再有一条专用
//! 消息),于是点卡片里的空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获。
//! **`opaque` 只包卡片**是这条语义成立的前提:包住整窗居中容器的话,命中区就变成整窗,
//! 遮罩永远收不到按下(r1 复审 P2-1 抓到的原实现)。
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space};
use iced::{Alignment, Background, Border, Color, Element, Font, Length, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use crate::app::components::buttons;
use crate::app::components::feedback::dialog::scrim;
use crate::app::components::feedback::panel::DialogGeometry;
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::{Message as DialogMessage, Modal};
/// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。
const SCROLL_HEIGHT: f32 = 300.0;
/// 有导出结论时滚动区让出的高度(那一段会多占两行,不让就只能压扁页脚按钮)。
const SCROLL_HEIGHT_WITH_NOTICE: f32 = 210.0;
/// 卡片最大高(兜底:不许把边框撑出屏幕)。
const CARD_HEIGHT: f32 = 660.0;
/// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。
///
/// ## 三层的命中语义(r1 复审 P2-1 的修法)
///
/// ```text
/// stack![ 页面树, 遮罩(mouse_area→Dismiss, 整窗), 居中容器(透明) → opaque(卡片) ]
/// ```
///
/// 关键在于**卡片那层只有卡片那么大**:`opaque(卡片)` 的命中区就是卡片本身,点卡片里
/// 只会被它吃掉(`shell.capture_event`),点卡片外则**继续往下走**到遮罩 → `Dismiss`。
/// 反例(r1 抓到的原实现):`mouse_area(整窗居中容器).on_press(Swallow)` —— 整窗都在它
/// 的 bounds 里,任何一次卡片外点击都先被它捕获,遮罩永远收不到,于是「点遮罩关闭」
/// 只存在于注释里。
pub(crate) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
modal: &'a Modal,
) -> Element<'a, Message> {
let backdrop = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(backdrop_color(ui))),
..container::Style::default()
}),
)
.on_press(Message::Dialog(DialogMessage::Dismiss));
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
let centered = container(card_plane(ui, modal))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, backdrop, centered].into()
}
/// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。
///
/// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把
/// 整窗**提亮**(r1 复审实测 (9,21,28) → (129,138,145));而且 α 混色在 wgpu/tiny-skia 两档
/// 合成口径不同(linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。
///
/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。
pub(super) fn backdrop_color(ui: &Ui) -> Color {
scrim(ui)
}
/// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。
///
/// 可见性放宽到 `pub(super)` 只为让 [`super::tests`] 能断言它的 `size_hint`(P2-1 的守卫)。
pub(super) fn card_plane<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
iced::widget::opaque(card(ui, modal))
}
/// 卡片本体(**尺寸 = 内容**,不声明 `Fill` —— 见 [`layer`] 的命中语义)。
fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
// 几何全部走组件层(r1 复审 P1 的纪律项:别绕过现成的 Dialog 档)。
let geometry = DialogGeometry::of(ui);
let shape = Component::Dialog.spec().shape;
// 头(标题 + 三句正文)与脚(按钮)**固定**,中间那段滚——按钮滚出屏幕等于功能不存在
// (视觉复审在日志页踩过同一坑:诊断卡把摘要页脚顶出去了)。
let mut header = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
header = header.push(role(
ui,
TypeRole::HeadlineSmall,
true,
modal.title.as_str(),
));
for line in &modal.body {
header = header.push(wrapped(ui, line.as_str(), ui.color(Role::OnSurface)));
}
let mut middle = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
if let Some(card) = &modal.card {
// **详细说明在前、路径在后**:用户先要知道「发生了什么」,再要「文件在哪」。
if card.expanded {
if let Some(title) = &card.detail_title {
middle = middle.push(role(ui, TypeRole::LabelMedium, false, title.as_str()));
}
for line in &card.detail_lines {
middle = middle.push(mono(ui, line.clone()));
}
middle = middle.push(frame::divider(ui).map(Message::Page));
}
for (index, (label, path)) in card.paths.iter().enumerate() {
middle = middle.push(
button(
column![
role(ui, TypeRole::LabelMedium, false, label.as_str()),
mono(ui, path.clone()),
]
.spacing(0.0)
.width(Length::Fill),
)
.width(Length::Fill)
.padding(0)
.style(|_theme, _status| button::Style {
background: None,
text_color: Color::TRANSPARENT,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
})
.on_press(Message::Dialog(DialogMessage::CopyPath(index))),
);
}
}
let mut footer = column![].spacing(ui.space(Sp::X1)).width(Length::Fill);
if let Some(notice) = &modal.notice {
footer = footer.push(wrapped(ui, notice.as_str(), ui.color(Role::Primary)));
}
footer = footer.push(actions(ui, modal));
// 中间那段给**定高**预算:`Shrink` 会让滚动区按内容高排版,把页脚顶到卡片外面
// (实测第一版就是这样:导出/关闭两个按钮整排不可见)。
let body = if modal.card.is_some() {
column![
header,
scrollable(middle).height(Length::Fixed(if modal.notice.is_some() {
SCROLL_HEIGHT_WITH_NOTICE
} else {
SCROLL_HEIGHT
})),
frame::divider(ui).map(Message::Page),
footer,
]
} else {
column![header, frame::divider(ui).map(Message::Page), footer,]
}
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
// 宽度取官方**上限** 560(`DialogGeometry::max_width`)而不是本项目默认 480:
// 崩溃卡片多两行路径 + 三枚辅助按钮,480 会把主按钮压成竖排(实测过)。
// 边界:组件层日后若改宽度,这里不会跟(已记 P3 台账)。
container(body)
.width(Length::Fixed(geometry.max_width))
.max_height(CARD_HEIGHT)
.padding(geometry.padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: ui.outlined(shape),
..container::Style::default()
})
.into()
}
/// 底部按钮区:**两行**——辅助操作(开文件夹 / 复制路径 / 展开)在上,主次按钮在下靠右。
///
/// 为什么分两行:五个按钮塞一行在 560 的卡宽里会把主按钮压成竖排(实测第一版就是这样,
/// 「导出诊断包」被挤成两行单字)。
fn actions<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
let mut column_actions = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
if let Some(card) = &modal.card {
column_actions = column_actions.push(
row![
buttons::outlined(
ui,
buttons::Button::new(
card.actions[1].as_str(),
Message::Dialog(DialogMessage::OpenFolder),
),
),
buttons::outlined(
ui,
buttons::Button::new(
card.actions[2].as_str(),
Message::Dialog(DialogMessage::CopyPaths),
),
),
buttons::text(
ui,
buttons::Button::new(
card.toggle.as_str(),
Message::Dialog(DialogMessage::Toggle),
),
),
]
.spacing(ui.space(Sp::X2))
.align_y(Alignment::Center),
);
}
let mut line = row![Space::new().width(Length::Fill)].spacing(ui.space(Sp::X2));
if let Some(secondary) = &modal.secondary {
line = line.push(buttons::text(
ui,
buttons::Button::new(
secondary.as_str(),
Message::Dialog(DialogMessage::Secondary),
),
));
}
if !modal.blocking_progress {
line = line.push(buttons::filled(
ui,
buttons::Button::new(
modal.primary.as_str(),
Message::Dialog(DialogMessage::Primary),
),
));
}
column_actions.push(line.align_y(Alignment::Center)).into()
}
/// 令牌排版档的文字(`pages::frame` 的那一套,映射成本层的消息类型)。
fn role<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
) -> Element<'a, Message> {
frame::text_role(
ui,
role,
emphasized,
content,
ui.color(Role::OnSurfaceVariant),
)
.map(Message::Page)
}
/// 等宽文字(路径 / 堆栈 / 捕获输出——这些必须能一眼看出字符边界)。
fn mono<'a>(ui: &'a Ui, content: String) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodySmall);
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(Font {
family: iced::font::Family::Name(ui.families().mono),
..Font::DEFAULT
})
.color(ui.color(Role::OnSurface))
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into()
}
/// 正文一段:令牌排版档 + 按词折行(长路径只按词折会把卡片顶破)。
fn wrapped<'a>(ui: &'a Ui, content: &'a str, color: Color) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodyMedium);
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into()
}
+121
View File
@@ -0,0 +1,121 @@
//! 「日志文件夹」在 app 侧的唯一解析口(M188):路径从哪来、点了按钮之后开哪个目录。
//!
//! ## 为什么不在页面里现拼路径
//!
//! 日志根 = `<启动器数据目录>/logs`,而「启动器数据目录」是三模式解析(便携 / 标准 / 自定义)
//! 的结果。那条解析链只有一份实现(`core::platform::data_dir` + `presenter::settings_bridge`),
//! 页面若自己拼 `exe_dir/.ULL/logs`,标准模式与自定义模式的用户就会看到一个**不存在的目录**。
//!
//! 本模块只做两件事:
//!
//! 1. 从设置桥给定的 `config/launcher.json` 反推 `<home>`,再用 `LauncherLayout::logs()` 取分区
//! (分区名只从 core 的 `layout` 来,不手拼字符串);
//! 2. 打开前先确认目录真的在(不在就建),失败时给出的文本**带完整路径**——用户至少能照着去翻。
//!
//! ## 谁来登记
//!
//! `App::new` 拿到设置桥之后调 [`register`]:那是本进程唯一知道「实际生效的数据根」的地方
//! (`--data-dir` 也在这里生效)。没登记时(`pages_demo` 出图通道、单测)退回
//! `platform::data::layout()`——同一份解析链的缓存版本。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_presenter::settings_bridge::SettingsBridge;
static LOGS_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
/// `<home>/config/launcher.json` → `<home>/logs`(**只有**这一条形状,别处不许手拼)。
pub(crate) fn logs_dir_of(config: &Path) -> Option<PathBuf> {
let home = config.parent()?.parent()?;
Some(LauncherLayout::new(home).logs())
}
/// 登记本进程的日志目录(`App::new` 在第一帧之前调一次;重复调用以**第一次**为准)。
pub(crate) fn register(bridge: &SettingsBridge) {
let _ = LOGS_DIR.set(bridge.path().and_then(logs_dir_of));
}
/// 本进程的日志目录。
///
/// - 已登记且解析出来 ⇒ 那条;
/// - 已登记但解析不出来(内存态桥)⇒ `None`:页面照实说「数据根没解析出来」,不猜一个目录;
/// - 还没登记(`pages_demo` / 单测)⇒ 退回 `platform::data::layout()`。
pub(crate) fn logs_dir() -> Option<PathBuf> {
match LOGS_DIR.get() {
Some(known) => known.clone(),
None => crate::platform::data::layout().map(|layout| layout.logs()),
}
}
/// 确保日志目录存在(不在就建),返回给用户看的路径。
///
/// 失败文本里**带完整路径**:界面把这段话原样显示出来,用户能复制去自己找。
pub(crate) fn prepare(dir: &Path) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir)
.map_err(|error| format!("{}(这个目录建不出来:{error})", dir.display()))?;
if !dir.is_dir() {
return Err(format!("{}(这个路径不是一个目录)", dir.display()));
}
Ok(dir.to_path_buf())
}
/// 打开日志文件夹:解析 → 确保存在 → 交给平台打开器。
///
/// # Errors
///
/// 返回的文本永远带路径(`...logs` 那一串),可以直接贴给用户/贴进 issue。
pub(crate) fn open() -> Result<PathBuf, String> {
let dir = logs_dir().ok_or_else(|| {
"日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)"
.to_string()
})?;
let dir = prepare(&dir)?;
crate::app::dialog::export::open_dir(&dir)?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
/// 落点形状:`<home>/config/launcher.json` → `<home>/logs`。
///
/// 变异证明:把 `logs_dir_of` 里的 `LauncherLayout::logs` 换成 `cache`,这条立刻红。
#[test]
fn the_log_folder_is_the_logs_partition_next_to_config() {
let config = PathBuf::from("/tmp/ull-home/.ULL/config/launcher.json");
assert_eq!(
logs_dir_of(&config),
Some(PathBuf::from("/tmp/ull-home/.ULL/logs"))
);
// 少了 config 那一层就没有 `<home>` 可言:宁可不猜。
assert_eq!(logs_dir_of(Path::new("launcher.json")), None);
}
/// **目录真的存在**(守门③):`prepare` 在不在时把它建出来,并且返回同一个路径。
///
/// 变异证明:删掉 `create_dir_all` 那一行,`is_dir` 立刻为假。
#[test]
fn prepare_creates_the_folder_and_reports_the_same_path() {
let root = std::env::temp_dir().join(format!("ull-logs-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join(".ULL/logs");
assert!(!dir.is_dir(), "夹具目录不该已经存在");
let prepared = prepare(&dir).expect("应当建得出来");
assert_eq!(prepared, dir);
assert!(dir.is_dir(), "打开之前必须真的存在");
std::fs::remove_dir_all(&root).ok();
}
/// 失败文本带路径(「不许只在成功时可用」)。
#[test]
fn a_failure_names_the_path_it_could_not_use() {
let file = std::env::temp_dir().join(format!("ull-logs-notadir-{}", std::process::id()));
std::fs::write(&file, b"x").expect("写夹具");
let error = prepare(&file).expect_err("一个文件不是目录");
assert!(error.contains(&file.display().to_string()), "{error}");
std::fs::remove_file(&file).ok();
}
}
+108
View File
@@ -0,0 +1,108 @@
//! 应用消息:单一大枚举。
//!
//! 纪律:本枚举是**唯一**的消息类型,`view` 产出的所有回调都必须落到这里;组件库与页面层
//! 各自用自己的 `Message`(组件契约 §2 的泛型纪律),由 `view` 做一次 `.map` 接上。
//!
//! ## 三类输入(M90)
//!
//! | 来源 | 变体 | 落点 |
//! |---|---|---|
//! | 窗口/框架 | `Boot` / `WindowReady` / `Window` / `Frame` / `Captured` / 三个 caption | [`super::update`] 直接处理 |
//! | **时间** | `Frame`(忙碌,33 ms 闸门)+ `Heartbeat`(空闲,1 Hz) | presenter 的 TICK 节拍:`backend.tick` + `drain_events` + `apply` |
//! | **页面** | `Page(pages::Message)` | 导航 → `AppState::navigate`;启动/停止 → `AppCommand` |
use iced::{window, window::Direction};
use unlucky_presenter::router::Route;
use crate::pages;
use crate::shell::NavItem;
use super::components;
/// 消息(单一大枚举)。
#[derive(Debug, Clone)]
pub enum Message {
/// 启动即发一次(`iced::application` 的 boot 闭包)。
Boot,
/// `window::latest()` 的结果。
WindowReady(Option<window::Id>),
/// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。
SystemTheme(iced::theme::Mode),
/// 一帧画完。**只在需要 33 ms 节拍时订阅**:截图通道 / 有 Busy-Running 实例 / 有未过期
/// toast / 派发后的 3 s 观察窗(见 `subscription::needs_frames`)。
Frame,
/// **空闲态**的 1 Hz 心跳:`Task::perform(sleep(1s))` 的自续链,用来替代空闲时的帧循环
/// (M90 r3 / P2-2)。它保证 `theme::config_poll_due` 仍按「30 × 33 ms ≈ 1 秒」的
/// presenter 语义跑,外部 CLI/AI 改配置后主题 1 秒内生效。
Heartbeat,
/// 窗口事件(M90:`Resized` 决定布局档位,600 / 840 两条断点靠它生效)。
Window(window::Event),
/// 窗口句柄(M93:`window::run` 探测出的 raw handle;`None` = 拿不到,整窗 α 无法下发)。
WindowHandle(Option<isize>),
/// 页面消息(导航 / 启动 / 停止 / 占位动作)。
Page(pages::Message),
/// **AI 助手栏 / 独立页**的消息(M104:三档状态、输入框、拖拽、键盘)。
///
/// 为什么不走 `pages::Message`:助手栏是**外壳级视图**(它挂在任何页面右边),不是某一页的
/// 消息;塞进页面枚举还要动 `app/update/page_messages.rs` 的穷尽 match(那个文件正被未合的
/// M101 改),绕开它就少一处 rebase 面。落点是 `app/update/agent.rs`。
Agent(crate::shell::agent::Message),
/// 外壳导航轨点选(`src/shell/nav.rs` 的回调仍然发这一条;收到就翻成同一个路由跳转)。
///
/// M90 起产品界面用 `pages` 里的真 `NavigationRail`,这条变体是给**外壳回退路径**
/// (`Route::Agent`)留的——`shell` 不在本 mission 的 scope 里,不能删它的消息。
Select(NavItem),
/// 按下标题栏 → 拖窗。
DragWindow,
/// 边缘热区:开始缩放(方向由坐标决定)。
ResizeStart(Direction),
/// 最小化。
Minimize,
/// 最大化/还原。
ToggleMaximize,
/// 关窗退出。
Close,
/// 截图回来了。
Captured(window::Screenshot),
/// gallery(组件库 harness)里的演示交互。
Demo(components::gallery::DemoMessage),
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
Dialog(super::dialog::Message),
/// 导入已有版本向导(M223):打开 / 选版本 / 选目标 / 预检 / 确认 / 关闭。
///
/// 落点是 `app/update/adopt.rs`。页面只发一条入口意图
/// (`pages::Message::Action(pages::Action::Import)`),向导自己的四段状态都在这里收口。
Adopt(super::adopt::AdoptMessage),
}
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
///
/// 这是 `NavItem → Route` 的**唯一**转换点:外壳(`src/shell/nav.rs`)与页面导航轨
/// (`pages::NAV`)最终都落到同一个 `Route`,所以两条入口不会各走一套页面语义。
pub(super) fn route_of(nav: NavItem) -> Route {
match nav {
NavItem::Instances => Route::Instances,
NavItem::Download => Route::Download,
NavItem::Logs => Route::Logs,
NavItem::Settings => Route::Settings,
NavItem::Ai => Route::Agent,
// 轨底头像位(M114):账户是一级页,但**不在主序列**里
NavItem::Account => Route::Accounts,
}
}
/// 路由 → 外壳导航项([`route_of`] 的逆;外壳 rail 与 caption 键都按它高亮)。
///
/// `Route::Detail(_)` 高亮「实例」(与 `Route::nav()` 同源)。
pub(super) fn nav_item_of(route: &Route) -> NavItem {
match route {
Route::Instances | Route::Detail(_) => NavItem::Instances,
// M109:内容库与版本管理共用「下载」那一个导航项(B1 裁定)
Route::Download | Route::Content => NavItem::Download,
Route::Logs => NavItem::Logs,
Route::Settings => NavItem::Settings,
Route::Agent => NavItem::Ai,
Route::Accounts => NavItem::Account,
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线,以及启动序列。
//!
//! 启动序列:①探测渲染后端并写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1)→ ②求值字体栈
//! (D28 前半)→ ③`iced::application(..).run()`(1180×760、无边框自绘标题栏)→ ④首个 `view()`
//! 打耗时;截图模式第 3 帧落盘后退出(无边框窗首帧可能是旧尺寸,M60 坑 #5)。
//!
//! ## 为什么拆成目录(M90)
//!
//! 本文件原来是**单个 400 行的 `app.rs`**:正好卡在「单文件 ≤400 行、动它前必须先拆」的硬门槛上
//! (AGENTS 的文件上限 + 本 mission 的第一条任务)。拆法与职责:
//!
//! | 文件 | 职责(**只做这一件事**) |
//! |---|---|
//! | [`state`] | [`App`] 字段 + 构造(presenter 三件在此持有)+ 主题切换 |
//! | [`message`] | [`Message`] 枚举与「导航项 → 路由」的翻译 |
//! | [`update`] | 全部 `Message` 分支 + 「用户动作 → `AppCommand`」的派发与事件回流 |
//! | [`subscription`] | 帧事件(截图)与 presenter 的 TICK 节拍 |
//! | [`view`] | `view` / `theme` / `title` |
//! | [`boot`] | [`run`]:后端探测 → 字体 → `iced::application(..)` |
//!
//! 拆分的每一步都保持**可观察行为不变**(截图四件套 `--screenshot` / `--screenshot-theme` /
//! `--screen=gallery` / `--diff` 出图逐字节相同),presenter 接线是在拆分**之后**另一步做的。
//!
//! ## 组件库为什么还在本模块下声明
//!
//! `src/lib.rs` 是五条组件线共用的文件,改它必然冲突;`#[path]` 保证物理路径仍是
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
//! (组件源码里的文档与测试都按这个路径写)。
mod adopt;
mod appearance;
mod backend;
mod boot;
pub(crate) mod diag;
mod dialog;
pub(crate) mod logs_folder;
mod message;
mod state;
mod subscription;
mod update;
mod view;
mod window_alpha;
#[cfg(test)]
mod tests;
pub(crate) use boot::run;
pub use message::Message;
// 组件库(D39)在本文件声明:`src/lib.rs` 是五条组件线共用的文件,改它必然冲突;
// `#[path]` 保证物理路径仍是 `src/components/`(组件目录自己的归属规则不变)。
//
// M90:可见性从 `mod` 放到 `pub(crate) mod` —— 页面层(`src/pages/**`)要**用**真组件
// (`components::buttons` / `navigation` / `divider`),而 `app` 不是它的父模块。
// 只放开 crate 内可见,组件目录的归属规则一字不改。
#[path = "../components/mod.rs"]
pub(crate) mod components;
/// 验收窗口尺寸(1180×760 @100% DPI —— D30 的硬要求)。
pub const WINDOW_SIZE: iced::Size = iced::Size {
width: 1180.0,
height: 760.0,
};
/// 产品名(自绘标题栏的标题;`'static` 好让 view 借用)。
pub const APP_TITLE: &str = "Unlucky Launcher";
/// 窗口最小尺寸(再小骨架会挤坏:88 导航 + 页面留白 + 卡片)。
const MIN_SIZE: iced::Size = iced::Size {
width: 880.0,
height: 560.0,
};
/// 截图落盘的帧号(见模块文档)。
const CAPTURE_AT_FRAME: u32 = 3;
+246
View File
@@ -0,0 +1,246 @@
//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`super::update`] 的事)。
//!
//! ## presenter 三件(`crates/presenter/src/lib.rs` crate 文档 §4)
//!
//! | 字段 | 谁给 | 干什么 |
//! |---|---|---|
//! | `backend: Box<dyn Backend>` | [`build_backend`](unlucky_presenter::backend::build_backend) | 派发 [`AppCommand`](unlucky_presenter::dto::AppCommand)、收 [`AppEvent`](unlucky_presenter::dto::AppEvent) |
//! | `bridge: SettingsBridge` | [`SettingsBridge::open`](unlucky_presenter::settings_bridge::SettingsBridge::open) | 读一次外观/主题,之后每 N 拍问「文件变了吗」 |
//! | `state: AppState` | [`AppState::new`](unlucky_presenter::state::AppState::new) | **唯一**的状态写入口(`apply` / `navigate`) |
//!
//! `view: pages::Model` 是**展示转换的缓存**:presenter 快照 → 页面要画的东西。
//! 它在**状态真的变了**时重建([`App::view_dirty`] 门控,M90 r2/P2-1 起),不是每拍重建——
//! 重建要把 N 个实例的名字/版本串克隆一遍,30 Hz 空转没有意义。缓存成字段是为了让
//! `view(&self)` 能只借 `&self`:iced 的 `view` 签名不允许返回一棵借用局部变量的树。
//!
//! ## 本文件只管「有哪些字段」
//!
//! M119 起按功能拆出去(`state.rs` 当时正好 400 行、零余量):
//!
//! | 子文件 | 内容 |
//! |---|---|
//! | [`construct`] | 两个构造器 + 字段初始化表 |
//! | [`refresh`] | [`App::refresh_view`](快照 → 视图模型的唯一转换点) |
//! | [`theme`] | `Ui` 重算 / 主题档 / [`App::has_screenshot`] |
//! | [`boot_probe`] | 启动探针(`ULL_*` 取证开关与首帧日志) |
use std::time::Instant;
use iced::window;
use unlucky_presenter::dto::Backend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::state::AppState;
use crate::cli::Options;
use crate::pages;
use crate::theme::{Families, Ui};
use crate::ui::lang::LangState;
/// presenter 的 TICK 节拍(33 ms ≈ 30 fps)——**忙碌态**的节拍。
///
/// 与 Slint 版的 `TICK_MS` 同值:`backend.tick(..)` 的时间推进、toast 过期、
/// `theme::config_poll_due` 的计数都按这个节拍走,两个 UI 版的时间语义因此一致。
pub(super) const TICK_MS: u64 = 33;
/// [`TICK_MS`] 的 `Duration` 形态。
pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TICK_MS);
/// **空闲态**的低频心跳(1 Hz,M90 r3 / P2-2)。
///
/// 空闲时不能订阅 `window::frames()`:那条订阅会自持重绘循环(实测软件渲染约 500 帧/秒),
/// 而空闲时唯一需要的时钟是「偶发一次」——外部 CLI / AI 工具改 `launcher.json` 后的热切
/// (presenter 的 `theme::config_poll_due` 语义是 30 × 33 ms ≈ 1 秒一拍)。
///
/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒
/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`:
/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。
///
/// **M192 的第三条时钟不在这里**:动效(`components::motion`)也只在「有轨道」时借用帧订阅,
/// 最后一个轨道到期就清空 ⇒ 下一拍 [`App::needs_frames`] 自动退回这条 1 Hz 心跳。
pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1);
/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。
///
/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
mod boot_probe;
// M224: 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE`;产品路径零影响)
mod construct;
mod mods_shot;
mod refresh;
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
mod saves_shot;
mod theme;
/// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。
///
/// 为什么用 `#[path]` 而不是在 `lib.rs` 加一行 `mod presenter;`:本 mission 的 scope 不含
/// `lib.rs` / `app/mod.rs`,而 `shell::agent` 挂 `pages/agent/` 是同一个先例。没有第二份实现。
#[path = "../presenter/mod.rs"]
pub(super) mod presenter;
pub(crate) use boot_probe::{log_first_frame, STARTED};
/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。
pub struct App {
/// 启动参数(截图/主题/画面开关)。
pub(super) options: Options,
/// 字体族(切主题时重建 [`Ui`] 要用)。
pub(super) families: Families,
/// 令牌求值器(浅/深两套)。
pub(super) ui: Ui,
/// 后端通道(真 host 或 mock)。
pub(super) backend: Box<dyn Backend>,
/// 设置持久化桥。
pub(super) bridge: SettingsBridge,
/// presenter 的全局状态(唯一写入口是 `apply`)。
pub(super) state: AppState,
/// 已推进的拍数(`theme::config_poll_due` 的输入)。单位是 **33 ms 一拍**,不是调用次数。
pub(super) ticks: u64,
/// 下一次「比对配置文件 mtime」的 tick 号(见 `update::advance` 的注释)。
pub(super) config_due_at: u64,
/// 设置文件里的主题模式(`auto` / `light` / `dark`)。
///
/// 用来定优先级:`--theme` > 配置文件 > 系统主题。没有它,`Message::SystemTheme` 会无条件
/// 盖掉配置文件的显式主题(M90 r3 修)。
pub(super) theme_mode: ThemeMode,
/// 界面语言(M106):用户档位 + 探测结论 + 目录选择。见 [`crate::ui::lang`]。
///
/// 为什么不在 `Ui` 里:`theme.rs` 是令牌求值器(颜色/字号),而语言是**运行期状态**——
/// 随设置变、要落盘、要重建视图模型(切主题只重建 Style)。
pub(super) lang: LangState,
/// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。
pub(super) last_tick: Instant,
/// 窗口宽度(布局档位的判据;交互模式跟着 resize 事件走)。
pub(super) width: f32,
/// 展示转换缓存(presenter 快照 → 页面视图模型)。
pub(super) view: pages::Model,
/// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态**
/// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。
pub(super) view_dirty: bool,
/// 「刚派发过指令,等后端回事件」的观察窗(P2-1)。
///
/// 空闲态**不订阅帧事件**(只有 1 Hz 的 `heartbeat`),所以派发之后必须有这段短窗口让
/// 33 ms 节拍跑起来,否则真 host 的异步事件永远抽不出来。
///
/// 空闲态不订阅帧事件(不点亮窗口重绘),但派发之后必须有一段短窗口保持节拍,
/// 否则真 host 的异步事件永远抽不出来(`LaunchInstance` 之后状态还是 Idle ⇒ 帧订阅永不开启)。
pub(super) watch_until: Option<Instant>,
/// 窗口句柄(`window::latest()` 之后才有)。
pub(super) window: Option<window::Id>,
/// 已画的帧数(截图闸门用)。
pub(super) frames: u32,
/// 视图模型重建次数(P2-1 的可观测计数:空闲态 100 拍里必须是 0 增长)。
pub(super) refreshes: u64,
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
pub(super) auto_selected: bool,
/// 上一次看到的**中文字体下载状态**(M168)。
///
/// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了
/// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」,
/// 而空闲态不会为了它每拍白重建一次。
pub(super) font_seen: crate::fonts::install::CjkInstall,
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
pub(super) height: f32,
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
pub(super) appearance: pages::appearance::State,
/// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段,
/// 没动过的字段原样保留;M250 起强调色也在「真改了」的那一列里)。
pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs,
/// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。
pub(super) hwnd: Option<isize>,
/// 设置页各组自己的运行期状态(M101 r2 的**状态漏斗**)。
///
/// 所有者在 `pages/settings/state.rs` 的 `Groups`:加/改某一组的字段只改那一组自己的文件,
/// 本字段与 `app/view.rs` 都不用动(`settings::States` 只看 `&Groups`,不看任何一组的字段)。
/// 放在字段列表**末尾**:后续 mission 若也要往这里加字段,冲突面最小。
pub(super) settings: pages::settings::Groups,
/// AI 助手栏的 UI 状态(M104):右栗三档 + 展开宽度 + 草稿/焦点。
///
/// 挂在 `App` 上而不是全局:这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,
/// 状态跟着 App 走才能 per-app、可测(tower 明确否掉了 `OnceLock<Mutex<..>>`)。
///
/// **只留 UI 态**:会话内容 / 未读 / busy 在 `agent_host` 的 rail 上(M119 起)。
pub(super) agent: crate::shell::agent::AgentUi,
/// AI 助手的 presenter 句柄(M119):四个契约 + 事件队列 + 具名错误。
///
/// 为什么单独一份 `AppState` 而不是把 `state` 交给它:`Presenter` 的可写状态后门
/// (`state_mut`)只在 presenter crate 的 `#[cfg(test)]` 里存在,而 `app/subscription.rs`
/// 等处仍要直读 `App.state`。所以口径是——**`App.state` 管除 rail 外的一切,这个句柄管 rail**;
/// `AppEvent::Agent` 只喂这里(`App.state.ai` 从此不再被读,也不再被写)。
pub(super) agent_host: presenter::AgentHost,
/// 下载页的安装草稿(M105:选中版本 + 加载器 + 加载器版本 + 实例名)。
///
/// 为什么住在 `App` 而不是页面模型:`refresh_view` 每次事件都把 `self.view` 整棵重建,
/// 页面自己存不住「用户正在填但还没确认」的东西;这与分类筛选(`view.download.category`)
/// 是同一个理由、同一个做法。
pub(super) download_draft: pages::InstallDraft,
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
pub(super) install_cancelled_seen: u64,
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
pub(super) launch_ends_seen: u64,
/// 已经提示过的启停失败(M224):`(实例, 文件, 大类)`。同一条失败重复到达不重弹。
///
/// 为什么不用计数器(与上面两条不同):失败是**并发可交错**的(A 的文件失败、B 的列表成功、
/// A 又失败),计数差说明不了"哪一条没提示过",三元组才是幂等键。
pub(super) mods_failed_seen: Option<(String, String, unlucky_presenter::dto::ModFailureKind)>,
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
///
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
/// 而检索条件与结果(会跨页存活、要被事件写)在 `AppState::content`。放进 `view` 模型也
/// 不行——`refresh_view` 每次都用快照重建模型,选择会被冲回默认值。
pub(super) content: pages::content::PageState,
/// 账户页的**表单状态**(三条轨道的输入串;密码只活在这一次请求里)。
pub(super) account_form: pages::account::Form,
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
pub(super) account: pages::account::Page,
/// 实例页「启动参数」区的表单(M157):五项 + 脏标记 + 绑定的实例 id。
///
/// 住在 `App` 的理由与 `download_draft` 完全一致:`refresh_view` 每次都整棵重建
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
pub(super) launch_form: pages::LaunchForm,
/// 实例页「导入已有版本」向导的表单态(M223):选中版本 / 展示名 / 目标档 / 在等回程。
///
/// 住在 `App` 的理由与 `launch_form` 一致:向导跨帧(扫描 → 选 → 预检 → 确认),
/// 而候选/预检/失败/完成这些**事件真相**在 `AppState.adopt`(presenter),不在这里。
pub(super) adopt_form: super::adopt::Form,
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
///
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
/// 是两件事——队列管弹窗的排队与按钮,卡片管证据(路径 / 崩溃摘要 / 捕获输出)。
pub(super) dialog_card: Option<super::dialog::CrashCard>,
/// 弹窗的**视图模型**(在 [`App::refresh_view`] 里重建;`view()` 只借它)。
///
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
pub(super) dialog_model: Option<super::dialog::Modal>,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
///
/// `None` = 还没下发过(首屏 `boot` 那一次)。指纹类型见
/// [`crate::platform::win32::corner::CornerApplied`]。
pub(super) corner_applied: Option<crate::platform::win32::corner::CornerApplied>,
}
impl App {
/// iced 侧的**设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流进来的那一份。
///
/// 真值只有一份——presenter 的 `AppState::settings_groups`(`apply_core` 是它唯一的写入口,
/// 由 host 的 `refresh_all` / `SetSetting` 分支发出来)。这里**不另存一个字段**:那会变成第二份
/// 真相,两份在 `pump` 之间必然有一步不一致(而「界面上是旧值、盘上是新值」正是本 mission 要
/// 消灭的形状)。要画的那一份是 [`pages::Model::settings`](`refresh_view` 里从同一个字段克隆)。
///
/// `#[cfg(test)]`:产品路径不需要这个入口(页面拿的是 `Model.settings`,同一份数据的投影),
/// 断言需要它——「App 认的值变了吗」必须能直接从 App 上问,不能去问页面模型(那是下游)。
#[cfg(test)]
pub(crate) fn settings_snapshot(&self) -> &[unlucky_presenter::dto::SettingGroupView] {
&self.state.settings_groups
}
}
@@ -0,0 +1,61 @@
//! 启动期取证:后端选项的换算 + 首帧耗时那一行日志。
//!
//! 从 `app/state.rs` 拆出(rebase 到 5adac23 之后 `state.rs` 顶到 401 行)。这两件事都属于
//! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出,
//! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。
use crate::app::diag::diag;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use std::time::Instant;
use crate::cli::Options;
use unlucky_presenter::backend::AppOptions;
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
pub(crate) static STARTED: OnceLock<Instant> = OnceLock::new();
/// 首帧日志只打一次。
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
/// 启动参数 → presenter 的后端选项。
pub(super) fn app_options(options: &Options) -> AppOptions {
AppOptions {
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
data_root: options.data_dir.clone(),
mock: options.screenshot.is_some() && !real_host_in_screenshot(),
}
}
/// 取证开关 `ULL_SCREENSHOT_REAL_HOST=1`:让**截图通道**接真 host(默认关)。
///
/// ## 为什么需要它(M201)
///
/// 截图通道恒 `mock: true`(D30:同一份代码出同一张图),而 `MockBackend` **不发**
/// `CoreEvent::SettingsChanged` ⇒ 设置页拿不到任何真值,只能拍演示行。可「设置页里改了就生效」
/// 这件事的正脸图**必须带真值**:M193 用户的投诉就是「图上有 8 threads,追下去是假的」。
/// 这条开关把出图的**数据源**换成真 host(`--data-dir` 指一个临时根),渲染通道与落盘一字不改。
///
/// ## 边界(别把它当产品行为)
///
/// - 默认关;开了它就不再满足 D30 的可复现(数据来自磁盘,两台机器会不同),所以**只用于取证**;
/// - 它只改 `mock` 这一位,不碰任何业务分支(`build_backend` 的其余两条路仍然照旧);
/// - 与 `ULL_START_ROUTE` / `ULL_SETTINGS_GROUP` 同一族:只影响这一次进程。
fn real_host_in_screenshot() -> bool {
std::env::var("ULL_SCREENSHOT_REAL_HOST")
.ok()
.is_some_and(|raw| raw.trim() == "1")
}
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
pub(crate) fn log_first_frame() {
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
if let Some(started) = STARTED.get() {
diag!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0
);
}
}
+185
View File
@@ -0,0 +1,185 @@
//! `App` 的两个构造器(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
//!
//! | 构造器 | 谁用 | 碰不碰磁盘 |
//! |---|---|---|
//! | [`App::new`](super::App::new) | 生产(`main` → `boot`) | 按启动参数选后端 + 打开设置桥 |
//! | [`App::with_presenter`](super::App::with_presenter) | 单测(后端与桥都由调用方给) | **不碰** |
//!
//! 两个都走 `with_presenter`:生产路径只是先造好后端与桥。**字段初始化表**也在这一个文件里
//! (构造 = 字段表 + 三件外部依赖),加字段时只改这一处。
use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::backend::{build_backend, config_path};
use unlucky_presenter::dto::Backend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::state::AppState;
use crate::cli::Options;
use crate::pages;
use crate::theme::{Families, Ui};
use crate::ui::lang::LangState;
use super::super::WINDOW_SIZE;
use super::boot_probe::app_options;
use super::App;
impl App {
/// 生产构造:按启动参数选后端、打开设置桥。
///
/// **截图通道强制 mock**:出图要可复现(D30),真 host 会按本机盘上的实例清单画面,
/// 同一份代码在两台机器上出不了同一张图。交互模式走真 host(数据根解析不出来时
/// [`build_backend`] 自己退回 mock)。
pub(in crate::app) fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
let backend = build_backend(&app_options(&options));
// P2-2:设置桥必须与后端指向**同一个数据根**。早先这里无条件 `SettingsBridge::open()`
// (按便携配置解析),于是 `--data-dir X` 会出现「实例来自 X、主题/设置读写默认根」的一进程
// 两个数据根。Slint 版是同一条口径:`app/src/ui_bind.rs` 的
// `Some(root) => SettingsBridge::with_path(config_path(root))`。
let bridge = match &options.data_dir {
Some(root) => SettingsBridge::with_path(config_path(root)),
None => SettingsBridge::open(),
};
// M188:日志目录也在这一拍登记——它是从**同一份**设置桥反推的(`--data-dir` 同样生效),
// 所以设置页显示的路径与 `launcher.log` 的真实落点不会各说各话。
crate::app::logs_folder::register(&bridge);
Self::with_presenter(options, families, flavor, backend, bridge)
}
/// 注入式构造(单测用:后端与桥都由调用方给,**不碰磁盘**)。
pub(in crate::app) fn with_presenter(
options: Options,
families: Families,
flavor: SchemeFlavor,
backend: Box<dyn Backend>,
bridge: SettingsBridge,
) -> Self {
let width = WINDOW_SIZE.width;
let height = WINDOW_SIZE.height;
// 主题档:截图通道**不读机器配置**(D30:同一份代码在两台机器上要出同一张图)——
// 由 `--screenshot-theme` 钉死(浅 → Silver / `light`,深 → Ink / `dark`)。
// 产品路径才读 `appearance.theme_mode`。复审 P2:此前这里两种通道都读文件,
// 设置页「主题」那一行的选中态会随出图机器的配置漂移。
let theme_mode = match options.screenshot.is_some() {
true => match options.shot_flavor().unwrap_or(SchemeFlavor::Light) {
SchemeFlavor::Light => ThemeMode::Light,
SchemeFlavor::Dark => ThemeMode::Dark,
},
false => bridge.load().1,
};
// 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 4 个只读下层键
// (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。M93 之前这些键在 app 侧**根本读不到**。
//
// 截图通道**不读用户配置**(D30:同一份代码在两台机器上要出同一张图):外观一律取出厂值,
// 矩阵需要的值由 `ULL_APPEARANCE_*` 显式灌进来。
let (appearance_prefs, appearance_base) = if options.screenshot.is_some() {
(
unlucky_presenter::prefs::AppearancePrefs::factory(),
unlucky_presenter::prefs::AppearanceBase::factory(),
)
} else {
(bridge.load().0, bridge.load_base())
};
// 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2):
// 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。
let backend_tier = super::super::backend::effective_backend(options.backend);
let mut appearance = pages::appearance::State::new(
&appearance_prefs,
&appearance_base,
backend_tier,
flavor,
);
// 取证开关(D30):`ULL_SETTINGS_GROUP=<下标>` 直达设置页的某一组(出图要看见语言行)。
if let Some(index) =
crate::ui::lang::screenshot_settings_group(options.screenshot.is_some())
{
appearance.selected_group = index;
}
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
let lang = LangState::boot(&bridge, options.screenshot.is_some());
// M168(复审 r2 P2-2):出图通道**不许联网**——`log_cjk` 那一拍会走 `after_probe`,
// 一台 zh-locale 且没有中文字体的机器上会真的下 17 MiB,并可能在出图过程中注册新字体
// (D30:同一份代码要出同一张图)。产品路径不设这个开关。
crate::fonts::install::set_offline(options.screenshot.is_some());
let mut view = pages::Model::new(
pages::Screen::Instances,
width,
height,
Some(pages::Screen::Instances.nav()),
);
view.lang = lang.lang();
let mut app = Self {
ui: Ui::new(flavor, families),
families,
options,
backend,
bridge,
state: AppState::new(),
ticks: 0,
config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS,
theme_mode,
lang,
last_tick: std::time::Instant::now(),
width,
view,
view_dirty: false,
watch_until: None,
window: None::<window::Id>,
frames: 0,
refreshes: 0,
auto_selected: false,
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
font_seen: crate::fonts::install::status(),
height,
appearance,
appearance_prefs,
hwnd: None,
settings: pages::settings::Groups::default(),
// 出厂是**空会话**(M119 删了 M104 的样本会话);取证开关 `ULL_AGENT_*` 的档位
// 与夹具在 `boot_agent` 里落地(它要先让 presenter 知道栏可见性,见那里)。
agent: crate::shell::agent::AgentUi::new(),
agent_host: super::presenter::AgentHost::from_env(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(),
launch_form: pages::LaunchForm::default(),
adopt_form: super::super::adopt::Form::default(),
install_cancelled_seen: 0,
launch_ends_seen: 0,
mods_failed_seen: None,
dialog_card: None,
dialog_model: None,
content: pages::content::PageState::default(),
account_form: pages::account::Form::new(),
account: pages::account::Page::build(
&AppState::new(),
&pages::account::Form::new(),
&mut pages::account::avatar::Cache::default(),
unlucky_i18n::Lang::En,
width,
),
avatars: pages::account::avatar::Cache::default(),
corner_applied: None,
};
// M229:设置页「主题」行的选中态来自设置组自有状态(`language_appearance::State::theme`),
// 唯一写者是 `App::set_theme_mode`;这里把启动时读到的档位灌进去,首帧才不会画错档。
app.settings.language_appearance.theme = theme_mode;
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是色板。
app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings();
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
// M223: adopt wizard screenshot seed (evidence channel only; `ULL_ADOPT_STATE`).
app.hydrate_adopt_state();
app.hydrate_java_settings();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log();
app.lang.log_cjk();
app
}
}
@@ -0,0 +1,97 @@
//! 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE=1`)。
//!
//! # 它解决什么
//!
//! D30 的 MODS 区截图需要一屏**有内容的列表**。产品路径的列表来自后台线程(列 `mods/` → 快照),
//! 而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及完成,截出来会是
//! 「正在读取 mods/…」。M157 的启动参数区、M222 的下载页、M223 的导入都是同一个问题,
//! 处理方式一致:**状态注入**,且注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
//!
//! # 边界(不要把它读成产品能力)
//!
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
//! - 它注入的是**快照**([`ModListView`]),不是页面模型——`Mods::from_state` 的转换、
//! 计数、`.old` 徽标、在飞置灰走的都是产品代码;
//! - 真实链路由两条端到端用例钉住(`host::mods::tests` 在真盘上列目录 + 启停)。
/// 夹具开关是否打开(`ULL_MODS_FIXTURE` 非空且不为 `0`)。
#[must_use]
pub fn enabled() -> bool {
std::env::var("ULL_MODS_FIXTURE")
.map(|value| {
let value = value.trim();
!value.is_empty() && value != "0"
})
.unwrap_or(false)
}
/// 一份固定快照(实例 id 用调用方给的那个)。
///
/// `dir` 故意留空:夹具不碰路径(这一版页面也不画它),写一个假路径反而会被当成"真的派生结果"。
#[must_use]
pub fn view(instance: &str) -> unlucky_presenter::dto::ModListView {
use unlucky_presenter::dto::{ModEntryView, ModListView};
let entry = |file: &str, name: &str, enabled: bool, bytes: u64, old: bool| ModEntryView {
file: file.to_string(),
name: name.to_string(),
enabled,
bytes,
old,
};
// 夹具不碰路径:`dir` 留空(页面这一版不画它,它只是 DTO 的一部分)。
let dir = String::new();
ModListView {
instance: instance.to_string(),
dir,
exists: true,
entries: vec![
entry(
"sodium-fabric-0.5.11.jar",
"sodium-fabric-0.5.11.jar",
true,
1_048_576,
false,
),
entry(
"iris-fabric-1.8.5.jar",
"iris-fabric-1.8.5.jar",
true,
2_621_440,
false,
),
entry(
"jei-1.20.4-forge-17.3.0.49.jar.disabled",
"jei-1.20.4-forge-17.3.0.49.jar",
false,
1_363_148,
false,
),
entry(
"aether-1.20.1-1.0.0.jar.old",
"aether-1.20.1-1.0.0.jar",
true,
7_340_032,
true,
),
entry(
"create-1.20.1-0.5.1.f.jar.disabled",
"create-1.20.1-0.5.1.f.jar",
false,
4_194_304,
false,
),
entry(
"fabric-api-0.92.2+1.20.4.jar",
"fabric-api-0.92.2+1.20.4.jar",
true,
2_097_152,
false,
),
],
skipped: Vec::new(),
running: false,
known: true,
}
}
+234
View File
@@ -0,0 +1,234 @@
//! 快照 → 视图模型的重建口径(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
//!
//! 全仓只有 [`App::refresh_view`] 这一个「presenter 快照 → 页面要画的东西」的转换点,
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
use unlucky_presenter::dto::{AppCommand, ModsCommand, SavesCommand};
use super::{mods_shot, saves_shot};
use unlucky_presenter::toast::ToastKind;
use crate::app::view;
use crate::pages;
use unlucky_presenter::router::Route;
use super::App;
impl App {
/// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。
///
/// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。
pub(in crate::app) fn refresh_view(&mut self) {
// **不变式先对齐再算宽度**(M104 / 复审 r1 P2-1):`Route::Agent` ⟺ `RailState::Detached`。
// 每一次路由变化都会走到这里,所以它是「档位 ↔ 路由」的唯一点;放在算宽度之前,
// 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。
self.sync_agent_route();
// M119:把「栏可见吗」写回 presenter(未读口径的输入,两个窄入口:`navigate` + `prefs_mut`)。
// 必须在 `sync_agent_route` 之后:档位刚被它对齐过,这里读到的才是这一帧的真值。
self.sync_agent_presenter();
// M105:取消安装的提示语在**这里**弹(`refresh_view` 是事件 apply 之后必经的那一处)。
// 文案由页面层求(`pages::cancelled_toast`),presenter 只发信号 ——
// 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。
if self.state.install_cancelled != self.install_cancelled_seen {
self.install_cancelled_seen = self.state.install_cancelled;
// M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位已删——它恒英文,
// 中文界面会弹出英文提示,那正是 M105 r1 复审 P2-2 要堵的同屏两语)。
let text = pages::cancelled_toast(self.lang());
self.state.toasts.push(ToastKind::Info, text);
}
// M161:启动收尾的提示语也在这里弹(presenter 只发结构:结局 + 退出码 + 失败阶段)。
// 此前 presenter 直接推一条自己拼好的中文 Toast,英文界面会弹中文。
if self.state.launch_ends != self.launch_ends_seen {
self.launch_ends_seen = self.state.launch_ends;
if let Some(finished) = self.state.last_launch_end.as_deref() {
let text = view::instances::launch_toast(finished, self.lang());
let kind = if finished.outcome == unlucky_presenter::dto::LogOutcome::Ok
&& finished.failure.is_none()
{
ToastKind::Success
} else {
ToastKind::Error
};
self.state.toasts.push(kind, text);
// M187:失败要**弹窗**(Toast 会自己消失,崩溃现场不能只靠它)。
let ended = finished.clone();
self.open_failure_dialog(&ended);
}
}
// M224:详情页 ⇄ MODS 区的绑定与取数(换实例就重新列一次 mods/)。
// **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态
// (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。
self.sync_mods();
// M235:详情页 ⇄ SAVES 区的绑定与取数(换实例就重新列一次存档)。同样是**建模型之前**。
self.sync_saves();
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
self.note_mods_failure();
// M247(复审 r1 P2-1):被确认的世界已经从快照里消失 ⇒ 收起那个确认框。
// 不这样的话主按钮就是一次静默空操作(点了没删、也不说一句)。
self.settle_saves_confirm();
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category;
// 草稿同理(M105):它也是页面自己持有的输入,重建模型时带回去,
// 否则每来一条事件(连安装进度都算)用户填的加载器就被抹回「原版」。
let draft = self.download_draft.clone();
// 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
let width = self.agent.content_width(self.width);
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
self.view = super::super::view::model(
&self.state,
width,
self.height,
self.options.force_state,
category,
&draft,
self.lang(),
&self.content,
);
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
self.sync_launch_form();
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width);
// M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量
// (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。
self.dialog_model = self.dialog_modal();
self.view_dirty = false;
self.refreshes += 1;
}
/// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。
///
/// 判据由 presenter 给(`SavesState::take_closed_confirm`:那个世界从快照里消失了),
/// **框的 id 也由它带着**——所以只收我们自己那个框,崩溃卡片/进度框一概不碰。
///
/// 为什么必须收:目标已经作废,主按钮就成了一次**静默空操作**(用户点了「删除世界」,
/// 框关了、什么也没发生、也没一句解释)。少删是安全的,但"看起来坏了"不是可接受的结果。
fn settle_saves_confirm(&mut self) {
let Some(dialog) = self.state.saves.take_closed_confirm() else {
return;
};
if self
.state
.dialogs
.current()
.is_some_and(|current| current.id == dialog)
{
self.close_dialog();
}
}
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
///
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
/// 免得「A 的草稿被当成 B 的初值」。
fn sync_launch_form(&mut self) {
let Route::Detail(id) = self.state.route() else {
self.launch_form.bound = None;
return;
};
match self
.state
.instances
.iter()
.find(|instance| &instance.id == id)
{
Some(instance) => {
let view = instance.launch.clone();
self.launch_form.sync(&view);
}
None => self.launch_form.bound = None,
}
}
/// 详情页 ⇄ MODS 区的绑定与取数。
///
/// 判据只有一条:**绑定变了**(进详情页 / 换实例 / 退出详情页)。
/// - 绑定函数自己会清掉旧快照(A 的列表不许画在 B 的详情页上,见 presenter 的 `ModsState::bind`);
/// - 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Mods(List)` 去后台列目录。
///
/// 为什么放在 `refresh_view` 里:它是**唯一**的「路由变了」漏斗(点行、返回、首屏自动选中都走它)。
/// 派发会再进一次 `refresh_view`(`dispatch` → `pump` → 重建),但那时 `bound` 已经相等,
/// 这一支直接返回,不会重复发请求、也不会无限递归。
fn sync_mods(&mut self) {
let wanted = match self.state.route() {
Route::Detail(id) => Some(id.clone()),
_ => None,
};
if self.state.mods.bound == wanted {
return;
}
self.state.bind_mods(wanted.as_deref());
let Some(id) = wanted else {
return;
};
// 出图取证(`ULL_MODS_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `mods_shot` 的模块文档)。
if mods_shot::enabled() {
self.state.mods.changed(mods_shot::view(&id));
return;
}
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒(`watch_until`),
// 而这里只是"进详情页顺手后台列一次目录"——结果在下一拍心跳(1 Hz)落地就够了。
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红(`subscription` 的三条用例立刻红:实测过)。
self.backend
.dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id))));
}
/// 详情页 ⇄ SAVES 区的绑定与取数(M235)。
///
/// 判据与 `sync_mods` **一字不差**:绑定变了(进详情页 / 换实例 / 退出详情页)才动;
/// 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Saves(List)` 去后台列目录。
///
/// 不弹 Toast:存档区的失败就地画在卡片里(带「重试」),再弹一条是同一件事说两遍。
fn sync_saves(&mut self) {
let wanted = match self.state.route() {
Route::Detail(id) => Some(id.clone()),
_ => None,
};
if self.state.saves.bound == wanted {
return;
}
self.state.bind_saves(wanted.as_deref());
let Some(id) = wanted else {
return;
};
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
if saves_shot::enabled() {
saves_shot::seed(self, &id);
return;
}
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
// 这里只是"进详情页顺手后台列一次目录",结果在下一拍心跳落地就够了(M224 同款理由:
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红)。
self.backend
.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(id))));
}
/// 启停失败的提示语:每条**新**失败弹一次 Toast。
///
/// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时,
/// **只弹一次**——第二次是同一件事的重复,再弹就是刷屏(用户已经看见了那句红字)。
/// 换了文件、换了实例、或失败大类变了(例如"不覆盖冲突"变成"被占用"),三元组就变了,
/// 于是照样会再弹一次:提示语跟的是"新事实",不是"点击次数"。
fn note_mods_failure(&mut self) {
let Some(failure) = self.state.mods.failed.as_ref() else {
self.mods_failed_seen = None;
return;
};
let key = (failure.instance.clone(), failure.file.clone(), failure.kind);
if self.mods_failed_seen.as_ref() == Some(&key) {
return;
}
let text = pages::mods_failure_line(failure, self.lang().catalog());
self.state.toasts.push(ToastKind::Error, text);
self.mods_failed_seen = Some(key);
}
}
+266
View File
@@ -0,0 +1,266 @@
//! 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)。
//!
//! # 它解决什么
//!
//! D30 的 SAVES 区截图需要一屏**有内容的列表**(世界 + 截图)。产品路径的列表来自后台线程
//! (列 `saves/` → 快照),而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及
//! 完成,截出来会是「正在读取 saves/…」。M157 / M222 / M223 / M224 都是同一个问题,处理方式一致:
//! **状态注入**,注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
//!
//! # 边界(不要把它读成产品能力)
//!
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
//! - 它注入的是**快照**([`SavesListView`]),不是页面模型——`Saves::from_state` 的时间格式化、
//! 计数、「有图标」标记走的都是产品代码;
//! - 真实链路由端到端用例钉住:`core::game::saves::tests`(真盘只读列举)与
//! `presenter::host::saves::tests`(派生运行目录 → 快照)。
//!
//! ```text
//! ULL_SAVES_FIXTURE=1 ULL_INSTANCE_STATE=demo ULL_START_ROUTE=instances \
//! unlucky-launcher-iced --screenshot=saves.png --software-renderer
//! ```
/// 夹具开关是否打开(`ULL_SAVES_FIXTURE` 非空且不为 `0`)。
#[must_use]
pub fn enabled() -> bool {
std::env::var("ULL_SAVES_FIXTURE")
.map(|value| {
let value = value.trim();
!value.is_empty() && value != "0"
})
.unwrap_or(false)
}
/// 2024-05-01 10:00 UTC(写死的 UTC 毫秒,不是"现在")。
///
/// 写死有两个原因:两张主题图要能逐像素对照;而且它能证明「毫秒 → `YYYY-MM-DD HH:MM UTC`」
/// 这条换算在页面上真的走通了(`docs` 里的期望值就是这个时刻)。
const NEWEST: u64 = 1_714_557_600_000;
/// 比 [`NEWEST`] 早 32 天。
const MIDDLE: u64 = NEWEST - 32 * 24 * 3_600_000;
/// 比 [`NEWEST`] 早 42 天。
const OLDEST: u64 = NEWEST - 42 * 24 * 3_600_000;
/// 一份固定快照(实例 id 用调用方给的那个)。
///
/// 其中一个世界/一张截图的 mtime 是 `None`:那是「时间未知」这条分支的唯一出图证据。
#[must_use]
pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
use unlucky_presenter::dto::{
SavesListView, ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind,
ScreenshotView, WorldView,
};
let world = |dir: &str, icon: bool, modified_ms: Option<u64>| WorldView {
dir: dir.to_string(),
icon,
modified_ms,
};
let shot = |file: &str, bytes: u64, modified_ms: Option<u64>, preview: ScreenshotPreview| {
ScreenshotView {
file: file.to_string(),
bytes,
modified_ms,
preview,
}
};
SavesListView {
instance: instance.to_string(),
dir: "D:/ULL/.ULL/data/instances/Better-MC/saves".to_string(),
exists: true,
worlds: vec![
world("Better World", true, Some(NEWEST)),
world("Redstone Lab", true, Some(MIDDLE)),
world("Skyblock", false, Some(OLDEST)),
world("Test Flat", false, None),
],
screenshots_dir: "D:/ULL/.ULL/data/instances/Better-MC/screenshots".to_string(),
screenshots_exists: true,
// 四张截图,把缩略图墙的**四种格子**一次摆出来(M251):
// ① 白天(真像素)② 黄昏(真像素)③ 「未生成预览」占位 ④ 具名的失败原因。
// 像素是**合成的**(下面 `scene`):夹具不连真实解码器,出图只证明墙的排版与状态,
// 解码由 presenter 的 `host::saves::thumbs` 用例用真 PNG 钉住。
screenshots: vec![
shot(
"2024-05-01_10.00.00.png",
1_887_436,
Some(NEWEST),
ScreenshotPreview::Ready(scene(1)),
),
shot(
"2024-04-30_21.14.05.png",
2_097_152,
Some(MIDDLE),
ScreenshotPreview::Ready(scene(2)),
),
shot(
"2024-04-20_08.30.00.png",
964_608,
None,
ScreenshotPreview::NotGenerated,
),
shot(
"2024-04-11_19.02.41.png",
9_437_184,
Some(OLDEST),
ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: "9437184 > 8388608".to_string(),
}),
),
],
skipped: Vec::new(),
// 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。
// 要看「备份成功那句话」长什么样,用 `ULL_SAVES_FIXTURE=backed-up`(见 `seed`)。
last_op: None,
}
}
/// 夹具形态(由 `ULL_SAVES_FIXTURE` 的取值决定;未知值 = 默认那一种)。
///
/// | 取值 | 出图抓什么 |
/// |---|---|
/// | `1`(或任何未知值) | 有内容的列表:世界行右侧的重命名 / 备份 / 删除 |
/// | `rename` | 行内重命名进行中(输入框 + 改名/取消) |
/// | `confirm` | 删除的二次确认模态框 |
/// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) |
/// | `running` | 「游戏正在运行,先退出再操作」那条写失败(M247 的在飞闸门) |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// 默认:列表 + 行操作区。
Plain,
/// 行内重命名进行中。
Rename,
/// 删除确认模态。
Confirm,
/// 备份成功的回执。
BackedUp,
/// 写操作被「游戏在运行」挡下。
Running,
}
fn mode() -> Mode {
match std::env::var("ULL_SAVES_FIXTURE")
.unwrap_or_default()
.trim()
{
"rename" => Mode::Rename,
"confirm" => Mode::Confirm,
"backed-up" => Mode::BackedUp,
"running" => Mode::Running,
_ => Mode::Plain,
}
}
/// 注入这一屏需要的状态(**只走产品路径的入口**:`changed` / `start_rename` / `ask_delete` /
/// `dialogs.push`,一处新渲染分支都没有)。
pub fn seed(app: &mut crate::app::state::App, instance: &str) {
let mode = mode();
let mut view = view(instance);
if mode == Mode::BackedUp {
view.last_op = Some(unlucky_presenter::dto::SavesOped::BackedUp {
dir: "Better World".to_string(),
file: "Better World-20240501-100000.zip".to_string(),
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
bytes: 84_213_760,
skipped: 0,
});
}
let first = view.worlds.first().map(|world| world.dir.clone());
app.state.saves.changed(view);
let Some(dir) = first else {
return;
};
match mode {
Mode::Rename => app.state.saves.start_rename(&dir),
// 在飞闸门那条失败(`host::saves::dispatch` 在 `running` 时直接回它)。
Mode::Running => app
.state
.saves
.failed(unlucky_presenter::dto::SavesFailureView {
instance: instance.to_string(),
file: dir,
op: Some(unlucky_presenter::dto::SavesFailureOp::Rename),
kind: unlucky_presenter::dto::SavesFailureKind::GameRunning,
detail: String::new(),
}),
Mode::Confirm => {
let (title, body, primary, secondary) =
crate::pages::saves_delete_prompt(app.lang().catalog(), &dir);
let dialog = app.state.dialogs.push(
unlucky_presenter::dialogs::DialogSource::UserAction,
title,
body,
unlucky_presenter::dialogs::DialogKind::Confirm { primary, secondary },
);
app.state.saves.ask_delete(dialog, &dir);
}
Mode::Plain | Mode::BackedUp => {}
}
}
/// 一张**合成的**缩略图(160×90 RGBA):天空渐变 + 地面 + 太阳/月亮。
///
/// 为什么合成而不是读真图:夹具的职责是「让页面有东西可画」,而它跑在截图通道的第 3 帧——
/// 真解码要真的有一张 PNG 在盘上、还要等后台线程回来。合成像素让出图**确定**(两张主题图
/// 逐像素可比),并且明确地不假装自己是解码证据(那由 presenter 的用例负责)。
///
/// `tone`:`1` = 白天(亮天空 + 绿地面),其余 = 黄昏(橙天空 + 暗地面)。同一套几何,
/// 两种配色——墙上两格一眼能看出"每格各画各的"。
fn scene(tone: u8) -> unlucky_presenter::dto::ThumbnailView {
const W: u32 = 160;
const H: u32 = 90;
// 地平线在 2/3 高度(截图里天空总比地面多)。
const HORIZON: u32 = H * 2 / 3;
let (sky_top, sky_bottom, ground, sun): ([u8; 3], [u8; 3], [u8; 3], [u8; 3]) = if tone == 1 {
(
[128, 176, 240],
[200, 224, 248],
[86, 138, 66],
[255, 246, 186],
)
} else {
([96, 66, 96], [232, 148, 96], [58, 74, 48], [255, 214, 150])
};
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for y in 0..H {
for x in 0..W {
let (r, g, b) = if y < HORIZON {
// 天空:从顶到底线性插值(`t` = 0..=255 的定点比例)。
let t = y * 255 / HORIZON.max(1);
let mix = |from: u8, to: u8| -> u8 {
let span = i32::from(to) - i32::from(from);
(i32::from(from) + span * t as i32 / 255) as u8
};
(
mix(sky_top[0], sky_bottom[0]),
mix(sky_top[1], sky_bottom[1]),
mix(sky_top[2], sky_bottom[2]),
)
} else {
// 地面:按 3px 一条的条纹给出「方块地形」的观感。
let stripe = (y / 3) % 2 == 0;
let shade: u8 = if stripe { 0 } else { 14 };
(
ground[0].saturating_sub(shade),
ground[1].saturating_sub(shade),
ground[2].saturating_sub(shade),
)
};
// 太阳/月亮:右上角一个 18px 的圆(够小,不会盖住画面主体)。
let (dx, dy) = (x as i32 - 126, y as i32 - 26);
let (r, g, b) = if dx * dx + dy * dy <= 18 * 18 {
(sun[0], sun[1], sun[2])
} else {
(r, g, b)
};
pixels.extend_from_slice(&[r, g, b, 255]);
}
}
unlucky_presenter::dto::ThumbnailView {
width: W,
height: H,
pixels,
}
}
+232
View File
@@ -0,0 +1,232 @@
//! 主题与 `Ui` 的重算口径(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
//!
//! 三件事在这里:①`Ui` 什么时候重建([`App::rebuild_ui`])②主题档怎么定
//! ([`App::set_flavor`] / [`App::apply_config_theme`])③读当前求值器([`App::ui`])。
//!
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
use crate::app::diag::diag;
use crate::app::message::Message;
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::ThemeMode;
use crate::theme::Ui;
use super::App;
impl App {
/// 本次进程是否走截图通道。
pub(in crate::app) fn has_screenshot(&self) -> bool {
self.options.screenshot.is_some()
}
/// 切主题:重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
///
/// M93 起 `Ui` 有两个来源:配色来源取到了图(①②级)就用解析出的方案,否则用令牌 baseline。
/// 所以这里改叫「重算 `Ui`」:先让外观状态记住新深浅(配色解析要它),再重建。
pub(in crate::app) fn set_flavor(&mut self, flavor: SchemeFlavor) {
if self.ui.flavor() == flavor && self.appearance.flavor() == flavor {
return;
}
self.appearance.set_flavor(flavor);
let previous = self.ui.name();
self.rebuild_ui();
diag!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
previous,
self.ui.name()
);
}
/// 按「强调色 / 配色来源是否接管」重建 [`Ui`]。
///
/// **优先级(M250):显式强调色 > D38 取色方案 > 出厂品牌板**。手填的色是直接意图,取色是从
/// 图片派生的,所以强调色只在用户真的填了合法值时压过取色——两条路都只决定「颜色来自哪里」,
/// `flavor`(明暗 / 跟随系统)仍由 `self.appearance.flavor()` 决定。
///
/// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是
/// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的
/// 像素**改掉。这条偏离登记在 `pages::appearance` 的偏离表 #4。
pub(in crate::app) fn rebuild_ui(&mut self) {
// 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一**
// 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。
let panel_alpha = self.appearance.effective_panel_alpha();
// 三选一规则只在 `theme::tokens` 里写一遍:出图 harness 也调它(两头各抄一份迟早
// 出现「图上有强调色、产品里没有」,而那种偏差在图上看不出来)。
let (ui, colors) = crate::theme::tokens(
self.appearance.flavor(),
self.appearance.accent.color(),
self.appearance.derived_scheme().copied(),
self.families,
);
match colors {
crate::theme::Colors::Accent => {
// M250:一行里同时给「强调色生效了」与「取色方案被谁压住」,两件事都能读出来。
diag!(
"[appearance] accent={} → Ui::with_accent(只换 primary/secondary/tertiary;取色方案 palette={} 被压住)",
self.appearance.accent.log_token(),
self.appearance.palette_level()
);
}
crate::theme::Colors::Scheme => {
diag!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level()
);
}
crate::theme::Colors::Board => {
// 非法值:**保持出厂板**,但错行留在设置页(不静默),日志里也留一条。
if let Some(error) = self.appearance.accent.error() {
diag!("[appearance] accent 非法({error:?})→ 出厂板,错行见设置页");
}
}
}
self.ui = ui.with_panel_alpha(panel_alpha);
}
/// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。
///
/// **截图通道直接跳过**:`--screenshot` 的主题唯一取值口是 `Options::capture_flavor`
/// (D30 可复现),读本机 `launcher.json` 会让同一份代码在两台机器上出不同的图。
///
/// `ThemeMode::Auto` 不在这里处理:那要看系统主题(`iced::system::theme()` 的结果在
/// `Message::SystemTheme` 里落地)。
pub(in crate::app) fn apply_config_theme(&mut self) {
if self.has_screenshot() || self.options.theme.is_some() {
return;
}
let mode = self.bridge.load().1;
self.theme_mode = mode;
// M229:设置页那一行画的是这个字段(唯一写者见 [`App::set_theme_mode`])。
self.settings.language_appearance.theme = mode;
match mode {
ThemeMode::Light => self.set_flavor(SchemeFlavor::Light),
ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark),
ThemeMode::Auto => {}
}
}
/// 换主题档(M229:设置页「通用 → 主题」→ `pages::Message::Theme`)。
///
/// 四件事:①改 `theme_mode`(唯一真相)②同步设置页那一行的档位 ③重建 `Ui`(色板换了,
/// `Theme::name` 与 49 个角色一起变,iced 才会重算缓存)④落盘 `appearance.theme_mode`。
/// `Auto` 自己没有颜色,要等系统回答——所以返回 `iced::system::theme()` 那个 Task,
/// 由既有的 `Message::SystemTheme` 分支落地(它只在 `theme_mode == Auto` 时吃这条消息)。
pub(in crate::app) fn set_theme_mode(&mut self, mode: ThemeMode) -> Task<Message> {
self.settings.language_appearance.theme = mode;
if mode == self.theme_mode {
return Task::none();
}
self.theme_mode = mode;
let prefs = self.appearance_prefs.clone();
self.bridge.persist(&prefs, mode);
match self.bridge.path() {
Some(path) => diag!("[theme] mode={} 已写回 {}", mode.as_str(), path.display()),
None => diag!(
"[theme] mode={} 未落盘(数据根解析不出来,内存态运行):重启会回到旧值",
mode.as_str()
),
}
match mode {
ThemeMode::Light => {
self.set_flavor(SchemeFlavor::Light);
self.after_theme_switch();
Task::none()
}
ThemeMode::Dark => {
self.set_flavor(SchemeFlavor::Dark);
self.after_theme_switch();
Task::none()
}
// 跟随系统:当前先按已有深浅画,紧接着由系统主题回答覆盖
// (`set_flavor` 有「同档不重建」的保护,所以这里不必先猜一次)。
ThemeMode::Auto => iced::system::theme().map(Message::SystemTheme),
}
}
/// 换档之后重建视图模型 + 记一行取证日志(色板名就是证据:`ink` / `silver`)。
fn after_theme_switch(&mut self) {
self.view_dirty = true;
self.refresh_view();
diag!(
"[theme] mode={} board={} Theme::name={}",
self.theme_mode().as_str(),
self.board_name(),
self.ui.name()
);
}
/// 当前主题档(设置页那一行的选中态来自 `settings.groups`,这里是取证与测试的读数口)。
pub(in crate::app) fn theme_mode(&self) -> ThemeMode {
self.theme_mode
}
/// 当前色板名(日志与测试用:`ink` / `silver`;取色方案接管时仍报它所属深浅的那块)。
pub(in crate::app) fn board_name(&self) -> &'static str {
self.ui.board().name()
}
/// 当前令牌求值器(测试与截图日志用)。
pub(in crate::app) fn ui(&self) -> &Ui {
&self.ui
}
}
#[cfg(test)]
mod tests {
use super::super::App;
use crate::app::components::feedback::progress;
use crate::cli::Options;
use crate::pages::appearance::Change;
use crate::theme::Families;
use unlucky_design::color::{Color, SchemeFlavor};
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 强调色接线(M250 ACCENTWIRE)的 App 级判据:`rebuild_ui` 真的换了令牌。
///
/// **这条是判据一的变异探针**:把 [`App::rebuild_ui`] 改回「恒走 `Ui::new` 出厂板」
/// (或在它前面短路掉 `accent.color()` 分支),下面 `assert_ne!` 当场红——`pages/appearance`
/// 那条用例只证明「`Ui::with_accent` 能给出不同的色」,抓不到「谁去调它」。
///
/// 抓得住:接线不存在 / 被短路 / 强调色解析结果没进 `Ui`。
/// 抓不住:像素(样式闭包被别处覆盖)——那是出图的事。
#[test]
fn rebuild_ui_follows_the_configured_accent() {
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
);
// 出厂态:同一组件(进度指示条 = `Role::Primary`)取到的值
let (_, factory_primary) = progress::linear_colors(app.ui());
assert_eq!(app.ui().name(), "ull-silver");
// 设置页填色 → `on_appearance` 会 rebuild 一次(与真机路径同一个函数)
app.on_appearance(Change::Accent(Color::rgb(0x00, 0x6A, 0x5C).to_hex()));
let (_, accent_primary) = progress::linear_colors(app.ui());
assert_ne!(
factory_primary, accent_primary,
"填了自定义强调色,`rebuild_ui` 却还给出出厂板的 Primary ⇒ 接线没落地"
);
assert_eq!(
app.ui().flavor(),
SchemeFlavor::Light,
"强调色只换颜色来源,不改明暗"
);
}
}
+350
View File
@@ -0,0 +1,350 @@
//! 订阅:**按需**帧节拍(presenter 的 TICK)+ 窗口尺寸事件。
//!
//! ## 为什么要「按需」
//!
//! iced_winit 在处理完任何一条消息后都会 `request_redraw()`,而 `window::frames()` 监听的是
//! `RedrawRequested` —— 于是「订阅帧 → 收 Frame → update → 再触发重绘」是一条**自持循环**:
//! 只要订阅着,窗口就永远在重绘(M90 r1 就是这样,CPU 空转)。
//!
//! 现在只在**真的需要 33 ms 节拍**的时候订阅([`App::needs_frames`]):
//!
//! | 条件 | 为什么需要 33 ms 节拍 |
//! |---|---|
//! | 截图通道 | 第 3 帧要落盘 |
//! | **有动画在跑**(M192) | 动效时间轴要逐帧采样(帧泵自己按 16 ms 结算,见 `components::motion`) |
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
//! | 有未过期的 toast | toast 按毫秒倒计时 |
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
//!
//! 上面五条**共享同一个布尔**:订阅表里 `window::frames()` 只出现一处(写在同一个 `if` 里),
//! 所以「动画」与「观察窗 / Busy」同时成立时也只有一条订阅——不双订阅靠的是结构,不是判重。
//!
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
//!
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
//!
//! r2 把空闲的帧订阅掐掉之后,`config_poll_due` 就再也没被调用过 —— 外部 CLI / AI 工具改
//! `launcher.json` 后主题**永不生效**(不是「等下次交互」,是「等下一次 dispatch」)。这与
//! D4「AI 能读改 ULL 配置」直接冲突。
//!
//! 所以空闲态换成一个 **1 Hz 的自续心跳**([`crate::app::message::Message::Heartbeat`],
//! 由 `Task::perform(sleep(1s))` 驱动,见 `update::heartbeat`):每秒醒一次、推进一拍
//! presenter(内部按 33 ms 折算 30 拍,于是 `config_poll_due` 仍是「约 1 秒比对一次 mtime」)。
//!
//! 为什么不用 `window::frames()` + 1 s 闸门来实现这个低频时钟:帧订阅**本身就是**自持重绘循环
//! (iced_winit 处理完任何消息都 `request_redraw()`),闸门只能省掉 `advance()` 里的活,省不掉
//! 整窗重绘——实测软件渲染约 **500 帧/秒**。心跳是「每秒一帧」,差三个数量级。
//!
//! ## 已知代价(都登记,不藏)
//!
//! 1. **忙碌态仍是自持帧循环**:`Running`/`Busy` 一天不结束,就一天按帧刷(软件渲染实测约
//! 970 帧/秒,refreshes 不增长 = 纯像素空转)。功能上需要 tick 来发现游戏退出,但「整段
//! 运行期近千帧/秒重绘」是真实开销;要收口得让 `Running` 走更慢的时钟(follow-up)。
//! 2. 心跳用 `thread::sleep` 占线程池一个线程 1 秒;1 Hz 下可接受,但不是优雅解法(正解仍是
//! `iced::time::every` + tokio/smol,Cargo.toml 在 scope 外,已报 finding)。
use std::time::Instant;
use iced::{window, Subscription};
use super::components::motion;
use super::message::Message;
use super::state::App;
impl App {
/// 本次进程的订阅表。
pub(super) fn subscription(&self) -> Subscription<Message> {
let frames = if self.needs_frames() {
window::frames().map(|_| Message::Frame)
} else {
Subscription::none()
};
// 断点 600 / 840 要跟着真窗口宽度走;截图模式的窗口尺寸由参数钉死,不吃 resize。
let events = if self.has_screenshot() {
Subscription::none()
} else {
window::events().map(|(_, event)| Message::Window(event))
};
// 键盘(M104):`Esc` 收栗 / `Tab` 走焦点环 / `Enter` 激活焦点键。
//
// 为什么订阅整条键盘流而不是自己挑键:`iced::keyboard::listen()` 是唯一的全窗键盘入口,
// 挑键与「什么状态下该做什么」的解释权放在 `app/update/agent.rs`(一眼看完),
// 订阅表只负责搬运。截图通道不开它——D30 要可复现,键盘事件一个都不该进图。
let keys = if self.has_screenshot() {
Subscription::none()
} else {
iced::keyboard::listen()
.map(|event| Message::Agent(crate::shell::agent::Message::Key(event)))
};
Subscription::batch([frames, events, keys])
}
/// 是否订阅帧事件(= 截图模式要帧数闸门)。
pub(super) fn wants_frames(&self) -> bool {
self.has_screenshot()
}
/// 现在要不要**时钟**(见模块文档的表)。
pub(super) fn needs_frames(&self) -> bool {
self.has_screenshot()
// M192:只有动画在跑时才多这一条(帧泵清空轨道后立刻为假 ⇒ 退回 1 Hz 心跳)。
|| motion::needs_frames()
|| !self.state.toasts.is_empty()
|| !self.state.running().is_empty()
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|| self.settings.java.scanning.is_some()
|| self
.watch_until
.is_some_and(|deadline| Instant::now() < deadline)
}
}
#[cfg(test)]
mod tests {
//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。
//!
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
//!
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。
//! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表,
//! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。
//!
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
use super::*;
use crate::app::components::motion::{Slot, FRAME_MS};
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 走到「真·空闲」:没有观察窗、没有忙碌实例、没有动画(首屏那一条 Boot 已跑完)。
///
/// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完,
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
fn idle_app() -> App {
motion::reset();
motion::configure(MotionScale::STANDARD, None);
let mut app = headless();
// 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档)
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// **按运行时顺序**:空闲态一条消息进来(换页)⇒ **同一拍**订阅表就要有帧;跑完立刻退订。
///
/// 变异证明:把起跑搬回 `view`(删掉 `App::update` 里的 `sync_motion`)⇒ 第一步的
/// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。
#[test]
fn a_state_change_opens_the_frame_subscription_in_the_same_tick() {
let mut app = idle_app();
assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧");
// 用户点了一次导航轨:**一条**消息,之后不再补任何消息
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert!(
app.watch_until.is_none(),
"换页不该打开观察窗(这里要证明帧是**动效**要来的,不是派发顺手开的)"
);
assert!(motion::needs_frames(), "换页必须在同一拍起跑");
assert!(
app.needs_frames(),
"同一拍的订阅表必须能看见轨道(iced 先算订阅表、后建视图 —— 复审 P1-1)"
);
run_until_idle();
assert!(!motion::needs_frames(), "动画跑完帧泵必须清空");
assert!(
!app.needs_frames(),
"动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)"
);
motion::reset();
}
/// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。
#[test]
fn opening_a_detail_also_opens_the_frames_in_the_same_tick() {
let mut app = idle_app();
// 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
run_until_idle();
assert!(!app.needs_frames());
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert!(app.needs_frames(), "点进详情必须当拍订阅帧");
run_until_idle();
assert!(!app.needs_frames());
motion::reset();
}
/// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。
#[test]
fn pages_that_render_elsewhere_do_not_start_a_track() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts)));
assert!(
!motion::needs_frames(),
"账户页没有 pages 树可动,不许白开帧"
);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert!(motion::needs_frames(), "从账户页回到某一屏仍然是换页,要跑");
run_until_idle();
motion::reset();
}
/// **没有窗口就不起跑**:`window::frames()` 在没窗口时一条消息都不会有,起跑只是白占一档;
/// 首屏那一条也因此要等窗口到了才算「第一眼」。
#[test]
fn without_a_window_nothing_animates() {
motion::reset();
motion::configure(MotionScale::STANDARD, None);
let mut app = headless();
assert!(app.window.is_none(), "前提:还没拿到窗口句柄");
let _ = app.update(Message::Boot);
app.watch_until = None;
assert!(!motion::needs_frames(), "没有窗口 ⇒ 不许起跑");
// 窗口来了:首屏入场这时才开始(不是被启动期那棵空树吃掉)
let _ = app.update(Message::WindowReady(Some(iced::window::Id::unique())));
assert!(app.needs_frames(), "窗口到了之后「首屏」才起跑");
run_until_idle();
motion::reset();
}
/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。
///
/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。
#[test]
fn no_scale_requests_no_frames_at_all() {
motion::reset();
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
motion::configure(MotionScale::NONE, None);
// 三种状态变化:换页 / 换详情 / 再换回来
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert!(!app.needs_frames(), "关掉动效后换页一帧都不该订阅");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert!(!app.needs_frames());
run_until_idle();
assert!(!motion::needs_frames());
assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0");
motion::reset();
}
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。
///
/// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看
/// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`——
/// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。
#[test]
fn there_is_exactly_one_frame_subscription() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rust_files(&src, &mut files);
assert!(
files.len() >= 40,
"只扫到 {} 个文件:扫描逻辑坏了",
files.len()
);
let mut hits = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读源文件");
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
let code = product
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join(
"
",
);
let count = code.matches("window::frames()").count();
if count > 0 {
let name = path
.strip_prefix(&src)
.unwrap_or(&path)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
hits.push((name, count));
}
}
hits.sort();
let expected = [
("app/subscription.rs".to_string(), 1_usize),
("pages/harness.rs".to_string(), 1_usize),
];
assert_eq!(
hits, expected,
"`window::frames()` 只许出现在 `app/subscription.rs`(产品接线)\
与 `pages/harness.rs`(独立 example 的自有 app),且各一处"
);
}
/// 递归收集 `dir/**/*.rs`。
fn collect_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("读目录") {
let path = entry.expect("目录项").path();
if path.is_dir() {
collect_rust_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。
#[test]
fn slots_are_the_three_documented_ones() {
assert_eq!(Slot::ALL.len(), 3);
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]);
}
}
Loaded 100 of 1580 files, more files were not shown because too many files have changed in this diff. Show more