Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

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
+190
View File
@@ -0,0 +1,190 @@
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
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
- run: cargo test --workspace --all-targets
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
# 运行时冒烟(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
+381
View File
@@ -0,0 +1,381 @@
name: Guards
# 架构与合规守门(与 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)
#
# 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:
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'
tree=$(cargo tree -p unlucky-core --edges normal --prefix none)
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"
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden")
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` 的能力(Win32 的
# `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。
# 判据必须是**集合相等**而不是「黑名单命中」:
# - 新增第二个显式放行 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/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 秒。
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)
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 落点(D36 只允许一个):"
printf '%s\n' "$extra" | sed 's/^/ - /'
fi
if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then
echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。"
fi
echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。"
exit 1
fi
echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED"
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
+369
View File
@@ -0,0 +1,369 @@
name: Release
# 发布流水线:tag `v*` → 四平台(六槽位)矩阵构建 → 单文件产物 + SHA256SUMS
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
#
# 契约唯一真相源:packaging/README.md(命名/目录/运行时依赖)。
# 打包本体在 packaging/make_artifacts.py(本地与 CI 同一份脚本,逐字节同布局)。
# 手动触发(workflow_dispatch)默认只跑构建不建 Release —— 用来验证矩阵仍然编得过。
# 注意 `inputs.prerelease` 只在 **tag ref** 上 dispatch 才可达:Actions 页面的
# "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。
# 分支 ref 的 dispatch 与 push 事件一样被 release job 的 `if: github.ref_type == 'tag'`
# 挡在发布之外,所以「dispatch 只构建不发布」与「dispatch 可关 pre-release」并不矛盾。
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
prerelease:
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: build (${{ matrix.slot }})
strategy:
fail-fast: false
matrix:
include:
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
# 产物根 target/release;自检与打包都从 matrix.build_dir 取路径(不硬编码)。
- slot: windows-x64
os: windows-latest
build_dir: target/release
rustflags: -C target-feature=+crt-static
# Windows arm64:在 windows-latest(x64 宿主)上**交叉编译**到 aarch64-pc-windows-msvc。
# 为什么不用 windows-11-arm 原生 runner(GitHub 文档 2026 已把 `windows-11-arm` 与
# `windows-11-vs2026-arm` 列为公开仓库**免费**的标准 arm64 runner,原生路线可行):
# ① 双 Windows 槽位共用同一套已验证镜像/工具链,tag 重跑行为与既有槽位一致;
# ② M50 的 dumpbin 定位链取的是 `Hostx64/x64/dumpbin.exe`,dumpbin 读 ARM64 PE 的
# 导入表没问题,而原生 ARM 宿主上那条链得再长一条 `Hostarm64/arm64` 分支——为一个
# 槽位放宽刚立的门禁不值得;
# ③ 交叉编译产物路径变成 target/<triple>/release,已用 matrix.build_dir 参数化。
# 若将来改成原生:只需把 os 换成 windows-11-arm、删掉 target/rustflags 依赖并给 dumpbin
# 定位链补 Hostarm64 分支(届时再评估,不在这里预先放宽)。
- slot: windows-arm64
os: windows-latest
target: aarch64-pc-windows-msvc
build_dir: target/aarch64-pc-windows-msvc/release
rustflags: -C target-feature=+crt-static
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 的 glibc 明显更高)。
- slot: linux-x64
os: ubuntu-22.04
build_dir: target/release
# Linux arm64:公开仓库免费的原生 ARM64 runner(原生构建,不做交叉)。
# 用 `ubuntu-22.04-arm` 而**不是** 24.04:与 x64 同 glibc 地板(2.35),
# 覆盖 Debian 12 / Ubuntu 22.04 arm64 / Raspberry Pi OS Bookworm(2.36)这类实际在跑的
# ARM 发行版;换 24.04 会把地板抬高、把上面前两个挡在门外。该家族标签有
# `ubuntu-22.04-arm` / `ubuntu-24.04-arm` / `ubuntu-26.04-arm`,均 4C/16GB、
# 公开仓库免费不限量(GitHub 文档 Standard runners for public repositories)。
- slot: linux-arm64
os: ubuntu-22.04-arm
build_dir: target/release
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
- slot: macos-arm64
os: macos-latest
build_dir: target/release
- slot: macos-x64
os: macos-15-intel
build_dir: target/release
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
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
# 只有交叉编译槽位需要额外 target(原生槽位留空 → 本步整体跳过)。
# 不把 `${{ }}` 直接拼进 bash:与后面的打包步骤同一纪律,一律经 env 传入再展开。
- name: 追加交叉编译目标 ${{ matrix.target }}
if: matrix.target != ''
shell: bash
env:
TARGET: ${{ matrix.target }}
run: rustup target add "$TARGET"
# 交叉链接的前提是镜像里真有 ARM64 的 MSVC 库(VC `/lib/arm64/libcmt.lib`,+crt-static
# 就靠它)与 Windows SDK 的 ARM64 导入库(`um/arm64/kernel32.lib`)。缺了照样是失败,
# 但先给一句能照做的话,而不是让链接器吐一屏乱码才发现方向:门禁不降级,只把失败说清楚。
- name: Windows ARM64 交叉工具链体检
if: matrix.target == 'aarch64-pc-windows-msvc'
shell: bash
run: |
set -euo pipefail
roots=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
vc_lib=$(find "${roots[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
sdk_version=$(ls -d "C:/Program Files (x86)/Windows Kits/10/Lib"/* 2>/dev/null | tail -n1 || true)
sdk_lib=""
if [ -n "$sdk_version" ]; then
sdk_lib=$(find "$sdk_version/um/arm64" -maxdepth 1 -name 'kernel32.lib' 2>/dev/null | head -n1 || true)
fi
echo "MSVC ARM64 静态 CRT: ${vc_lib:-<缺失>}"
echo "SDK ARM64 导入库: ${sdk_lib:-<缺失>}"
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
echo "::error::交叉链接缺 ARM64 库:给 VS 加上 Microsoft.VisualStudio.Component.VC.Tools.ARM64(含 ARM64 Windows SDK),或把该槽位改用原生 windows-11-arm runner"
exit 1
fi
# M118(D26):**Slint 版停发**。旧写法 `--bin unlucky-launcher` 打的是 `crates/app`
# (Slint 退役树)的 bin —— 也就是说这条流水线一直在发 Slint 版二进制,而 D26 早已把
# iced 定为产品 UI。现在 GUI 来源只有 `unlucky-launcher-iced`;`crates/app` 也已在根
# Cargo.toml 被 `exclude`,不参与任何 workspace 构建(它连 `--manifest-path` 都编不了)。
# 对外产物名由 `packaging/make_artifacts.py` 的 `asset_stem()` 统一生成,格式是
# `<名字>_<版本号>_<平台>_<架构>`(用户令 2026-11-05):下划线分段,且**不带实现细节
# 字样**(产物名里不出现 `iced`)。`LAUNCHER_BIN` 只用来在构建产物里找文件。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ matrix.rustflags }}
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
target_args=()
if [ -n "$TARGET" ]; then
target_args+=(--target "$TARGET")
fi
cargo build --release --locked "${target_args[@]}" --bin unlucky-launcher-iced --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 跨槽位的关键点:产物在 target/<triple>/release 而不是 target/release。
# M118:校验的是**构建产物**(iced bin 的原始文件名)。对外名 `unlucky-launcher`
# 由 packaging/make_artifacts.py 在落盘时才换上,那一步之后才存在。
EXE: ${{ matrix.build_dir }}/unlucky-launcher-iced.exe
run: |
set -euo pipefail
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
# 可再发行包,那就不是"单文件"了,直接 fail。
#
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位):
# ① vswhere:VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/安装布局无关;
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
# Hostx64 的 dumpbin 读 ARM64 PE 的导入表没问题,所以 arm64 槽位共用同一条链。
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# -find 的模式优先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
for pattern in 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' \
'VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe'; do
hit=$("$VSWHERE" -latest -products '*' -find "$pattern" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=${hit//\\//}
VIA="vswhere"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
# 镜像里的 VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),
# 固定层数的 glob 会在 <年>/<版次> 这两级踩空,所以按路径模式扫。
for root in "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio"; do
[ -d "$root" ] || continue
hit=$(find "$root" -maxdepth 11 -type f \
-path '*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe' 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="find: $root"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="PATH"
fi
fi
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
exit 1
fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
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: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ matrix.slot }}
BUILD_DIR: ${{ matrix.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
# 槽位与产物根都从矩阵来:交叉槽位的二进制不在 target/release,脚本据此定位
# 并回读可执行文件头校验架构(宿主 x64 ≠ 产物 arm64 时冒烟自动跳过,只等 CI 真机)。
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-${{ matrix.slot }}
path: dist
if-no-files-found: error
retention-days: 14
release:
name: GitHub Release
needs: build
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: 矩阵完整性 + 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
# 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。
# 每个槽位都要 GUI 与 CLI 两个资产——只按「槽位名出现过」判会被其中一个掩盖。
for slot in windows-x64 windows-arm64 linux-x64 linux-arm64 macos-arm64 macos-x64; do
for prefix in unlucky-launcher ull; do
if ! compgen -G "dist/$prefix-*-$slot*" >/dev/null; then
echo "::error::矩阵缺产物:$prefix / $slot"
exit 1
fi
done
done
# 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。
# GUI 与 CLI 都要进清单:漏了 ull 就是「发了但没有校验和」。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib
version = os.environ["TAG"].lstrip("v")
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
print(body[:400])
PY
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 标记只在**显式要求**时才断言:
# PRERELEASE=true / false —— 来自 tag ref 上的 workflow_dispatch 输入;
# PRERELEASE 空 —— push 触发的运行或它的 Re-run,此时**不碰**该标记,
# 否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release。
# 首次创建(create)仍保持「pre-release 默认开」的老契约:空值按 true 处理。
create_mark=()
edit_mark=()
case "${PRERELEASE:-}" in
true)
create_mark=(--prerelease)
edit_mark=(--prerelease)
;;
false)
edit_mark=(--prerelease=false)
;;
"")
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上,
# 保证每个下载平台的许可条款都随包分发。
# `ull` 是独立资产(不在 tar.gz 里):headless 用户下一个小文件直接跑,不用解档。
assets=(dist/unlucky-launcher_* dist/ull_* 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
+28
View File
@@ -0,0 +1,28 @@
# 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/
# IDE
.idea/
.vscode/
*.iml
# OS
Thumbs.db
.DS_Store
+5
View File
@@ -0,0 +1,5 @@
edition = "2021"
max_width = 100
newline_style = "Unix"
use_field_init_shorthand = true
use_try_shorthand = true
Generated
+6343
View File
File diff suppressed because it is too large. Load diff
+164
View File
@@ -0,0 +1,164 @@
[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.0"
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"] }
# 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,特此致谢。
+243
View File
@@ -0,0 +1,243 @@
# 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>
Unlucky Launcher (ULL) 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. The launcher embeds an AI agent client (chat / configuration / crash diagnosis) and can be
attached to other agent tooling as an MCP server.
## Highlights
- **Single-file native binary**: the launcher itself needs no JVM, no WebView and no sidecar
runtime. The JDK a game needs is downloaded and managed per instance, not as a launcher
dependency.
- **One binary, two shapes**: 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.
- **One UI stack**: iced 0.14 + Material 3 Expressive, with self-drawn title bar, animated
navigation rail, light / dark / follow-system, and a background-image colour pipeline. The earlier
Slint implementation (`crates/app`) is kept as source-only history: excluded from the cargo
workspace, never built, tested or shipped.
- **CPU software-rendering fallback**: `--software-renderer` keeps the interface rendering normally
on machines with no usable GPU or with broken drivers; the backend probe falls back on its own when
no adapter is usable.
- **AI agent + MCP**: chat, setting edits and crash diagnosis inside the launcher; `ull agent serve`
exposes the same core as an MCP server (stdio or Streamable HTTP) to external agent tools. The
agent rail runs on real presenter state, and says so plainly when no model endpoint is wired.
- **Mod loaders and modpacks**: Fabric, Quilt, NeoForge, Forge; CurseForge manifest, Modrinth
`.mrpack` and MultiMC/Prism packs import, plus mrpack / CurseForge export.
- **Java management**: a Temurin → Oracle → Microsoft → Zulu fallback chain, region-aware.
- **Accounts**: Microsoft sign-in over OAuth 2.0 device code flow → Xbox Live → XSTS →
`api.minecraftservices.com`; third-party community auth servers; local offline profiles for
isolated testing. Account tokens are kept on this machine only.
- **Engine**: multi-source download (health scoring, mirrors, chunked transfers, hash while
downloading) and a YAML rule-based crash diagnosis engine.
- **License**: Apache-2.0 © AskaEth (YeiJ).
## Status
`v0.1.0` (2026-11-01) predates the UI rewrite: its assets are Slint builds, and it stays the last
Slint tag. `v0.2.0` is the first tag after the rewrite — the iced line, which is what the release
pipeline builds now. Everything described below lives on that line.
- **The iced UI is feature-complete**: fourteen pages — instances, version management (three-column
rail layout with search), content library (modpacks / mods / resource packs / shaders with source
and version filters), logs, settings (general / appearance / Java / downloads), accounts
(Microsoft device code, third-party community servers, local profiles), the AI rail and the
installer flows — on top of one component library and one token set.
- **Localisation follows the system language**: detection, an explicit override in settings, and
persistence across restarts. UI copy goes through a single message catalogue; there is no
hard-coded string in the view layer.
- **Appearance**: window background image, per-panel transparency and whole-window transparency
(Windows), light / dark / follow-system, and system window corners where the platform grants them.
On CPU rendering the translucent layers are switched off and the UI says so.
- **Packaging and the installer paths** (curseforge / modrinth / multimc import, mrpack and
CurseForge export, install options with loader selection and real cancellation) are in the build.
- **What is not done yet**: installers / auto-update channel; macOS re-verification on a real
machine after the Windows pass; per-file conflict policy on pack import (the import pipeline has one
global policy today, and the UI states that rather than pretending otherwise); a native file
chooser (import accepts drag-and-drop and a pasted path).
Binaries you may run into, so you can tell them apart:
| Binary | Crate | State |
|--------|-------|-------|
| `unlucky-launcher-iced` | `crates/app-iced` (iced 0.14) | the GUI binary the workspace builds; the release pipeline stages it under the public name `unlucky-launcher`; the same binary answers every CLI subcommand |
| `unlucky-launcher` | `crates/app` (Slint) | **historical**: that crate is excluded from the workspace, so this binary is no longer built or shipped; the v0.1.0 assets are the last Slint builds |
| `ull` | `crates/cli` | the zero-UI build of the same command set, shipped as its own asset |
`unlucky-launcher` (the GUI) and `ull` 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, a CI container or Termux where no window can
exist. Pick `ull` when there is no desktop, `unlucky-launcher <command>` when there is. Both print the
same CLI text and name themselves after the binary you invoked: `unlucky-launcher --help` starts with
`unlucky-launcher ·`, `ull --help` starts with `ull ·`.
### Subcommand dispatch
`unlucky-launcher <args>` first asks "is this a CLI call?" before any UI code runs:
```bash
unlucky-launcher # GUI
unlucky-launcher --software-renderer # GUI, CPU renderer (unchanged)
unlucky-launcher instances list # CLI, no window
unlucky-launcher --json launch 1.20.1 # CLI, global options before the command
unlucky-launcher --version # CLI, prints version + OS summary
```
The 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 itself lives in exactly one place —
`crates/cli/src/cli/parse.rs` — so both the GUI binary and `ull` accept the same set by construction.
## 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 # HISTORICAL Slint UI (bin `unlucky-launcher`): excluded from the workspace,
│ # source-only reference — never built, tested or shipped any more
├── 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 the 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 |
## Building
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/
```
`--workspace` covers the members in the module map. `crates/app` is excluded at the workspace level
(M118), so none of these commands compiles the Slint tree — and there is no build command for it:
that crate is kept as source-only reference and can no longer be built as a workspace member.
What the build produces:
- `unlucky-launcher-iced` — the GUI (iced). It is also the CLI: the same binary answers every
subcommand listed above. The name is temporary — it becomes `unlucky-launcher` at the flip. The
release artifacts already carry the public name: the pipeline builds this bin and
`packaging/make_artifacts.py` stages it as `unlucky-launcher[.exe]`, so download links never change.
- `unlucky-launcher` — **historical**: no workspace build produces this binary any more; it belonged
to `crates/app`, which is excluded. The v0.1.0 assets are the last builds under this name.
- `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, which 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
- **Historical** (v0.1.0's Slint build still takes it): `unlucky-launcher --software-renderer`, i.e.
`SLINT_BACKEND=winit-software`, forced the CPU renderer of that build through its own fallback chain
Dependency license check (optional locally, required in CI):
`cargo deny check licenses bans sources`.
## 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: a red result blocks
the merge. The macOS runtime probe is not there yet — it only runs on a manual `workflow_dispatch`
and its result is recorded, not enforced (`continue-on-error`), until the slot passes a first run on
a real runner.
| Workflow | Content |
|----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb: renders one real frame, then asserts the PNG is not 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 license and source guard |
| `.github/workflows/release.yml` | tag `v*` → per-slot builds → single-file artifacts + `SHA256SUMS.txt` → GitHub Release |
## Platform baseline
Minimum baseline: **Windows 10**; Windows 8.1 / 8 / 7 are not supported.
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU), built the same way as
upstream cargo
- macOS 12+ (Apple Silicon and Intel) and Linux x64 / arm64 are supported build slots; on Linux,
X11 is the preferred path today and Wayland is the long-term direction. macOS builds are unsigned,
so the first launch needs the Gatekeeper quarantine attribute cleared
(`xattr -d com.apple.quarantine`)
- Android Termux is experimental and not promised: CLI only, GUI unverified
- Each platform runs its own test matrix; capability differences use feature gates with runtime
probing.
## License and acknowledgements
- **This project**: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in
[NOTICE](NOTICE).
- **Slint** (<https://slint.dev>), Royalty-Free 2.0: obligation for the Slint build that `v0.1.0`
ships — the condition attaches to each distribution of an Application, not to a version number.
Upstream §2 offers two forms: (a) the official `AboutSlint` widget on an "About" screen or dialog
reachable from the top level menu (or the splash screen when there is none); (b) the official Slint
attribution badge on a public page where the binaries can be downloaded. **`v0.1.0`'s settings page
does embed the `AboutSlint` widget** (`crates/app/ui/pages/settings.slint`, reachable from the
sidebar); whether that satisfies §2(a), and the fact that the `v0.1.0` release page carries a text
link rather than the §2(b) badge, are **not judged here** — Slint's own licence text is the
authority. The `v0.1.0` NOTICE, that page's text and this section are **records, not a statement of
discharge**. Later builds link no Slint, so they add nothing to this obligation. The obligation
follows a **Slint-linked binary**: it applies to the `v0.1.0` assets (and to any later build that
links Slint again), not to new iced builds — `crates/app` is excluded from the workspace, so the
pipeline no longer produces one.
- **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.
+203
View File
@@ -0,0 +1,203 @@
# Unlucky Launcher (ULL)
[English](README.md) · **简体中文**
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher | 下载、议题、发布说明都在这里:
> <https://github.com/AskaEth/Unlucky-Launcher/releases>
Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java 版启动器(Apache-2.0)。
每个平台就一个原生二进制——没有安装包、没有捆绑运行时、没有 WebView。启动器内置 AI Agent
客户端(对话 / 改配置 / 崩溃诊断),也能作为 MCP server 接进别的 Agent 工具。
## 特性
- **单文件原生二进制**:启动器本体不需要 JVM、不需要 WebView、不带旁挂运行时。游戏要的 JDK 由
启动器按实例下载管理,不是启动器的依赖。
- **默认构建里只有一套 UI**:UI 是 iced 0.14 + Material 3 Expressive。早先的 Slint 实现
(`crates/app`)作为**历史参考**保留源码:已被 workspace `exclude`,默认构建不编、测试不跑、
发布不发。`v0.1.0` 那个 tag 发的仍是 Slint 版;iced 重写后的第一个 tag 是 `v0.2.0`(见「当前状态」)。
- **CPU 软渲染兜底**:`--software-renderer` 让没有可用 GPU 或驱动有问题的老机也能正常显示界面。
- **AI Agent + MCP**:启动器内可对话、改设置、读崩溃诊断;`ull agent serve` 把同一个内核以 MCP
server 形态(stdio 或 Streamable HTTP)开放给外部 Agent 工具。
- **加载器**:Fabric / Quilt / NeoForge / Forge;整合包与 CurseForge / Modrinth 客户端在 core 里。
- **Java 管理**:Temurin → Oracle → Microsoft → Zulu 回退链,带地区判定。
- **账户**:用 Microsoft 账户登录,认证走 OAuth 2.0 device code flow → Xbox Live → XSTS →
`api.minecraftservices.com`;账户令牌只保存在本机。
- **引擎**:多源下载(健康度评分、镜像、分块传输、边下边算)与 YAML 规则引擎的崩溃诊断。
- **许可**:Apache-2.0 © AskaEth (YeiJ)。
## 当前状态
`v0.1.0`(2026-11-01)早于 UI 重写:那批资产是 Slint 版,它也是最后一个 Slint 版 tag。重写之后的
第一个 tag 是 `v0.2.0`——也就是 iced 这条线,下面说的这套东西都在线上,也正是发布流水线现在会打
出来的东西。
- **iced 版界面功能已齐**:十四页——实例、版本管理(三栏 + 搜索)、内容库(整合包 / Mod / 资源包 /
光影,带来源与版本筛选)、日志、设置(通用 / 外观 / Java / 下载)、账户(微软设备码、第三方社区服、
本地档案)、AI 右栏,以及安装流程——共用一套组件库与一套令牌。
- **文案跟随系统语言**:自动探测、设置里可显式切换、重启后保持。界面文案统一走一份消息目录,视图层
没有硬编码字符串。
- **外观**:窗口背景图、面板透明度、整窗透明度(Windows)、浅色 / 深色 / 跟随系统,以及在系统愿意给
的时候使用系统窗口圆角。CPU 渲染档会关掉半透明层,并在界面上说清楚。
- **整合包与安装链路都在构建里**:CurseForge 清单 / Modrinth `.mrpack` / MultiMC·Prism 导入,mrpack
与 CurseForge 导出,安装选项(选加载器)与**真取消**。
- **还没做**:安装器与自动更新通道;Windows 定型后的 macOS 真机复核;整合包导入的**逐文件**冲突策略
(当前只有一份全局策略,界面如实写明而不是假装支持);原生文件选择框(导入支持拖拽与粘贴路径)。
- **Slint 版已退出构建,只留源码作参考**:`crates/app` 不再是 workspace 成员(M118)——源码留作历史
参考,不编译、不测试、不发布,也不再有可用的构建方式。翻牌只剩**改名**这一步:它的 bin 现在叫
`unlucky-launcher-iced`,发布流水线已经把它按对外名 `unlucky-launcher` 打出去。
可能会遇到的 bin,先认清楚:
| 二进制 | crate | 状态 |
|--------|-------|------|
| `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | workspace 里**唯一**的 GUI bin;发布流水线把它按对外名 `unlucky-launcher` 落盘;子命令分发同款 |
| `unlucky-launcher` | `crates/app`(Slint) | **历史**:该 crate 已被 workspace `exclude`,这个 bin 不再被构建或分发;`v0.1.0` 的资产是最后一批 Slint 版 |
| `ull` | `crates/cli` | 同一套命令的零 UI 构建,作为独立资产随发布分发 |
`unlucky-launcher` 与 `ull` 不是重复品:开窗时 GUI 二进制要用图形栈(X11 / Wayland / GL 走
`dlopen`,启动时直接链接 `libfontconfig`);`ull` 除了 glibc 什么都不需要,所以在没有窗口的服务器、
CI 容器、Termux 上跑得起来。没有桌面就用 `ull`,有桌面就用 `unlucky-launcher <子命令>`。两者打出来的
CLI 文案是同一套,自称的名字跟着**你敲的那个二进制**走:`unlucky-launcher --help` 首行是
`unlucky-launcher ·`,`ull --help` 首行是 `ull ·`。
### 子命令分发
`unlucky-launcher <参数>` 在跑任何 UI 代码之前先问一句「这是不是一次 CLI 调用」:
```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`),所以两个 GUI 二进制和 `ull` 认的命令集天然一致。
## 模块地图
```
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 # 历史 Slint UI(bin `unlucky-launcher`):已被 workspace `exclude`,
│ # 仅作源码参考——不再被构建 / 测试 / 发布
├── crates/app-iced # iced UI(bin `unlucky-launcher-iced`):workspace 里唯一的 GUI bin
├── crates/cli # CLI(bin `ull`):headless,零 UI
└── packaging # 单文件产物矩阵与发布脚本
```
`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 检测 |
## 构建
前置:
- 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 编译期要用(Slint 版当年还要 femtovg)
```bash
cargo fmt --all --check # 格式
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
cargo test --workspace # 测试
cargo build --release # bin 落在 target/release/
```
`--workspace` 就是模块地图里那八个成员:`crates/app` 已在 workspace 层被排除(M118),上面这些命令
都不会编 Slint 那棵树——而且**它没有构建方式**:该 crate 只作源码参考,作为 workspace 成员已不可编。
编出来的 bin:
- `unlucky-launcher-iced`——GUI(iced)。它同时也是 CLI:同一个二进制认上面列的全部子命令。名字是
临时的,翻牌时才改成 `unlucky-launcher`;发布产物**已经**用对外名:流水线编这个 bin,
`packaging/make_artifacts.py` 落盘时改成 `unlucky-launcher[.exe]`,所以下载链接不用变。
- `unlucky-launcher`——**历史**:workspace 构建已不产出它(它属于被排除的 `crates/app`)。
`v0.1.0` 的资产是最后一批用这个名字发出去的构建。
- `ull`——CLI 的独立无 UI 构建:同一套命令、不链接图形栈、体积更小,给完全没有显示设备的机器用。
渲染开关:
- `unlucky-launcher-iced --software-renderer` 强制 tiny-skia(`ICED_BACKEND=tiny-skia`);
`--screenshot <png>` 出一帧图后退出,这是 CI 无头冒烟 job 用的验收通道
- 没有可用适配器时,iced 版靠显式后端探测自己退到 CPU
- **历史**(`v0.1.0` 的 Slint 版仍认这条):`unlucky-launcher --software-renderer`,即
`SLINT_BACKEND=winit-software`,让那一版走它自己的渲染器回退链
依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。
## CI 门禁
每次推送到 `main` 与每个 PR 都跑 fmt / clippy(warnings 视为错误)/ 全量测试,Linux 无头渲染冒烟
与它们同级、是硬门禁:红了就不许合。macOS 运行期探针还不算——它只在手动 `workflow_dispatch` 时跑,
结果留证据但不作为流水线结论(`continue-on-error`),直到该槽位在真实 runner 上首跑通过。
| 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` 零 UI 守门(依赖表 + `cargo tree`)、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 分槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
## 平台基线
最低基线 **Windows 10**;Windows 8.1 / 8 / 7 不在支持范围内。
- Windows 10/11 的产物由**标准工具链**(MSVC / GNU)构建,构建方式与上游 cargo 一致
- macOS 12+(Apple Silicon 与 Intel)与 Linux x64 / arm64 都是支持槽位;Linux 上 X11 是当前首选路径,
Wayland 是大方向。macOS 产物未签名,首次启动要自己清掉 Gatekeeper 隔离标记
(`xattr -d com.apple.quarantine`)
- Android Termux 试验级、不承诺:只保证 CLI,GUI 未验证
- 每个平台跑各自的测试矩阵;能力差异用 feature gate + 运行期探测表达。
## 许可证与致谢
- **本项目**:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)。
- **Slint**(<https://slint.dev>),Royalty-Free 2.0:义务针对 `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 的产物**走:已发布的 `v0.1.0` 资产(以及将来任何重新链接 Slint 的构建)
适用;`crates/app` 已被 workspace `exclude`,流水线不再产出链接 Slint 的二进制,所以新的 iced 版不适用。
- **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 玩家走了很多年,
本项目在设计取舍与实现细节上都从中受益良多。
+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" }
# 序列化:工具 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, 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,
}
}
}
+340
View File
@@ -0,0 +1,340 @@
//! 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};
/// 本地 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 => "",
}
}
/// 设置页展示名。
#[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::new(
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
));
}
if self.model.trim().is_empty() {
return Err(ProviderError::new("没有配置模型名(model)"));
}
if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::new(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::new(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 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderError {
message: String,
}
impl ProviderError {
/// 从可读文本构造。
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
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)]
mod tests {
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 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}"
);
}
}
+368
View File
@@ -0,0 +1,368 @@
//! 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::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, 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 错误 → 本方错误(文案面向用户)。
fn map_error(error: RigError) -> ProviderError {
ProviderError::new(error.to_string())
}
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
RigToolName::new(name)
.map_err(|_| ProviderError::new("工具名不能为空(ToolSpec 注册阶段应已拦截)"))
}
#[cfg(test)]
mod tests {
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, "对话历史不能带凭据明文");
}
+92
View File
@@ -0,0 +1,92 @@
[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" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", version = "0.2" }
# 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" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
unlucky-i18n = { path = "../i18n", version = "0.2" }
# 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"]
[lints]
workspace = true
+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
}
}
}
+130
View File
@@ -0,0 +1,130 @@
//! 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 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");
}
eprintln!("{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(它知道怎么保留强调色这类本 mission 不管的字段)。
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`(只改本 mission 管的字段,强调色/玻璃原样保留)。
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;
self.bridge.persist(&prefs, self.theme_mode);
self.appearance_prefs = prefs;
match self.bridge.path() {
Some(path) => eprintln!("[settings] 外观已写回 {}", path.display()),
None => eprintln!("[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"
);
}
}
+248
View File
@@ -0,0 +1,248 @@
//! 启动编排:**探测 → 字体 → 打印诊断 → 起 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 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;
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` 里统一打)
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();
eprintln!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
eprintln!("{}", resolved.describe());
if resolved.has_synthetic() {
eprintln!(
"[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() {
eprintln!("[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) => {
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
eprintln!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options
.screenshot
.as_ref()
.map_or_else(String::new, |path| path.display().to_string())
);
eprintln!(
"[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 的标题栏是留白带,出不了「三键位置」那张图)。
let (size, size_source) = acceptance::window_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height),
);
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());
eprintln!(
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
renderer::FALLBACK_ENV,
kind.env_value(),
kind.env_value()
);
eprintln!("[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) => eprintln!(
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
elapsed.as_secs_f64() * 1000.0
),
None => eprintln!(
"[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;
+110
View File
@@ -0,0 +1,110 @@
//! 启动阶段的**验收诊断编排**:把 `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 上会出现原生标题栏 + 自绘三键两套。
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);
}
+117
View File
@@ -0,0 +1,117 @@
//! 起步后端失败后的**兜底/重开**一簇:判据、进程环境交接、`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::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| {
eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})");
std::process::exit(exit_code(None));
});
eprintln!(
"[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) => {
eprintln!("[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 {
let mut command = std::process::Command::new(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
}
+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}"
);
}
+101
View File
@@ -0,0 +1,101 @@
//! 应用消息:单一大枚举。
//!
//! 纪律:本枚举是**唯一**的消息类型,`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),
}
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;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,
}
}
+71
View File
@@ -0,0 +1,71 @@
//! 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 appearance;
mod backend;
mod boot;
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;
+188
View File
@@ -0,0 +1,188 @@
//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`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)。
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;
mod construct;
mod refresh;
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,
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
pub(super) height: f32,
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
pub(super) appearance: pages::appearance::State,
/// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段,
/// 强调色这类本 mission 不管的字段原样保留)。
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,
/// 内容库页的页面内选择(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,
/// 皮肤/头像解码缓存(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>,
}
@@ -0,0 +1,40 @@
//! 启动期取证:后端选项的换算 + 首帧耗时那一行日志。
//!
//! 从 `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 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(),
}
}
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
pub(crate) fn log_first_frame() {
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
if let Some(started) = STARTED.get() {
eprintln!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0
);
}
}
+148
View File
@@ -0,0 +1,148 @@
//! `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::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(),
};
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;
let theme_mode = 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());
let mut view = pages::Model::new(
pages::Screen::Instances,
width,
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,
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(),
install_cancelled_seen: 0,
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,
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log();
app.lang.log_cjk();
app
}
}
+61
View File
@@ -0,0 +1,61 @@
//! 快照 → 视图模型的重建口径(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
//!
//! 全仓只有 [`App::refresh_view`] 这一个「presenter 快照 → 页面要画的东西」的转换点,
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
use unlucky_presenter::toast::ToastKind;
use crate::pages;
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);
}
// 分类是**页面自己持有的过滤条件**(真 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.options.force_state,
category,
&draft,
self.lang(),
&self.content,
);
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width);
self.view_dirty = false;
self.refreshes += 1;
}
}
+86
View File
@@ -0,0 +1,86 @@
//! 主题与 `Ui` 的重算口径(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
//!
//! 三件事在这里:①`Ui` 什么时候重建([`App::rebuild_ui`])②主题档怎么定
//! ([`App::set_flavor`] / [`App::apply_config_theme`])③读当前求值器([`App::ui`])。
//!
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
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();
eprintln!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
previous,
self.ui.name()
);
}
/// 按「配色来源是否取色成功」重建 [`Ui`]。
///
/// 色卡档(③)刻意继续走 `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();
self.ui = match self.appearance.derived_scheme() {
Some(scheme) => {
eprintln!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level()
);
Ui::from_scheme(*scheme, self.families)
}
None => Ui::new(self.appearance.flavor(), self.families),
}
.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;
match mode {
ThemeMode::Light => self.set_flavor(SchemeFlavor::Light),
ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark),
ThemeMode::Auto => {}
}
}
/// 当前令牌求值器(测试与截图日志用)。
pub(in crate::app) fn ui(&self) -> &Ui {
&self.ui
}
}
+94
View File
@@ -0,0 +1,94 @@
//! 订阅:**按需**帧节拍(presenter 的 TICK)+ 窗口尺寸事件。
//!
//! ## 为什么要「按需」
//!
//! iced_winit 在处理完任何一条消息后都会 `request_redraw()`,而 `window::frames()` 监听的是
//! `RedrawRequested` —— 于是「订阅帧 → 收 Frame → update → 再触发重绘」是一条**自持循环**:
//! 只要订阅着,窗口就永远在重绘(M90 r1 就是这样,CPU 空转)。
//!
//! 现在只在**真的需要 33 ms 节拍**的时候订阅([`App::needs_frames`]):
//!
//! | 条件 | 为什么需要 33 ms 节拍 |
//! |---|---|
//! | 截图通道 | 第 3 帧要落盘 |
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
//! | 有未过期的 toast | toast 按毫秒倒计时 |
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
//!
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`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::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()
|| !self.state.toasts.is_empty()
|| !self.state.running().is_empty()
|| self
.watch_until
.is_some_and(|deadline| Instant::now() < deadline)
}
}
+382
View File
@@ -0,0 +1,382 @@
//! `app` 的单测:主题切换、窗口尺寸、截图闸门、gallery 能构图,以及**presenter 接线的闭环**。
//!
//! 闭环测试用的是 `MockBackend`(presenter 自带的假后端):它走的是与真 host 完全相同的
//! `dispatch → drain_events → AppState::apply → 视图模型` 通道,所以「点 Play → 状态真的变」
//! 这件事在这里就能判死,不必等图形环境。
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
#[cfg(test)]
mod appearance;
#[cfg(test)]
mod presenter;
use super::message::Message;
use super::state::App;
use super::{APP_TITLE, WINDOW_SIZE};
use crate::cli::{Options, Screen};
use crate::pages;
use crate::shell::agent::RailState;
use crate::shell::NavItem;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘的 App(mock 后端 + 内存设置桥)。
fn headless(options: Options, flavor: SchemeFlavor) -> App {
App::with_presenter(
options,
FAMILIES,
flavor,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
fn screenshot_options() -> Options {
Options {
screenshot: Some(std::path::PathBuf::from("x.png")),
..Options::default()
}
}
#[test]
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.state.navigate(Route::Logs);
app.refresh_view();
assert_eq!(app.ui().name(), "ull-m3-light");
app.set_flavor(SchemeFlavor::Dark);
assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名");
assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态");
assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由");
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
}
#[test]
fn window_size_is_the_acceptance_size() {
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
assert_eq!(APP_TITLE, "Unlucky Launcher");
}
#[test]
fn screenshot_mode_never_waits_for_the_system_theme() {
// 门禁依赖:CI 的 runtime-smoke 不带主题,xvfb 里也没有主题会话。
let options = screenshot_options();
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
let app = headless(options, SchemeFlavor::Light);
assert!(app.has_screenshot() && app.ui().name() == "ull-m3-light");
}
#[test]
fn gallery_screen_builds_a_tree() {
assert_eq!(Options::default().screen, Screen::Shell, "默认仍是产品骨架");
let options = Options {
screen: Screen::Gallery,
..Options::default()
};
let app = headless(options, SchemeFlavor::Light);
// 真实像素由 `--screen=gallery --screenshot` 出图 + `gallery::probe` 量
let _ = app.view();
}
#[test]
fn frames_are_only_requested_in_screenshot_mode() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
assert!(
app.wants_frames(),
"截图模式必须订阅帧事件才能在第 3 帧出图"
);
}
// --------------------------------------------------------------- P2-1 按需节拍
/// 空闲态**不要每帧节拍**(但要有 1 Hz 心跳)。
///
/// 意图与 r2 那条 `an_idle_app_asks_for_no_clock` 一致:空闲时不许按帧循环跑(那会以每秒几百帧
/// 的速度重绘整窗),改成「低频心跳」。断言拆成两半:
///
/// 1. `needs_frames()` 为假 ⇒ **不订阅** `window::frames()`;
/// 2. 空闲时钟的周期 = [`IDLE_TICK`] = 1 s,且心跳每拍推进的 tick 预算 = 30(= 1 s / 33 ms),
/// 这样 `theme::config_poll_due`(30 拍)仍是「约 1 秒比对一次 mtime」。
///
/// 抓得住:有人把「无条件订阅帧」改回来(第 1 半红);有人把心跳周期改快/改慢(第 2 半红)。
/// 抓不住:真实窗口的重绘次数(那要 `ULL_TICK_TRACE=1` 跑一次交互模式看日志,见报告)。
#[test]
fn an_idle_app_uses_a_slow_heartbeat_instead_of_frames() {
let app = headless(screenshot_options(), SchemeFlavor::Light);
assert!(app.needs_frames(), "截图通道永远要帧(第 3 帧落盘)");
let mut idle = headless(Options::default(), SchemeFlavor::Light);
let _ = idle.update(Message::Boot);
assert!(
idle.needs_frames(),
"Boot 刚派发过 RefreshAll:3 秒观察窗内要节拍(等真 host 的首批事件)"
);
// 观察窗过期、也没有忙碌实例 ⇒ 空闲态不订阅帧事件(改走 1 Hz 心跳)
idle.watch_until = None;
assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件(只有心跳)");
// 派发之后观察窗重新打开
let _ = idle.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
let _ = idle.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
assert!(idle.needs_frames(), "派发后观察窗内要有节拍");
// 进入 Busy 之后由「忙碌实例」这个条件自己续订(不靠观察窗)
idle.watch_until = None;
assert!(
idle.needs_frames(),
"有 Busy 实例时即便观察窗过期也要继续 tick"
);
}
/// 心跳的**节奏**:1 Hz,且每拍折算 30 个 33 ms 单位 ⇒ `config_poll_due` ≈ 每秒一次。
///
/// 抓得住:心跳周期常量、以及 `advance()` 里「按流逝时间折算 ticks」这条记账(改回
/// `ticks += 1` 会让空闲态的配置轮询变成 30 秒一次,这条就红)。
/// 抓不住:`Task::perform` 那条自续链真的每秒触发一次(那是运行期 trace 的事,见报告)。
#[test]
fn the_idle_heartbeat_budgets_one_config_poll_per_second() {
use unlucky_presenter::theme::config_poll_due;
assert_eq!(
super::state::IDLE_TICK,
std::time::Duration::from_secs(1),
"空闲心跳周期必须是 1 s"
);
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
app.watch_until = None;
// 一次空闲心跳(1 s)之后:ticks 增加 30,且正好落在 config 轮询点上
let before = app.ticks;
app.advance(super::state::IDLE_TICK);
assert_eq!(app.ticks - before, 30, "1 s 应折算 30 个 33 ms 拍");
assert!(
config_poll_due(app.ticks),
"空闲心跳每拍都要落在 config 轮询点上(ticks={})",
app.ticks
);
}
/// 空闲态每拍**不再重建视图模型**(P2-1 的另一半:`refresh_view` 的 30 Hz × N 实例白克隆)。
///
/// 抓得住:把 `advance()` 里的 `if self.view_dirty` 去掉(改回无条件 `refresh_view()`)时,
/// 这条断言里的 `refreshes` 会从「不增长」变成「每拍 +1」。
/// 抓不住:「重建一次花了多少微秒」(没有基准测试;量级判断见报告)。
#[test]
fn idle_ticks_do_not_rebuild_the_view_model() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let before = app.refreshes;
for _ in 0..100 {
app.advance(super::state::TICK);
}
assert_eq!(
app.refreshes,
before,
"空闲 100 拍:视图模型一次都不该重建(实际 +{})",
app.refreshes - before
);
// 有事件进来时仍然要重建(否则页面不刷新)
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs)));
assert!(app.refreshes > before, "导航之后必须重建一次");
}
/// 视图模型跟着窗口宽度走(交互模式下 600 / 840 两条断点靠 resize 事件生效)。
///
/// M104 起 `view.window_width` 装的是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝),所以这里按三档
/// 各钉一个**字面量**:48 的折叠条与 320 的面板各自的减法都得对。
/// (断言里不调 `agent.content_width(..)`——那是同义反复,实现错一处它照样绿。)
#[test]
fn resizing_the_window_rebuilds_the_view_model() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
// 出厂是 Collapsed(48) 档:中栗 = 窗口 − 48
let _ = app.update(Message::Window(iced::window::Event::Resized(
iced::Size::new(1180.0, 760.0),
)));
assert!(
(app.view.window_width - 1132.0).abs() < f32::EPSILON,
"1180 − 48(折叠条)= 1132,实际 {}",
app.view.window_width
);
let _ = app.update(Message::Window(iced::window::Event::Resized(
iced::Size::new(700.0, 600.0),
)));
assert!(
(app.view.window_width - 652.0).abs() < f32::EPSILON,
"700 − 48(折叠条)= 652,实际 {}",
app.view.window_width
);
// Expanded(320) 档:中栗 = 窗口 − 320(面板)− 8(拖拽缝)= 852
app.agent.expand();
app.view_dirty = true;
app.refresh_view();
assert!(
(app.view.window_width - 652.0).abs() < f32::EPSILON,
"700 上展开档不成立(上限 700 − 88 − 472 − 8 = 132 < 280)⇒ 实际按折叠条 48 算: 700 − 48 = 652,实际 {}",
app.view.window_width
);
let _ = app.update(Message::Window(iced::window::Event::Resized(
iced::Size::new(1180.0, 760.0),
)));
assert!(
(app.view.window_width - 852.0).abs() < f32::EPSILON,
"1180 − 320 − 8 = 852,实际 {}",
app.view.window_width
);
// Detached 档:右栗不占宽,中栗 = 窗口 = 1180。
// **必须走路由**(`Select(Ai)`):r1 之后「档位 = Detached」与「路由 = Agent」是一条不变式,
// 直接 `agent.detach()` 再手工 refresh 会被 `sync_agent_route` 立刻收回右栗(那正是要的语义)。
let _ = app.update(Message::Select(NavItem::Ai));
app.view_dirty = true;
app.refresh_view();
assert!(
(app.view.window_width - 1180.0).abs() < f32::EPSILON,
"拆出后右栗不占宽:1180 − 0 = 1180,实际 {}",
app.view.window_width
);
// 页面照旧能构图(窄窗退化到单列,不 panic)
let _ = app.view();
}
/// **不变式(复审 r1 的 P2-1)**:`Route::Agent` ⟺ `RailState::Detached`。
///
/// 走的是**真实路由入口**(外壳导航第五项 `Assistant` → `Message::Select(NavItem::Ai)`),不是
/// 直接调 `agent.detach()`:r1 复现的 bug 正是「导航进页面时档位没变」,只测 `detach()` 抓不到它。
///
/// 抓得住:①进独立页后档位 = Detached(页头才会是 `Dock back` 而不是 `Open assistant`/`Pop out`);
/// ②未读在进页时清零;③`Esc` 在独立页真的离开(不是只把档位改成折叠);④从页上点别的导航,
/// 会话收回右栗(档位不再停在 Detached)。
/// 抓不住:按钮在像素上长什么样(那要出图,见 `13/14` 两张)。
#[test]
fn entering_the_agent_route_detaches_the_rail() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
assert_eq!(app.agent.rail, RailState::Collapsed, "出厂是折叠档");
// M119:出厂没有样本会话,未读由 presenter 按真事件累加 ⇒ 出厂就是 0
assert_eq!(app.agent_host.rail().unread, 0, "出厂 0 条未读");
// ① 外壳导航第五项(真实入口)
let _ = app.update(Message::Select(NavItem::Ai));
assert_eq!(app.state.route(), &Route::Agent);
assert_eq!(
app.agent.rail,
RailState::Detached,
"进了独立页,档位必须是 Detached——否则页头会画右栗才有的动作"
);
assert_eq!(app.agent_host.rail().unread, 0, "进独立页即读");
// ③ Esc 真的离开这一页
let _ = app.update(Message::Agent(crate::shell::agent::Message::Escape));
assert_ne!(app.state.route(), &Route::Agent, "独立页上 Esc 必须离开");
assert_eq!(app.agent.rail, RailState::Expanded, "收回到右栗展开档");
// ④ 从独立页点别的导航:会话收回右栗(档位不能停在 Detached)
let _ = app.update(Message::Select(NavItem::Ai));
assert_eq!(app.agent.rail, RailState::Detached);
let _ = app.update(Message::Select(NavItem::Instances));
assert_eq!(app.state.route(), &Route::Instances);
assert_eq!(
app.agent.rail,
RailState::Expanded,
"离开独立页 ⇒ 会话收回右栗(否则页走了、栏还是 0 宽)"
);
}
/// 同一条不变式的另一个入口:页面层的 `Navigate(Route::Agent)`(右栗里的 `pages::Message` 路径)。
#[test]
fn the_page_navigation_entry_also_detaches() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
// 走真消息(不是直接改字段):这样「展开即读」的 `agent_mark_read` 也会真的发生
let _ = app.update(Message::Agent(crate::shell::agent::Message::Expand));
assert_eq!(app.agent.rail, RailState::Expanded);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Agent)));
assert_eq!(app.state.route(), &Route::Agent);
assert_eq!(app.agent.rail, RailState::Detached);
assert_eq!(app.agent_host.rail().unread, 0);
// 页面层回退(不是 Dock 消息)也要把档位收回来
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert_eq!(app.agent.rail, RailState::Expanded);
}
/// 截图模式不读设置文件里的主题(D30 可复现:两台机器出同一张图)。
#[test]
fn screenshot_mode_ignores_the_settings_file_theme() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
app.apply_config_theme();
assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行");
}
/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。
///
/// r2 之前 `Message::SystemTheme` 无条件 `set_flavor`,于是「配置文件写 dark、系统是浅色」会
/// 出现 `light -> dark -> light` 的一次抖动、配置永远不生效(r3 做 P2-2 的配置热切实测时暴露
/// 出来,一并修)。
///
/// 抓得住:系统主题不许盖掉显式配置/命令行给出的主题(三条分支各一条断言)。
/// 抓不住:真实桌面切换系统主题时的事件时序(只有消息级验证)。
#[test]
fn the_system_theme_never_overrides_an_explicit_one() {
use unlucky_presenter::prefs::ThemeMode;
// ① auto(内存桥默认):系统主题说了算
let mut auto = headless(Options::default(), SchemeFlavor::Light);
assert_eq!(auto.theme_mode, ThemeMode::Auto);
let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark));
assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统");
// ② 配置文件显式 dark:系统给浅色也不许改
let mut pinned = headless(Options::default(), SchemeFlavor::Dark);
pinned.theme_mode = ThemeMode::Dark;
let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
pinned.ui().name(),
"ull-m3-dark",
"配置文件说 dark,系统说 light:不许改"
);
// ③ 命令行 `--theme` 优先级最高
let mut explicit = headless(
Options {
theme: Some(SchemeFlavor::Dark),
..Options::default()
},
SchemeFlavor::Dark,
);
let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
explicit.ui().name(),
"ull-m3-dark",
"--theme 优先级最高,系统主题不许盖"
);
}
+331
View File
@@ -0,0 +1,331 @@
//! 外观接线的单测:**配置 → 状态 → 下发字节 / 像素 / 落盘**。
//!
//! 每条都按本 mission 的验收纪律写「抓得住 / 抓不住」。真机才有的东西(`WS_EX_LAYERED` 是否补上、
//! `LWA_ALPHA` 的观感)不在这里假装——那是 `[appearance]` 日志与报告里「没验的部分」的事。
use std::path::PathBuf;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::prefs::{BgFill, PaletteSource};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::cli::{Backend, Options};
use crate::pages::appearance::{self, Change, State};
use crate::pages::{self, Screen};
use super::{headless, FAMILIES};
use super::super::state::App;
/// 一个把配置写在临时文件里的 App(测「读回 / 写回」必须真的过文件)。
fn with_config(json: &str, backend: Backend, label: &str) -> (App, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"ull-appear-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
std::fs::write(&path, json).expect("写配置文件");
let app = App::with_presenter(
Options {
backend,
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, dir)
}
fn cleanup(dir: &PathBuf) {
std::fs::remove_dir_all(dir).ok();
}
/// 抓得住:配置里写 `window_opacity=0.8` → 启动读回 0.8 → 下发字节 205(钳位链的入口那一段)。
/// 抓不住:Win32 是否真的收下 204(`transparency` 的单测与本机日志各自举证)。
#[test]
fn the_configured_window_alpha_is_read_back_and_turned_into_a_byte() {
let (app, dir) = with_config(
r#"{"appearance":{"window_opacity":0.8,"palette_source":"preset"}}"#,
Backend::Cpu,
"read",
);
assert_eq!(app.appearance.window_opacity, 0.8);
assert_eq!(
app.appearance.window_alpha_byte(),
Some(205),
"f32 0.8 → f64 略大于 0.8 → ceil 205(不向下取整)"
);
// CPU 档:窗口 α 允许,面板 α 强制 1.0。
assert_eq!(app.appearance.effective_panel_alpha(), 1.0);
assert!(app.appearance.panel_is_forced());
cleanup(&dir);
}
/// 抓得住:①面板 α 滑到 0.8 → 状态是 0.8、生效值 GPU 档也是 0.8;
/// ②CPU 档下滑到 0.5 → 生效值仍是 1.0,而**请求值**仍是 0.5(界面上的「requested → forced」靠这条)。
/// 抓不住:这层半透明在屏幕上到底多透(RDP 上不可信,见报告)。
#[test]
fn panel_alpha_is_independent_of_the_window_alpha_and_forced_on_cpu() {
let mut gpu = headless(Options::default(), SchemeFlavor::Light);
gpu.appearance.apply(Change::PanelAlpha(0.8));
assert_eq!(gpu.appearance.panel_alpha, 0.8);
assert_eq!(gpu.appearance.effective_panel_alpha(), 0.8);
assert_eq!(gpu.appearance.window_opacity, 1.0, "面板 α 不许带动整窗 α");
let mut cpu = headless(
Options {
backend: Backend::Cpu,
..Options::default()
},
SchemeFlavor::Light,
);
assert!(
!cpu.appearance.apply(Change::PanelAlpha(0.5)),
"只有整窗 α 需要立刻下发(面板 α 只重绘)"
);
assert_eq!(cpu.appearance.panel_alpha, 0.5, "请求值保留");
assert_eq!(
cpu.appearance.effective_panel_alpha(),
1.0,
"CPU 档强制 1.0"
);
assert_eq!(cpu.appearance.window_alpha_byte(), None, "整窗 α 仍是 1.0");
}
/// 抓得住:一次滑块改动 → 落盘 → **新开一个 bridge** 能读回(= 重启后还在)。
/// 抓不住:真 host 是否也写了同一份文件(`AppCommand::SetSetting` 走的是同一份 schema)。
#[test]
fn a_panel_alpha_change_is_written_back_to_the_config_file() {
let (mut app, dir) = with_config("{}", Backend::Gpu, "write");
app.on_appearance(Change::PanelAlpha(0.8));
app.on_appearance(Change::BgFill(BgFill::Tile));
app.on_appearance(Change::BgDim(0.5));
let path = dir.join("config/launcher.json");
let reopened = SettingsBridge::with_path(&path).load().0;
assert_eq!(reopened.panel_alpha, 0.8);
assert_eq!(reopened.bg_fill, BgFill::Tile);
assert!((reopened.bg_dim - 0.5).abs() < 1e-6);
assert_eq!(
app.appearance_prefs.panel_alpha, 0.8,
"内存里的「已落盘快照」也要跟上,否则下一次写回会拿旧基底覆盖"
);
cleanup(&dir);
}
/// 抓得住:路径读不出来时 ①不 panic ②`background_active()` 为假(静默回落)
/// ③诊断里有一句人话(不是空串)④配色回落到色卡档(不换 scheme)。
/// 抓不住:这张图在别的机器上路径存在时会怎样(那是成功路径,另一条测试管)。
#[test]
fn a_broken_background_path_falls_back_silently_but_says_why() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.appearance
.apply(Change::BgImage("D:/definitely/not/here.png".to_owned()));
assert!(!app.appearance.background_active());
assert!(app.appearance.image().is_none(), "解码失败 ⇒ 没有可用图");
assert_eq!(app.appearance.diagnostics().len(), 1, "恰好一条诊断");
assert!(
app.appearance.diagnostics()[0].contains("could not be read"),
"诊断要是人话:{:?}",
app.appearance.diagnostics()
);
assert_eq!(app.appearance.palette_level(), "preset", "回落色卡");
assert!(
app.appearance.derived_scheme().is_none(),
"色卡档不换 scheme"
);
}
/// 抓得住:真 PNG → 背景真的生效(解出原图尺寸、路径留给渲染器、配色走「背景图取色」这一级)。
/// 抓不住:渲染出来的像素(由截图举证),以及三种填充的覆盖范围(由 `background::tiles` 的
/// 「覆盖 ≥ 视口」断言守)。
#[test]
fn a_decodable_png_makes_the_background_and_the_palette_active() {
let dir = std::env::temp_dir().join(format!("ull-appear-png-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("临时目录");
let path = dir.join("bg.png");
let image = image::RgbImage::from_fn(64, 32, |x, _y| {
if x < 32 {
image::Rgb([200, 30, 30])
} else {
image::Rgb([20, 60, 200])
}
});
image.save(&path).expect("写 png");
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.appearance
.apply(Change::PaletteSource(PaletteSource::BackgroundImage));
app.appearance
.apply(Change::BgImage(path.to_string_lossy().to_string()));
app.rebuild_ui();
assert!(app.appearance.background_active());
assert!(app.appearance.image().is_some(), "解码成功 ⇒ 有可用图");
assert!(
!app.appearance.bg_path.trim().is_empty(),
"渲染器按路径解码(见 picture::image_handle)"
);
assert_eq!(app.appearance.diagnostics().len(), 0, "成功时没有诊断");
assert_eq!(app.appearance.palette_level(), "background-image");
assert!(
app.appearance.derived_scheme().is_some(),
"取色成功后 Ui 要换成解析出的方案"
);
// 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。
assert_eq!(app.ui().name(), "ull-m3-light");
let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES);
assert_ne!(
app.ui().color(unlucky_design::color::Role::Primary),
baseline.color(unlucky_design::color::Role::Primary)
);
std::fs::remove_dir_all(&dir).ok();
}
/// 抓得住:设置页在有外观状态时能真的构图(真控件树,含文本框/滑块/互斥按钮),并且
/// 「外观」分组的下标与 `appearance::APPEARANCE_GROUP` 一致(点左列能切过去)。
/// 抓不住:这些控件点下去之后的交互(`Message` 的落点由 `on_appearance` 的其它测试管)。
#[test]
fn the_settings_page_renders_the_real_appearance_group() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.state
.navigate(unlucky_presenter::router::Route::Settings);
app.refresh_view();
assert_eq!(app.view.screen, Screen::Settings);
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
drop(_tree);
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
app.appearance.apply(Change::SelectGroup(0));
assert_eq!(app.appearance.selected_group, 0);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
}
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
///
/// 抓得住:`Ui::layer(Canvas)` 在 α=0.55 时仍是不透明(标题栏/导航轨/页头带都取 Canvas,
/// 它们一旦透底,三个窗口键与导航文字就压在背景照片上);`Card` / `Overlay` 必须真的吃到 α。
/// 抓不住:真实合成后的观感(那由截图看)。
#[test]
fn the_canvas_layer_stays_opaque_while_panels_take_the_alpha() {
use unlucky_design::elevation::MaterialLayer;
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.appearance.apply(Change::PanelAlpha(0.55));
app.rebuild_ui();
let ui = app.ui();
assert_eq!(ui.panel_alpha(), 0.55, "生效值进了 Ui");
assert_eq!(
ui.layer(MaterialLayer::Canvas).a,
1.0,
"窗口底(标题栏/导航轨/页头带)永远不透明——令牌的不变量"
);
for layer in [MaterialLayer::Card, MaterialLayer::Overlay] {
assert!(
(ui.layer(layer).a - 0.55).abs() < 1e-6,
"{layer:?} 必须吃面板 α(实际 {})",
ui.layer(layer).a
);
}
// CPU 档:α 被强制 1.0 ⇒ 连卡片也不透明。
let mut cpu = headless(
Options {
backend: Backend::Cpu,
..Options::default()
},
SchemeFlavor::Light,
);
cpu.appearance.apply(Change::PanelAlpha(0.55));
cpu.rebuild_ui();
assert_eq!(cpu.ui().layer(MaterialLayer::Card).a, 1.0, "CPU 档强制 1.0");
}
/// 抓得住:**把滑块从头拖到尾**(0.00 → 1.00 的 11 个档)时,状态里的整窗 α 与下发字节逐档单调
/// 上升、且最低一档就是下限字节 21(= 0.0824 ≥ 0.08)、最后一档不透明(不给字节)。
/// 这就是「拖动 → 实际 α」的链条在数值上的等价物;真机拖动的那一次由二进制日志举证。
/// 抓不住:拖动的手感与真实观感(RDP 上不可信)。
#[test]
fn a_slider_sweep_moves_the_alpha_and_the_byte_monotonically() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let mut bytes = Vec::new();
for step in 0..=10 {
let fraction = step as f32 / 10.0;
let alpha = crate::pages::appearance::alpha::window_alpha_from_fraction(fraction);
app.appearance.apply(Change::WindowOpacity(alpha));
let byte = app.appearance.window_alpha_byte();
bytes.push(byte);
// 每一档都要能打出一行(数值而不是观感)。
assert!(app.appearance.log_line("sweep").contains("window_alpha="));
}
assert_eq!(bytes[0], Some(21), "0% 一档 = 下限 0.08 → 21");
assert_eq!(bytes[10], None, "100% 一档 = 不透明 → 不下发");
let sent: Vec<u8> = bytes.iter().flatten().copied().collect();
assert!(
sent.windows(2).all(|pair| pair[0] <= pair[1]),
"字节必须单调不减:{sent:?}"
);
assert_eq!(sent.len(), 10, "除最后一档(不透明)之外每档都有字节");
assert!(
sent.iter().all(|byte| *byte >= 21),
"任何一档都不许低于下限字节:{sent:?}"
);
}
/// 抓得住:日志行里有请求值、生效值与下发字节(RDP 上唯一可信的证据形态)。
/// 抓不住:这行日志是否真的被写进了 stderr(本测试只钉格式,真机日志由跑起来的二进制举证)。
#[test]
fn the_log_line_carries_requests_effective_values_and_the_byte() {
let mut cpu = headless(
Options {
backend: Backend::Cpu,
..Options::default()
},
SchemeFlavor::Light,
);
cpu.appearance.apply(Change::WindowOpacity(0.5));
cpu.appearance.apply(Change::PanelAlpha(0.4));
let line = cpu.appearance.log_line("change");
assert!(line.starts_with("[appearance] change "), "{line}");
assert!(line.contains("window_alpha=0.500"), "{line}");
assert!(line.contains("panel_alpha=1.000"), "CPU 档生效值:{line}");
assert!(line.contains("(requested 0.400)"), "请求值也要在:{line}");
assert!(
line.contains("byte=128"),
"0.5×255=127.5 → 向上取整 128:{line}"
);
// 不透明时不给字节(而不是给 255)。
let opaque = headless(Options::default(), SchemeFlavor::Light);
assert!(opaque.appearance.log_line("boot").contains("byte=none"));
}
/// 抓得住:`State::new` 与 `apply_env` 之外,`State` 的构造不会偷偷读环境(测试之间不串味)。
/// 抓不住:`ULL_APPEARANCE_*` 在真机上的解析(`apply_env` 由截图矩阵覆盖,见报告)。
#[test]
fn a_fresh_state_is_the_factory_appearance() {
let state = State::new(
&unlucky_presenter::prefs::AppearancePrefs::factory(),
&unlucky_presenter::prefs::AppearanceBase::factory(),
Backend::Gpu,
SchemeFlavor::Light,
);
assert_eq!(state.window_opacity, 1.0);
assert_eq!(state.effective_panel_alpha(), 1.0);
assert!(!state.background_active());
assert!(state.diagnostics().is_empty());
assert!(state.image().is_none());
}
+246
View File
@@ -0,0 +1,246 @@
//! presenter 闭环与节拍的断言(M90 r2 从 `app/tests.rs` 外置:那边 416 行会被 file-size 判红)。
//!
//! 这里的每条都依赖一个可注入的 `Backend`:
//! - `MockBackend`(presenter 自带)验「命令 → 事件 → 状态」的闭环;
//! - `RecorderBackend`(本文件,只记账不回事件)验「UI 侧不做乐观回显」——用 MockBackend 验这条
//! 会得到**假绿**,因为它是同步发事件的,本地回显与事件回流终态相同(P3-2 的教训)。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_i18n::Key;
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent};
use unlucky_presenter::mock_data::mock_instances;
use super::*;
// --------------------------------------------------------------- presenter 闭环
/// **首屏快照在 Boot 就到位**(截图在第 3 帧落盘,等 30 fps 的 tick 会拍进空页面)。
#[test]
fn boot_drains_the_first_snapshot_before_the_first_frame() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
assert!(app.view.instances.rows.is_empty(), "还没 Boot");
let _ = app.update(Message::Boot);
assert_eq!(
app.view.instances.rows.len(),
4,
"mock 的四个实例已进视图模型"
);
assert_eq!(app.view.screen, pages::Screen::Instances);
}
/// 首屏自动选中第一行(右栏不该开局空白),且**只做一次**:返回列表后再刷新不弹回详情。
#[test]
fn the_first_snapshot_selects_the_first_instance_once() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
assert_eq!(
app.state.route(),
&Route::Detail("survival".to_string()),
"首屏快照到位后自动选中第一行"
);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert_eq!(app.state.route(), &Route::Instances);
// 再推进一拍(会 drain_events + auto_select):不该把他弹回详情页
app.advance(super::super::state::TICK);
assert_eq!(app.state.route(), &Route::Instances, "自动选中只做一次");
}
/// 只记账、**不回任何事件**的后端(P3-2)。
///
/// 为什么需要它:`MockBackend::dispatch` 是**同步**发事件的,于是「UI 本地回显」与「事件回流」
/// 在 dispatch 之后终态相同——拿 MockBackend 测「不乐观回显」是假绿(r1 就是栽在这条上)。
/// 这个后端把两件事分开:命令只进 `dispatched`,`drain_events` 永远空 ⇒ 状态**不可能**变;
/// 一旦有人在 `update` 里偷偷改本地状态,下面那条断言立刻红(实测见报告「P3-2 取证」)。
#[derive(Debug, Default)]
struct RecorderBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
seeded: Vec<AppEvent>,
}
impl Backend for RecorderBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut self.seeded)
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command);
}
}
/// 用 recorder 后端造一个 App(首屏快照靠 `seeded` 一次性喂进去)。
fn recording(options: Options, flavor: SchemeFlavor) -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let recorded = Rc::new(RefCell::new(Vec::new()));
// 注意:`InstancesChanged` 是**整体替换**,所以四条必须合成一条事件;
// 分开发的话最后只会剩一个实例(r1 在这条上踩过)。
let backend = RecorderBackend {
dispatched: Rc::clone(&recorded),
seeded: vec![AppEvent::Core(
CoreEvent::InstancesChanged(mock_instances()),
)],
};
let app = App::with_presenter(
options,
FAMILIES,
flavor,
Box::new(backend),
SettingsBridge::memory_only(),
);
(app, recorded)
}
/// **P3-2:不做乐观回显**——命令必须真的出 UI 层,而状态在后端回事件之前**一个字节都不许变**。
///
/// 抓得住:`update` 里出现「本地先改状态」的写法(recorder 永远不回事件,本地一改这里就红)。
/// 抓不住:「命令有没有真的到达 host」——那由 `MockBackend` 的闭环测试与真机验证负责。
#[test]
fn launching_only_dispatches_and_never_echoes_locally() {
let mut options = screenshot_options();
options.screenshot = None; // recorder 不依赖截图通道
let (mut app, recorded) = recording(options, SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
// Boot 自己发过一条 RefreshAll,先把账本清掉,下面只看「点 Play」这一个动作
recorded.borrow_mut().clear();
let before = app.view.instances.detail.clone().expect("有选中项");
assert!(before.can_launch && !before.can_stop && before.status.is_none());
let _ = app.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
// ① 命令真的出 UI 层了
assert_eq!(
*recorded.borrow(),
vec![AppCommand::LaunchInstance("survival".to_string())],
"Launch 必须翻成一条 AppCommand::LaunchInstance 并交给 Backend::dispatch"
);
// ② 后端一条事件都没回 ⇒ 视图模型必须**原样不动**(任何本地回显都会让这条红)
let after = app.view.instances.detail.clone().expect("有选中项");
assert_eq!(
after, before,
"没有事件回流时页面不许自己变(乐观回显);现在的差异是 {after:?}"
);
assert!(!after.can_stop, "不许因为点了 Play 就本地切成「可停止」");
assert!(after.status.is_none(), "不许本地造一条进度文案");
}
/// **点 Play → 命令真的派发出去 → 事件回流 → 页面真的变**(真后端的闭环)。
#[test]
fn launching_goes_through_the_backend_and_comes_back_as_events() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
let detail = app.view.instances.detail.as_ref().expect("有选中项");
assert!(
detail.can_launch && !detail.can_stop,
"空闲:可启动不可停止"
);
let _ = app.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
let detail = app.view.instances.detail.as_ref().expect("有选中项");
assert!(
!detail.can_launch && detail.can_stop,
"派发 LaunchInstance 后后端发回了 Busy 事件:按钮组合必须已经翻转"
);
// 阶段名跟界面语言走(M120):断言对着**当前生效语言**的目录算,别在中文机器上钉死英文
let stage = app.lang().catalog().text(Key::InstancesStageDownload);
assert_eq!(
detail.status.as_deref(),
Some(format!("{stage} · 0%").as_str()),
"状态行来自事件里的进度,不是本地假造"
);
}
/// 时间推进真的把状态往前推(后端的脚本按 tick 的累计时长走)。
#[test]
fn ticks_advance_the_backend_script() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
let _ = app.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
// 33 ms × 50 ≈ 1.65 s:mock 的脚本在 1.5 s 从 Download 切到 Verify
for _ in 0..50 {
app.advance(super::super::state::TICK);
}
let detail = app.view.instances.detail.as_ref().expect("有选中项");
let status = detail.status.as_deref().unwrap_or_default();
let catalog = app.lang().catalog();
let (verify, launch) = (
catalog.text(Key::InstancesStageVerify),
catalog.text(Key::InstancesStageLaunch),
);
assert!(
status.starts_with(verify) || status.starts_with(launch),
"50 拍之后应已过下载阶段,实际 {status:?}"
);
}
/// 停止也是真命令:`StopInstance` 让 mock 直接回一条 Idle 事件。
#[test]
fn stopping_returns_to_idle_through_an_event() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
let _ = app.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
let _ = app.update(Message::Page(pages::Message::Stop("survival".to_string())));
let detail = app.view.instances.detail.as_ref().expect("有选中项");
assert!(detail.can_launch && !detail.can_stop, "停下来回到空闲");
assert!(detail.status.is_none(), "空闲没有状态行");
}
/// 导航:一级页替换、详情压栈、返回出栈——页面换的是 `Route`,不是页面自己的选中项。
#[test]
fn navigation_follows_the_presenter_router() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert_eq!(app.view.screen, pages::Screen::Instances, "详情仍是实例页");
assert!(app.state.router.can_go_back());
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings)));
assert_eq!(app.view.screen, pages::Screen::Settings);
assert_eq!(
app.view.active,
Some(pages::Screen::Settings.nav()),
"导航轨高亮跟着路由走"
);
assert!(
!app.state.router.can_go_back(),
"一级页互相替换,不进栈(presenter::router 的语义)"
);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert_eq!(app.view.screen, pages::Screen::Instances);
}
+288
View File
@@ -0,0 +1,288 @@
//! `update`:消息分支的**唯一**落点(含「用户动作 → 后端指令」的派发与事件回流)。
//!
//! ## 数据流的三个入口(presenter crate 文档 §2)
//!
//! ```text
//! 用户动作 ──dispatch(AppCommand)──► backend ──► drain_events() ──► AppState::apply ──► 视图模型
//! ▲ ▲
//! └── pages::Message(导航/启动/停止)──────────────────┘ 忙碌 33 ms / 空闲 1 Hz
//! ```
//!
//! 三条纪律:
//!
//! 1. **不做乐观回显**:点了「Play」不改本地状态,等 `InstanceRuntimeChanged` 事件回来才变
//! (否则事件与本地状态打架时,磁贴与进度条会各说一套);
//! 2. **状态只有一个写入口**:`AppState::apply`(事件)与 `AppState::navigate`(路由);
//! 3. **只在状态变了才重建视图模型**:`refresh_view()` 是快照 → 页面的唯一转换点,
//! 由 `view_dirty` 门控(M90 r2/P2-1 起;此前是每拍无条件重建)。
//!
//! ## 两个时钟(M90 r3 / P2-2)
//!
//! | 场景 | 时钟 | 周期 | 谁触发 |
//! |---|---|---|---|
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
//!
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
use std::time::Instant;
use iced::{window, Task};
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::AppCommand;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::router::Route;
use crate::pages;
use crate::renderer::Kind;
use super::message::{route_of, Message};
use super::state::{App, IDLE_TICK, TICK, WATCH_WINDOW};
use super::CAPTURE_AT_FRAME;
mod account;
mod agent;
mod appearance;
mod boot_env;
mod clock;
mod content;
mod download;
mod page_messages;
mod settings;
impl App {
/// 一条消息进来,改状态并返回后续任务。
pub(super) fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Boot => self.on_boot(),
Message::WindowReady(id) => {
self.window = id;
// M93:整窗 α 要 Win32 句柄,而 iced 只给 `window::Id`。`window::run` 是唯一能把
// `&dyn Window`(实现了 `HasWindowHandle`)拿出来的入口。
match id {
Some(id) => {
window::run(id, super::window_alpha::probe_hwnd).map(Message::WindowHandle)
}
None => Task::none(),
}
}
Message::WindowHandle(hwnd) => {
self.hwnd = hwnd;
match hwnd {
Some(_) => self.apply_window_alpha("boot"),
None => {
eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
self.log_appearance("boot", None);
}
}
// M116:圆角放在 α **之后**——α < 1 时那一步才补上 `WS_EX_LAYERED`,
// 先打圆角日志会把 `layered=` 记成 `off`(那正是我们要分辨的那一位)。
self.apply_window_corner("boot");
Task::none()
}
Message::SystemTheme(mode) => {
let flavor = match mode {
iced::theme::Mode::Dark => SchemeFlavor::Dark,
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
};
eprintln!("[theme] 系统主题 = {mode:?}");
// 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。
// 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现
// `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。
if self.options.theme.is_none() && self.theme_mode == ThemeMode::Auto {
self.set_flavor(flavor);
}
Task::none()
}
Message::Frame => self.on_frame(),
Message::Heartbeat => self.on_heartbeat(),
Message::Window(event) => {
match event {
window::Event::Resized(size) => {
self.width = size.width;
// 高度是背景平铺网格的视口判据之一(宽度早就有),以前只记宽是因为没有背景层。
self.height = size.height;
self.view_dirty = true;
self.refresh_view();
}
// M113: import/export —— 拖入整合包(本版唯一的选文件通道;窗口级事件,
// 所以入口收起时也照样接得住。细节在 `on_pack_dropped` / `on_pack_hover`)
window::Event::FileDropped(path) => return self.on_pack_dropped(path),
window::Event::FileHovered(_) => return self.on_pack_hover(true),
window::Event::FilesHoveredLeft => return self.on_pack_hover(false),
_ => {}
}
Task::none()
}
Message::Page(message) => self.on_page(message),
Message::Agent(message) => self.on_agent(message),
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
Message::Select(nav) => {
self.state.navigate(route_of(nav));
self.view_dirty = true;
self.refresh_view();
Task::none()
}
Message::DragWindow => match self.window {
Some(id) => window::drag(id),
None => Task::none(),
},
Message::ResizeStart(direction) => match self.window {
Some(id) => window::drag_resize(id, direction),
None => Task::none(),
},
Message::Minimize => match self.window {
Some(id) => window::minimize(id, true),
None => Task::none(),
},
Message::ToggleMaximize => match self.window {
Some(id) => window::toggle_maximize(id),
None => Task::none(),
},
Message::Close => iced::exit(),
Message::Captured(screenshot) => self.on_captured(&screenshot),
Message::Demo(message) => {
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
eprintln!("[gallery] {} pressed", message.id());
Task::none()
}
}
}
/// 启动:问窗口句柄 → 让后端发布首屏快照 → 决定主题。
///
/// **首屏快照在 `Boot` 就抽干**(不是等第一拍):截图通道在第 3 帧落盘,33 ms 的节拍
/// 未必来得及到达,等节拍会把空页面拍进图里。
fn on_boot(&mut self) -> Task<Message> {
let mut tasks = vec![window::latest().map(Message::WindowReady)];
// M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。
// 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络;
// 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面,
// 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨
// 「没配置」与「配了但登录失败」。
eprintln!("{}", unlucky_cli::commands::msa_client_id_log_line());
// 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
self.apply_config_theme();
// M93 外观取证开关(`ULL_APPEARANCE_*`)+ 启动那一行数值日志。
// 必须在 `refresh_view` 之前:设置页要画的就是这份状态。
self.appearance.apply_env();
self.rebuild_ui();
self.log_appearance("boot", None);
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
if self.options.wants_system_theme() {
tasks.push(iced::system::theme().map(Message::SystemTheme));
}
// M104:助手栏的启动尾巴(窗口尺寸 adopt + 「拆出档直接落到独立页」)在 update/agent.rs。
// **必须在 RefreshAll 之前**:那次 dispatch 会 `pump → auto_select`,把路由拉到实例详情,
// 而 `sync_agent_route` 看到「不在 Agent 页却还是 Detached」就会把档位收回来——顺序反了
// 就会出现「`ULL_AGENT_RAIL=detached` 启动后停在实例页」(r2 实测踩到)。
self.boot_agent();
// 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置
self.dispatch(AppCommand::RefreshAll);
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
if let Some(route) = boot_env::env_start_route() {
eprintln!("[boot] ULL_START_ROUTE → {route:?}");
self.state.navigate(route);
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true;
// M109:直达内容库时补一次检索(与页面内切换同一个入口)
self.ensure_content_loaded();
}
account::force_account_state(self);
self.auto_select();
self.view_dirty = true;
self.refresh_view();
// 空闲心跳从这里开始自续(它是唯一能在「没有帧事件」时戳醒 app 的时钟)
tasks.push(heartbeat());
Task::batch(tasks)
}
/// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。
///
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
fn on_heartbeat(&mut self) -> Task<Message> {
if !self.needs_frames() {
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK);
self.last_tick = now;
self.advance(elapsed);
}
heartbeat()
}
/// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。
///
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
fn on_frame(&mut self) -> Task<Message> {
self.frames += 1;
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick);
if elapsed >= TICK {
self.last_tick = now;
self.advance(elapsed);
}
// 主题在启动时就已确定(截图模式必有主题,见 cli 模块文档),这里只剩帧数闸门
if !self.wants_frames() || self.frames < CAPTURE_AT_FRAME {
return Task::none();
}
match self.window {
Some(id) => window::screenshot(id).map(Message::Captured),
None => Task::none(),
}
}
/// 截图回来了:落盘 + 打一行取证日志,然后退出。
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
let Some(path) = self.options.screenshot.clone() else {
return Task::none();
};
match crate::shot::save(screenshot, &path) {
Ok(summary) => {
eprintln!("{}", summary.describe());
// M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。
eprintln!(
"[shot] screen={} theme={} renderer={} route={:?}",
self.options.screen.name(),
crate::shot::flavor_slug(&self.ui),
super::backend::effective_kind().map_or("?", Kind::name),
self.state.route()
);
}
Err(error) => eprintln!("[shot] 失败:{error}"),
}
iced::exit()
}
}
/// 空闲心跳的一拍:睡 1 秒再发 [`Message::Heartbeat`]。
///
/// 用 `Task::perform` + `std::thread::sleep` 而不是 `iced::time::every`:后者只在 tokio/smol
/// 后端提供(本 crate 的执行器是 thread-pool,Cargo.toml 不在 scope)。代价是每拍让线程池里
/// 一个线程阻塞 1 秒——按 1 Hz 算,对线程池(默认 = CPU 数)是可接受的占用。
fn heartbeat() -> Task<Message> {
Task::perform(async { std::thread::sleep(IDLE_TICK) }, |()| {
Message::Heartbeat
})
}
#[cfg(test)]
mod download_wiring;
+210
View File
@@ -0,0 +1,210 @@
//! 账户页消息的落点(M114):页面只发意图,这里翻成 `AppCommand` 或本地的表单状态写入。
//!
//! 三条纪律(与 `update.rs` 同源):
//!
//! 1. **不做乐观回显**:切账户/删账户/登录都只派命令,等 `AccountsChanged` /
//! `DeviceLoginChanged` / `AuthlibLoginChanged` 回来才改列表;
//! 2. **密码只走一次**:`Submit` 把 `AuthlibRequest` 交给后端后立刻清掉表单里的密码
//! (成功失败都清——重试用新输入,不在内存里多留一份凭据);
//! 3. **表单态是纯 UI 状态**:输入框改动只写 `App::account_form` + 置脏,不派命令。
//!
//! ## 返回 `Task`(M114 r2 / 复审 P2-1)
//!
//! 复制用户码/链接必须把 `iced::clipboard::write` 的 `Task` **交回 runtime**——iced 0.14 的
//! `Task` 是惰性 stream,丢掉返回值等于永不执行(日志页的 Copy summary 就是正确写法)。
use iced::Task;
use unlucky_presenter::dto::{AccountKind, AppCommand, AuthlibRequest, DeviceLoginPhase};
use crate::app::Message;
use crate::pages::account::{Hint, Message as Account, Panel, Track};
use super::super::state::App;
impl App {
/// 账户页消息。
pub(super) fn on_account(&mut self, message: Account) -> Task<Message> {
// 复制是唯一需要**交回 Task** 的分支:先把它的 Task 抓出来,其余分支照旧改状态
let clipboard = match &message {
Account::Copy(text) => Some(text.clone()),
_ => None,
};
match message {
Account::Open(track) => {
self.account_form.open(track);
self.account_form.hint = None;
self.view_dirty = true;
}
Account::ToggleAdd => {
self.account_form.section_open = !self.account_form.section_open;
self.account_form.hint = None;
self.view_dirty = true;
}
Account::Close => {
self.account_form.close();
// 关面板必须把**两条轨**的登录态都收回 Idle(复审 r2 P2-1:只看 `busy()` 时,
// `Failed`/`Succeeded` 不在 busy ⇒ 状态仍是非 Idle ⇒ `Page::build` 又把面板
// 强制回 Microsoft,按钮成了死按钮)。`CancelDeviceLogin` 在 host 侧本来就
// 同时收两条轨并发 Idle,所以判据用「非 Idle」而不是「busy」。
if self.state.device_login.phase != DeviceLoginPhase::Idle
|| self.state.authlib_login.phase != DeviceLoginPhase::Idle
{
self.dispatch(AppCommand::CancelDeviceLogin);
}
self.view_dirty = true;
}
Account::DismissNotice => self.dispatch(AppCommand::DismissAccountNotice),
Account::Server(value) => {
self.account_form.server = value;
self.view_dirty = true;
}
Account::Login(value) => {
self.account_form.login = value;
self.view_dirty = true;
}
Account::Password(value) => {
self.account_form.password = value;
self.view_dirty = true;
}
Account::OfflineName(value) => {
self.account_form.offline_name = value;
self.view_dirty = true;
}
Account::Probe => {
let server = self.account_form.server.clone();
if !server.trim().is_empty() {
self.dispatch(AppCommand::ProbeAuthlibServer(server));
}
}
Account::Submit => match self.account_form.panel {
Panel::Offline => {
// 名字**不清空**:失败时用户还要看见自己输入的(复审 r1 P2-2 的另一半)
let name = self.account_form.offline_name.trim().to_string();
if !name.is_empty() {
self.dispatch(AppCommand::AddOfflineAccount(name));
}
}
Panel::Authlib => {
if self.account_form.authlib_ready() {
let request = AuthlibRequest::new(
self.account_form.server.clone(),
self.account_form.login.clone(),
std::mem::take(&mut self.account_form.password),
);
self.dispatch(AppCommand::AddAuthlibAccount(request));
}
}
Panel::Microsoft => return self.on_account(Account::StartBrowser),
Panel::None => {}
},
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
Account::StartBrowser => self.dispatch(AppCommand::StartBrowserLogin),
Account::Cancel => {
self.account_form.close();
self.dispatch(AppCommand::CancelDeviceLogin);
}
Account::Copy(_) => {}
Account::Switch(id) => self.dispatch(AppCommand::SwitchAccount(id)),
Account::Remove(id) => self.dispatch(AppCommand::RemoveAccount(id)),
Account::Relogin(id) => return self.relogin(&id),
}
self.refresh_view();
match clipboard {
Some(text) => iced::clipboard::write(text),
None => Task::none(),
}
}
/// 「重新登录」按**账户类型**分派(复审 r1 P2-3)。
///
/// - 微软轨:走授权码流(原路);
/// - 第三方轨:第三方站没有「一键重登」——预填服务器地址与登录名,让用户补密码;
/// - 本地档案:没有服务器账户可重登 ⇒ 明确说明,不假装能做。
fn relogin(&mut self, id: &str) -> Task<Message> {
let Some(account) = self
.state
.accounts
.iter()
.find(|account| account.id == id)
.cloned()
else {
return Task::none();
};
self.account_form.hint = None;
match account.kind {
AccountKind::Microsoft => {
self.account_form.panel = Panel::Microsoft;
self.dispatch(AppCommand::StartBrowserLogin);
}
AccountKind::Authlib => {
self.account_form.open(Track::Authlib);
self.account_form.server = account.server_url.clone();
self.account_form.login = account.login_name.clone();
self.account_form.password.clear();
self.account_form.hint = Some(Hint::AuthlibReauth);
self.view_dirty = true;
self.refresh_view();
}
AccountKind::Offline => {
self.account_form.hint = Some(Hint::OfflineNoRelogin);
self.view_dirty = true;
self.refresh_view();
}
}
Task::none()
}
}
/// 取证开关 `ULL_ACCOUNT_STATE` 的落地(**只在 `--screenshot` 下生效**)。
///
/// 为什么需要:设备码过期要等 15 分钟、表单要手点两下,截图通道等不到自然发生的状态。
pub(super) fn force_account_state(app: &mut App) {
if !app.has_screenshot() {
return;
}
let Ok(state) = std::env::var("ULL_ACCOUNT_STATE") else {
return;
};
match state.trim().to_lowercase().as_str() {
// 设备码已过期:起点往前挪一小时(倒计时自己算出来就是 0)
"expired" => {
app.state.device_login_started =
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
}
// 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
"authlib-failed" => {
app.account_form.panel = crate::pages::account::Panel::Authlib;
app.account_form.server = "https://example.invalid/api/yggdrasil/".to_string();
app.account_form.login = "alex@example.com".to_string();
}
// 离线档案失败态:面板打开(横幅由 mock 的 notice 场景给)
"notice" => {
app.account_form.panel = crate::pages::account::Panel::Offline;
app.account_form.offline_name = "ab".to_string();
}
// 第三方站表单:直接把面板打开并预填地址
"authlib" | "authlib-form" => {
app.account_form.panel = crate::pages::account::Panel::Authlib;
if app.account_form.server.trim().is_empty() {
app.account_form.server = "https://littleskin.cn/api/yggdrasil/".to_string();
}
app.account_form.login = "steve@example.com".to_string();
app.account_form.password = "not-a-real-password".to_string();
}
// 「关掉之后」:mock 给的是 failed 种子,这里按一次 Close(走真实的
// `on_account` → `CancelDeviceLogin` 通道),出「面板真的关了」的对照图。
"closed" => {
let _ = app.on_account(crate::pages::account::Message::Close);
}
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
"add" => {
app.account_form.section_open = true;
}
_ => {}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,303 @@
//! **树测试②(M119 验收核心)**:账户页消费 presenter 的账户列表与登录事件。
//!
//! 后端是 UI 侧的 [`AccountRig`]:它把 `AppCommand` 翻成**与真 host 同形状**的 `CoreEvent`
//! (`AccountsChanged` / `DeviceLoginChanged` / `AccountNoticeChanged`),数据在内存里。
//! 于是这条测试钉住的是「**接线**」——用户点一下之后,命令出去、事件回来、页面重算、横幅出现;
//! 抓不住的是 `core::auth::AccountStore` 的落盘(那条由 presenter 自己的
//! `host::accounts::offline_add_list_remove_round_trips` 覆盖,原因见 `presenter/rig.rs` 的模块文档)。
//!
//! 覆盖任务书第 3 条与第 4 条的账户那一半:
//!
//! | 断言 | 抓得住什么 |
//! |---|---|
//! | 添加离线档案 → 列表出现一行 | `AddOfflineAccount` 没派发 / 事件没回流 / 页面没重建 |
//! | 切换当前账号 → 「当前」标记换人 | `SwitchAccount` 没派发(M114 的 `active` 标记是页面唯一来源)|
//! | 删除 → 列表回到空态 | `RemoveAccount` 没派发 |
//! | 设备码「进行中可见 + 可取消」 | `StartDeviceLogin` / `CancelDeviceLogin` 没接线(只剩转圈)|
//! | 失败态:名字不合法 / 凭据失效都给**具名**错误 | 失败被吞掉(复审 r1 P2-2 的老毛病)|
use unlucky_design::color::SchemeFlavor;
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{
AccountNoticeCode, AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginView, LoginFailure,
};
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::presenter::rig::AccountRig;
use crate::pages::account::model::{AuthlibPanel, DevicePanel};
use crate::pages::account::{Form, Message as Account, Panel, Track};
use crate::theme::Families;
use super::App;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘的 App,后端换成**账户 rig**(其余分支照旧走 `MockBackend` 的能力就够)。
fn with_rig() -> App {
App::with_presenter(
crate::cli::Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(AccountRig::new()),
SettingsBridge::memory_only(),
)
}
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同源。
fn headless() -> App {
App::with_presenter(
crate::cli::Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
fn seed(app: &mut App, event: CoreEvent) {
app.state.apply(AppEvent::Core(event));
app.refresh_view();
}
/// **添加 → 切换 → 删除**(任务书第 3 条,一条端到端)。
///
/// 抓得住:三条命令任何一条没派发 / 事件没回流 / `refresh_view` 没带上账户页。
/// 抓不住:真实账户库的落盘(见模块文档)。
#[test]
fn an_offline_profile_is_added_switched_and_removed_end_to_end() {
let mut app = with_rig();
assert!(app.account.is_empty(), "出厂是空态");
// ① 添加离线档案:走真消息(点轨道卡 → 填名字 → 提交)
let _ = app.on_account(Account::Open(Track::Offline));
assert_eq!(app.account_form.panel, Panel::Offline);
// 表单输入也走真消息(纯 UI 态,只写 `App::account_form` + 置脏)
let _ = app.on_account(Account::OfflineName("Steve".to_string()));
assert!(
app.account_form.offline_ready(),
"3–16 位字母:按钮该是亮的"
);
let _ = app.on_account(Account::Submit);
assert_eq!(app.account.rows.len(), 1, "列表出现一行(不是静默)");
assert_eq!(app.account.rows[0].name, "Steve");
assert_eq!(
app.account.notice.as_ref().map(|notice| notice.code),
Some(AccountNoticeCode::Added),
"页面横幅给出的是一次性结果"
);
let id = app.account.rows[0].id.clone();
// ② 切换当前账号
let _ = app.on_account(Account::Switch(id.clone()));
assert_eq!(
app.account.active_row().map(|row| row.name.as_str()),
Some("Steve"),
"「当前」标记跟着切换走"
);
// ③ 删除
let _ = app.on_account(Account::Remove(id));
assert!(app.account.rows.is_empty(), "删除后回到空态");
assert_eq!(
app.account.notice.as_ref().map(|notice| notice.code),
Some(AccountNoticeCode::Removed)
);
// 两个横幅文案都有词(中英各一条,不是硬编码)
for lang in [Lang::En, Lang::ZhCn] {
assert!(!Catalog::of(lang).text(Key::AccountNoticeRemoved).is_empty());
}
// 整棵树在三种状态下都能构图
let _ = app.view();
}
/// 设备码流程:**指令发出 + 进行中可见 + 可取消**(任务书第 3 条的后半,真实网络不要求)。
#[test]
fn the_device_code_flow_is_started_visible_and_cancellable() {
let mut app = with_rig();
assert_eq!(app.account.device, DevicePanel::Hidden);
let _ = app.on_account(Account::StartDevice);
match &app.account.device {
DevicePanel::Code {
code, remaining, ..
} => {
assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑");
}
other => panic!("进行中状态必须可见,实际 {other:?}"),
}
assert_eq!(app.account.panel, Panel::Microsoft, "面板要切到微软那条轨");
let _ = app.view();
let _ = app.on_account(Account::Cancel);
assert_eq!(app.account.device, DevicePanel::Hidden, "取消要真的收掉");
assert_eq!(
app.state.device_login.phase,
unlucky_presenter::dto::DeviceLoginPhase::Idle
);
}
/// 失败态①:**未实现 / 名字不合法** ⇒ 页面横幅给出结构化码(不是静默无语)。
#[test]
fn an_invalid_profile_name_surfaces_a_named_failure() {
let mut app = with_rig();
let _ = app.on_account(Account::Open(Track::Offline));
// 名字不合法时**发送键是灰的**(与 host 同一份判据),所以这里直接派命令:
// 抓的是「host 真的回了一个结构化失败码」这条链路,而不是按钮的禁用态。
assert!(!app.account_form.offline_ready());
app.dispatch(unlucky_presenter::dto::AppCommand::AddOfflineAccount(
"ab".to_string(),
));
assert!(app.account.rows.is_empty(), "不合法的名字不入列表");
let notice = app.account.notice.as_ref().expect("必须有横幅");
assert_eq!(notice.code, AccountNoticeCode::OfflineNameInvalid);
assert!(notice.failure, "失败档(配色不同)");
assert_eq!(notice.subject, "ab", "横幅要带上是哪个名字");
// 文案走 catalog,中英各一条
for lang in [Lang::En, Lang::ZhCn] {
assert!(!Catalog::of(lang)
.text(Key::AccountNoticeOfflineNameInvalid)
.is_empty());
}
let _ = app.view();
}
/// 失败态②:**账户凭据失效** ⇒ 第三方轨面板进入 `Failed(BadCredentials)`(只带原因码)。
///
/// 为什么只断言原因码:M114 r3 起面板类型里**没有** `message`/`detail` 字段(原文只进 presenter
/// 日志),页面据码选 catalog 文案。这条测试与「页面画得出文案」之间的桥梁是
/// `pages::account::login::failure_body_key`(同一 crate 内的穷尽 match,缺一条编译不过)。
#[test]
fn expired_credentials_surface_a_named_failure_code() {
let mut app = headless();
seed(
&mut app,
CoreEvent::AuthlibLoginChanged(AuthlibLoginView::failed_as(
LoginFailure::BadCredentials,
"服务器返回 401:凭据已失效(token=…已脱敏)",
)),
);
app.account_form.open(Track::Authlib);
app.refresh_view();
assert_eq!(
app.account.authlib,
AuthlibPanel::Failed {
failure: LoginFailure::BadCredentials
},
"凭据失效要落成具名原因码,而不是静默回 Idle"
);
// 原因码对应的那句译文两套语言都得有
for lang in [Lang::En, Lang::ZhCn] {
let text = Catalog::of(lang).text(Key::AccountFailureBadCredentials);
assert!(!text.trim().is_empty(), "{} 缺这条失败文案", lang.tag());
}
// 原文不许出现在页面的任何可视文本里(M114 的凭据红线)
assert!(
!format!("{:?}", app.account).contains("服务器返回 401"),
"后端原文不许进页面模型"
);
let _ = app.view();
}
// ---------------------------------------------------------------- M114 的既有回归闸
/// **复审 r2 P2-1 的回归闸**:登录失败/成功态下按 `Close`,面板必须真的关掉。
///
/// 为什么这条以前是死按钮:`Page::build` 只要 `device_login.phase != Idle` 就把面板强制
/// 回 Microsoft,而 `Close` 只在 `busy()`(Requesting/Waiting)时才派 `CancelDeviceLogin`
/// ⇒ Failed/Succeeded 下点了没反应。现在判据改成「非 Idle」,两条轨都覆盖。
///
/// 抓得住:判据退回 `busy()`;`Close` 忘了 dispatch;`CancelDeviceLogin` 没有把状态收回 Idle。
/// 抓不住:`CancelDeviceLogin` 在**真 host** 上的副作用(这里跑的是 mock,但 mock 已按
/// 真 host 的契约发 Idle,见 `presenter/src/mock.rs`)。
#[test]
fn close_really_closes_the_login_panel_on_both_tracks() {
// ① 微软轨:失败态
let mut app = headless();
seed(
&mut app,
CoreEvent::DeviceLoginChanged(DeviceLoginView {
phase: unlucky_presenter::dto::DeviceLoginPhase::Failed,
message: "微软拒绝了这次登录:AADSTS7000218(HTTP 403)".to_string(),
failure: Some(LoginFailure::NotWhitelisted),
..DeviceLoginView::new()
}),
);
assert_eq!(app.account.panel, Panel::Microsoft, "失败态面板要显示");
let _ = app.on_account(Account::Close);
assert_eq!(app.account.panel, Panel::None, "Close 之后面板必须关掉");
assert_eq!(
app.state.device_login.phase,
unlucky_presenter::dto::DeviceLoginPhase::Idle
);
// ② 微软轨:成功态(同样不在 busy 里)
let mut app = headless();
seed(
&mut app,
CoreEvent::DeviceLoginChanged(DeviceLoginView::succeeded("Steve")),
);
assert_eq!(app.account.panel, Panel::Microsoft);
let _ = app.on_account(Account::Close);
assert_eq!(app.account.panel, Panel::None, "成功提示也要能关掉");
// ③ 第三方轨:失败态
let mut app = headless();
seed(
&mut app,
CoreEvent::AuthlibLoginChanged(AuthlibLoginView {
phase: unlucky_presenter::dto::DeviceLoginPhase::Failed,
server: "https://example.invalid/api/yggdrasil/".to_string(),
server_name: String::new(),
message: "服务器返回 HTTP 404".to_string(),
failure: Some(LoginFailure::NotAuthlibServer),
account_name: String::new(),
}),
);
app.account_form.open(Track::Authlib);
app.refresh_view();
assert_eq!(
app.account.panel,
Panel::Authlib,
"第三方轨失败态面板要显示"
);
let _ = app.on_account(Account::Close);
assert_eq!(app.account.panel, Panel::None, "第三方轨也要能关掉");
assert_eq!(
app.state.authlib_login.phase,
unlucky_presenter::dto::DeviceLoginPhase::Idle
);
}
/// 表单态是纯 UI 状态:打开/关闭面板不派命令,但会把面板切对。
///
/// 抓得住:`Open` 忘了写 `panel`(点了轨道卡什么都不出现)、`close()` 不清密码。
/// 抓不住:真实 dispatch 的副作用(那要真后端)。
#[test]
fn the_form_state_follows_the_page_messages() {
let mut form = Form::new();
assert_eq!(form.panel, Panel::None);
form.open(Track::Authlib);
assert_eq!(form.panel, Panel::Authlib);
form.password = "hunter2".to_string();
form.close();
assert_eq!(form.panel, Panel::None);
assert!(form.password.is_empty(), "关闭面板必须把一次性密码清掉");
}
/// 三张轨道卡默认收起(有账号时),`ToggleAdd` 展开(复审 r1 P2-4)。
#[test]
fn the_add_section_starts_collapsed() {
let mut form = Form::new();
assert!(!form.section_open);
form.section_open = true;
assert!(form.section_open);
}
+381
View File
@@ -0,0 +1,381 @@
//! 助手栏消息的落点(M104 建,M119 接线):三档状态迁移 / 输入 / 键盘 / **presenter 四契约**。
//!
//! ## 为什么单独一个文件
//!
//! `app/update.rs` 已经贴着 400 行硬上限(M94 也因此把下载页四支拆到 `update/download.rs`)。
//! 助手栏有十几个变体,塞回主文件必破线。
//!
//! ## 分工:视图态 vs presenter 契约(M119 之后的分界线)
//!
//! | 消息 | 落到哪 |
//! |---|---|
//! | `Expand` / `Collapse` / `Dock` / `Escape` | **只改视图态**(`AgentUi`)+ 路由栈(离开/进入 `Route::Agent`)+ `agent_mark_read` |
//! | `Detach` | 视图态 + `Route::Agent`(压栈,`back()` 能回去)+ `agent_mark_read` |
//! | `Edit` | 视图态(草稿),并同步进 presenter 的草稿(`agent_set_draft`) |
//! | `Submit` | [`AgentHost::submit`] → `agent_submit`;失败**不留白**(具名错误进横幅) |
//! | `Stop` | `AppCommand::StopAgent`(真后端才收敛得出结果;同步 rig 下是 no-op) |
//! | `Confirm(accept)` | [`AgentHost::confirm`] → `agent_confirm`(YOLO 下 presenter 自己放行并记审计) |
//! | `Events(..)` | [`AgentHost::apply`] → `AppState::apply`(**事件回流的唯一落点**) |
//! | `DragStart` / `DragTo` / `DragEnd` | 视图态:吸附宽度 |
//! | `Key` / `Tab` / `Activate` | 键盘路径(`Tab` 的环要在流式中多一格,`busy` 从 rail 取) |
//!
//! **会话内容一行都不在这里攒**:`transcript` / `busy` / `unread` / `confirm` 全在 presenter 的
//! `AgentRail` 上(M119 删掉了 M104 的自持副本)。
//!
//! ## 事件为什么绕一圈消息再回来
//!
//! `agent_submit` / `agent_confirm` 的端口是同步的,但**事件走队列**(见
//! `app/state/presenter/transport.rs`):抽队列的那一步必须和用户点击走同一条漏斗,否则两处各改
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
use iced::keyboard::{key, Key};
use iced::Task;
use unlucky_i18n::{Catalog, Key as TextKey};
use unlucky_presenter::dto::AppCommand;
use crate::app::MIN_SIZE;
use crate::app::WINDOW_SIZE;
use crate::shell::agent::demo::{self, Fixture};
use crate::shell::agent::{self, Focus, RailState};
use super::super::state::presenter::agent::SubmitOutcome;
use super::*;
#[cfg(test)]
mod tests;
impl App {
/// 助手栏消息(`app::Message::Agent(..)` 的唯一落点)。
pub(super) fn on_agent(&mut self, message: agent::Message) -> Task<Message> {
// M106 口径:文案走**生效语言**的目录,不再恒英文
let catalog = Catalog::of(self.lang());
// 只有**几何**会变的几支才需要重建页面模型(`pages::Model` 里没有会话内容:会话在
// presenter 的 rail 上,右栗自己那棵树每次 `view()` 都从 rail 现读)。复审 P3-9 的教训:
// 每敲一个字符都重建整张模型是白克隆。
let geometry_changed = matches!(
message,
agent::Message::Expand
| agent::Message::Collapse
| agent::Message::Detach
| agent::Message::Dock
| agent::Message::DragTo(_)
| agent::Message::DragEnd
| agent::Message::Escape
| agent::Message::Activate
| agent::Message::Key(_)
);
match message {
agent::Message::Expand => {
self.agent.expand();
// 「展开即读」是 presenter 的语义(`agent_mark_read`),壳只负责在正确的时刻调它
self.agent_host.mark_read();
}
agent::Message::Collapse | agent::Message::Escape => self.collapse_agent(),
agent::Message::Detach => {
self.agent.detach();
self.agent_host.mark_read();
self.state.navigate(Route::Agent);
}
agent::Message::Dock => {
self.agent.dock();
self.agent_host.mark_read();
self.leave_agent_page();
}
agent::Message::DragStart => self.agent.drag_start(),
agent::Message::DragTo(x) => self.agent.drag_to(self.width, x),
agent::Message::DragEnd => self.agent.drag_end(),
agent::Message::Edit(action) => {
self.agent.edit(action);
// 草稿同步给 presenter(切形态不丢;它也是 rail 上唯一允许躺原文的字段)
self.agent_host.set_draft(self.agent.draft.text());
}
agent::Message::Submit => self.submit_agent(),
agent::Message::Stop => self.dispatch(AppCommand::StopAgent),
agent::Message::Confirm(accept) => {
self.agent_host.confirm(accept);
self.drain_agent();
}
agent::Message::Events(events) => {
for event in events {
self.agent_host.apply(event);
}
self.view_dirty = true;
}
agent::Message::Tab { shift } => {
let rail = self.agent_host.rail();
let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(shift, busy, pending);
}
agent::Message::Activate => self.activate_focused(catalog),
agent::Message::Key(event) => return self.on_agent_key(event),
}
// 待确认请求出现/消失时,把键盘焦点挪到(或挪离)确认条的按钮上
self.sync_confirm_focus();
// 档位 / 路由变了就要把「栏可见吗」写回 presenter(未读口径的输入)
self.sync_agent_presenter();
if geometry_changed {
self.view_dirty = true;
self.refresh_view();
}
Task::none()
}
/// 发送:草稿 → `agent_submit` → 抽事件。
///
/// 三种结局各自有落点:发出去(清草稿)/ 后端失败(具名横幅,见 [`App::agent_failure`])/
/// presenter 拒收(日志一行——这三种情形正常情况下被禁用态挡住,够不着)。
fn submit_agent(&mut self) {
let text = self.agent.draft.text();
if text.trim().is_empty() {
return;
}
match self.agent_host.submit(text) {
SubmitOutcome::Sent => {
self.agent.clear_draft();
self.agent_host.set_draft(String::new());
self.drain_agent();
}
SubmitOutcome::Failed(failure) => {
eprintln!("[agent] 提交失败({})", failure.code());
}
SubmitOutcome::Rejected => {
eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
}
}
}
/// 抽干 presenter 的事件队列,**经既有 Message 通道**回流(见模块文档末节)。
pub(crate) fn drain_agent(&mut self) {
let events = self.agent_host.drain();
if events.is_empty() {
return;
}
let _ = self.on_agent(agent::Message::Events(events));
}
/// 把「栏可见吗」写回 presenter(`navigate` + `prefs_mut` 两个窄入口,见 `AgentHost`)。
pub(crate) fn sync_agent_presenter(&mut self) {
let on_page = self.state.route() == &Route::Agent;
let collapsed = self.agent.rail == RailState::Collapsed;
self.agent_host.sync_visibility(on_page, collapsed);
}
/// 失败横幅要显示的东西(`None` = 没有错误)。
///
/// 两个来源都取**展示输入**:具名原因码 → catalog 译文;或 presenter 已脱敏的这一轮原因。
/// 壳在这一层不做任何脱敏(那是 presenter 的`scrub_text`),也不拼中英(那是 catalog 的事)。
pub(crate) fn agent_failure(&self) -> Option<agent::Failure<'_>> {
let catalog = self.lang().catalog();
if let Some(failure) = self.agent_host.last_failure() {
return Some(agent::Failure {
title: TextKey::AgentErrorTitle,
body: catalog.text(failure.body_key()),
});
}
self.agent_host
.rail()
.error
.as_deref()
.map(|body| agent::Failure {
title: TextKey::AgentErrorTitle,
body,
})
}
/// 待确认请求出现 ⇒ 焦点直接落在「允许」;消失 ⇒ 收回输入框。
///
/// **只挪焦点,不替用户打开键盘巡航**(M119 r2 / 复审 P2-2):r1 在这里顺手把
/// `keyboard_mode` 置真,而 `sync_confirm_focus` 是 `on_agent` 的无条件尾巴、`Events` 又由心跳
/// 驱动——于是只要冒出待确认,全局键盘闸就被打开;用户发完那句话接着打下一句,第一个空格
/// 就成了「批准写操作」。现在那道闸只由真实的键盘表态打开(`Tab`,见 `AgentUi::step_focus`;
/// 或者 `on_agent_key` 把按键送进来时用户本来就在键盘巡航)。
///
/// 键盘用户怎么走到确认条:待确认时焦点已经在这两个按钮上,**按一次 `Tab` 点亮焦点环即是
/// 表态**(`step_focus` 里那条 arming 规则:这一次不移动焦点),之后 `Enter`/`Space` 就能裁决。
///
/// 唯一写入口:`on_agent` 末尾(任何会改动 rail 的分支之后都要经过它)。
fn sync_confirm_focus(&mut self) {
let pending = self.agent_host.rail().confirm.is_some();
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
if pending && !on_confirm {
self.agent.focus = Focus::ConfirmAllow;
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
} else if !pending && on_confirm {
self.agent.focus = Focus::Composer;
self.agent.keyboard_mode = false;
}
}
/// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。
///
/// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变,
/// 而路由还能从三条**别的**路径进 `Route::Agent`——左侧导航第五项、`pages::Message::Navigate`、
/// `ULL_START_ROUTE=agent`。脱钩的后果:从导航进独立页时头部出现「Open assistant」(那是
/// 「展开右栗」的意思)、标题旁挂着不化的未读胶囊、`Esc` 也收不走页面。
///
/// 两条对向规则:
/// - **在**独立页上 ⇒ `Detached`(顺带按「拆出即读」清未读);
/// - **不在**独立页上而档位还是 `Detached`(用户从页上点了别的导航)⇒ 会话收回右栗(`dock()`),
/// 否则会话会「两头都没有」:页走了、栏还是 0 宽。
///
/// 调用点只有一个:`App::refresh_view` 的开头。每一次路由变化都会经过它,于是**模型算宽度时**
/// 与**画头部动作时**读到的永远是同一份档位。
pub(crate) fn sync_agent_route(&mut self) {
let on_page = self.state.route() == &Route::Agent;
match (on_page, self.agent.rail) {
(true, RailState::Detached) | (false, RailState::Collapsed | RailState::Expanded) => {}
(true, _) => self.agent.detach(),
(false, RailState::Detached) => self.agent.dock(),
}
}
/// 启动时助手栏要做的三件事(从 `on_boot` 搬出来:那边只许留一行调用,复审 P3-6)。
///
/// 1. **窗口尺寸 adopt**:唯一真源是 `boot.rs` 开窗时用的那一个(`ULL_WINDOW_SIZE` + 最小尺寸
/// 钳位)。`App::with_presenter` 只认识常量 1180×760,不 adopt 的话验收矩阵开 900 / 1520 的窗
/// 时页面还按 1180 排版(窄窗里内容被裁)。
/// 2. **取证夹具**(`ULL_AGENT_*`):先落档位,再**跑一轮真提交**——夹具不造消息,消息只能
/// 由 presenter 产生(M119 的口径,见 `pages/agent/demo.rs`)。
/// 3. **拆出档直接落到独立页**:`ULL_AGENT_RAIL=detached`(以及将来持久化的偏好)时,会话就在
/// 独立页里,启动后不该停在实例页。
pub(super) fn boot_agent(&mut self) {
let (window_size, _source) = crate::renderer::acceptance::window::window_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height),
);
self.width = window_size.0;
self.height = window_size.1;
if let Some((ui, fixture)) = demo::from_env() {
self.agent = ui;
// 先让 presenter 知道「栏现在看得见吗」,再跑提交,未读口径才对
self.sync_agent_presenter();
match fixture {
Fixture::None => {}
Fixture::Turn | Fixture::Unread => {
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
self.drain_agent();
}
Fixture::Armed => {
// 先跑一轮(写操作脚本会挂一个待确认),再按一次 `Tab`:
// 待确认时这一下只 arm、不移动焦点(见 `AgentUi::step_focus` 的 arming 规则)
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
self.drain_agent();
let rail = self.agent_host.rail();
let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(false, busy, pending);
}
Fixture::Focused => {
let rail = self.agent_host.rail();
let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(false, busy, pending);
}
}
}
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
eprintln!("[agent] rail=detached → Route::Agent");
self.state.navigate(Route::Agent);
// 直达独立页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true;
}
}
/// 键盘订阅来的按键(`app/subscription.rs` 把整条键盘事件搬过来,解释权在这一层)。
///
/// 为什么不让订阅层直接翻成三个枚举:那样 `Escape`/`Tab`/`Enter` 的语义(什么状态下该做什么)
/// 会散在订阅表里;这里一眼能看完。
fn on_agent_key(&mut self, event: iced::keyboard::Event) -> Task<Message> {
let iced::keyboard::Event::KeyPressed { key, modifiers, .. } = event else {
return Task::none();
};
let message = match key {
Key::Named(key::Named::Escape) => agent::Message::Escape,
// 折叠档没有会话动作可巡航,`Tab` 不该被助手栏吃掉
Key::Named(key::Named::Tab) if self.agent.rail == RailState::Collapsed => {
return Task::none();
}
Key::Named(key::Named::Tab) => agent::Message::Tab {
shift: modifiers.shift(),
},
// `Activate` 只在软焦点落在**按钮**上时才动手(输入框的 Enter 由
// `text_editor::key_binding` 分流成「发送」,不能在这里重复发一次)。
//
// M119 r1 / P2-2:`Space` 与 `Enter` 等价(键盘用户对确认条的习惯动作是空格)。
// 它不会抢输入框里的空格:`activate_focused` 头一行就是 `keyboard_mode` 闸,
// 而用鼠标点进输入框时那道闸是关的(`AgentUi::edit` 的 `Click` 分支)。
Key::Named(key::Named::Enter | key::Named::Space) => agent::Message::Activate,
_ => return Task::none(),
};
self.on_agent(message)
}
/// `Esc` / `⌄`:展开 → 收起;独立页 → 收回右栗并离开 `Route::Agent`。
fn collapse_agent(&mut self) {
let was_detached = self.agent.rail == RailState::Detached;
self.agent.collapse();
if was_detached {
self.leave_agent_page();
}
}
/// 离开 AI 独立页:能回退就回退(`Detach` 是压栈的),否则回实例页。
fn leave_agent_page(&mut self) {
if self.state.route() != &Route::Agent {
return;
}
if !self.state.back() {
self.state.navigate(Route::Instances);
}
}
/// 键盘 `Enter`:激活软焦点上的按钮(`Tab` 环的顺序见 [`Focus`])。
///
/// 焦点在 [`Focus::Composer`] 上时**什么都不做**:那一条路径由输入框自己的 `key_binding`
/// 负责(按 `Tab` 走到输入框并不等于真的把 iced 焦点交过去,两次发送会打架)。
fn activate_focused(&mut self, catalog: Catalog) {
// 鼠标刚点过输入框(或从没按过 `Tab`)⇒ 这个 Enter 不归助手栏管
if !self.agent.keyboard_mode {
return;
}
let intent = match (self.agent.rail, self.agent.focus) {
(RailState::Collapsed, Focus::Collapse) => Some(agent::Message::Expand),
(RailState::Expanded, Focus::Collapse) => Some(agent::Message::Collapse),
(RailState::Expanded, Focus::Detach) | (RailState::Detached, Focus::Detach) => {
Some(if self.agent.rail == RailState::Detached {
agent::Message::Dock
} else {
agent::Message::Detach
})
}
(_, Focus::ConfirmAllow) => Some(agent::Message::Confirm(true)),
(_, Focus::ConfirmDeny) => Some(agent::Message::Confirm(false)),
(_, Focus::Send) => Some(agent::Message::Submit),
(_, Focus::Stop) => Some(agent::Message::Stop),
// 折叠档没有「拆出」、独立页没有「收起」:焦点环按 `Focus::order` 不会落到这两格,
// 显式列成 None 只是为了让编译器确认这份 intent 表是穷尽的。
(RailState::Collapsed, Focus::Detach)
| (RailState::Detached, Focus::Collapse)
| (_, Focus::Composer) => None,
};
if let Some(message) = intent {
let _ = self.on_agent(message);
} else {
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
eprintln!(
"[agent] Enter on focus={} handled by the composer",
self.agent.focus.name()
);
let _ = catalog;
}
}
}
@@ -0,0 +1,352 @@
//! **树测试①(M119 验收核心)**:AI 右栏在**真实 presenter 状态**下渲染。
//!
//! 走的是同一条用户路径:`Message::Agent(Submit)` → `AgentHost::submit` → `Presenter::agent_submit`
//! → transport → 事件队列 → `Message::Agent(Events)` → `Presenter::apply` → `AgentRail`。
//! **没有一条断言看本地数组**:`transcript` 这类壳自己的会话副本 M119 已经删了,能断言的只剩
//! presenter 的 rail,而 rail 正是右栏画的东西(`pages/agent::spec` 从它借)。
//!
//! 覆盖任务书点名的四件事:
//!
//! | 断言 | 抓得住什么 |
//! |---|---|
//! | 提交后 rail 出现「你 + 助手」两条、正文来自事件的流式增量 | 提交没接到 presenter、或右栏还在画样本会话 |
//! | `busy` 翻真 → 收敛后翻假 | 事件没回流、或流式标记没收敛(输入框会永远禁用) |
//! | 标记已读后未读归零 | `agent_mark_read` 没接、或未读口径没跟着 rail |
//! | 三种失败都给**具名**横幅 | 失败被吞掉(M104 的「静默无语」)|
use iced::widget::text_editor;
use unlucky_design::color::SchemeFlavor;
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{AgentEvent, AppEvent, ChatRole};
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use iced::keyboard::key::{Code, Named, Physical};
use iced::keyboard::{key, Location, Modifiers};
use crate::app::state::presenter::transport::Script;
use crate::app::state::presenter::AgentHost;
use crate::app::Message;
use crate::cli::Options;
use crate::shell::agent::{Focus, Message as AgentMessage, RailState};
use crate::theme::Families;
use super::App;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘的 App(mock 后端 + 内存设置桥 + **本脚本的** presenter 句柄)。
fn app_with(script: Script) -> App {
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
);
app.agent_host = AgentHost::with_script(script);
// 换句柄 = 可见性还没写回去;`refresh_view` 是那条单点同步
app.refresh_view();
app
}
/// 走真消息提交(用户路径:填草稿 → 点发送)。
fn submit(app: &mut App, text: &str) {
app.agent.draft = text_editor::Content::with_text(text);
let _ = app.update(Message::Agent(AgentMessage::Submit));
}
/// **提交 → 事件回流 → 右栏出现这一轮 → 标记已读归零**(任务书第 2 条)。
///
/// 抓得住:①`Submit` 没接 `agent_submit`(rail 空);②事件没经 Message 通道回流(rail 只有半截);
/// ③`expand` 没调 `agent_mark_read`(未读不清);④右栏读的还是壳自己的副本。
/// 抓不住:气泡在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/`)。
#[test]
fn a_submitted_turn_lands_in_the_rail_and_reading_it_zeroes_unread() {
let mut app = app_with(Script::Qa);
// 出厂是折叠档 ⇒ presenter 认为「用户没在看着」,到达的助手消息应计未读
assert_eq!(app.agent.rail, RailState::Collapsed);
assert_eq!(app.agent_host.rail().unread, 0, "空会话没有未读");
submit(&mut app, "Which instance should I start?");
let rail = app.agent_host.rail();
assert_eq!(
rail.messages.len(),
2,
"一条「你」+ 一条助手(事件产生,不是本地回显)"
);
assert_eq!(rail.messages[0].role, ChatRole::User);
assert_eq!(rail.messages[0].text, "Which instance should I start?");
assert_eq!(rail.messages[1].role, ChatRole::Assistant);
assert!(
rail.messages[1].text.contains("Fabric"),
"正文来自流式增量:{:?}",
rail.messages[1].text
);
assert!(!rail.messages[1].streaming, "Qa 脚本会收敛");
assert!(!rail.busy, "收敛之后不忙(发送键要能再按)");
assert_eq!(rail.unread, 1, "折叠态到达的一条助手消息 = 一条未读");
// 右栏画的正是这份 rail(`pages/agent::spec` 直接借它)——构图不 panic
let _ = app.view();
// 打开栏 = 读过:`agent_mark_read`
let _ = app.update(Message::Agent(AgentMessage::Expand));
assert_eq!(app.agent_host.rail().unread, 0, "标记已读后未读归零");
assert_eq!(app.agent_host.rail().messages.len(), 2, "已读不改会话");
}
/// **busy 正确翻转**:流式中忙 → 后端收敛后不忙。
///
/// 抓得住:`busy` 还由壳自己算(那它不会跟 rail 走);`Events` 通道被当成「只追加消息」而丢掉
/// `MessageFinished`(输入框会永远禁用——这是最要命的坏法之一)。
#[test]
fn busy_flips_with_the_backend_events() {
let mut app = app_with(Script::Streaming);
submit(&mut app, "hello");
assert!(app.agent_host.rail().busy, "流式没收敛前输入要禁用");
// 后端收敛:一条 `MessageFinished` 经同一条 Message 通道回流
let index = app.agent_host.rail().messages.len() - 1;
let _ = app.update(Message::Agent(AgentMessage::Events(vec![AppEvent::Agent(
AgentEvent::MessageFinished { index },
)])));
assert!(!app.agent_host.rail().busy, "收敛之后必须能再发");
}
/// 写操作确认:rail 上有待确认 ⇒ 忙;裁决后收敛并记一条审计。
#[test]
fn a_pending_write_op_is_visible_and_decidable() {
let mut app = app_with(Script::WriteOp);
submit(&mut app, "把下载并发改小");
let rail = app.agent_host.rail();
let request = rail.confirm.as_ref().expect("要挂一个待确认请求");
assert_eq!(request.tool, "set_config");
assert!(
rail.busy,
"等确认也算忙(否则用户能在一轮写操作中间再发一句)"
);
let _ = app.view();
let _ = app.update(Message::Agent(AgentMessage::Confirm(true)));
assert!(app.agent_host.rail().confirm.is_none(), "裁决后清空");
assert!(!app.agent_host.rail().busy);
assert_eq!(app.agent_host.audit_len(), 1, "裁决要进审计(YOLO 下也是)");
}
/// 失败态①:**AI 端点不可达** ⇒ 具名横幅(不是静默,也不是把后端原文糊上屏)。
#[test]
fn an_unreachable_endpoint_shows_a_named_error() {
let mut app = app_with(Script::Unreachable);
submit(&mut app, "hello");
let failure = app.agent_failure().expect("提交失败必须给横幅");
// 文案跟着**生效语言**走(`LangState` 在这台机器上探到 zh-CN,硬编码英文会假红)
let effective = app.lang().catalog();
assert_eq!(failure.title, Key::AgentErrorTitle, "标题是 catalog 键");
assert_eq!(failure.body, effective.text(Key::AgentErrorUnavailable));
assert!(!failure.body.trim().is_empty());
assert!(
app.agent_host.rail().messages.is_empty(),
"失败不产生消息:不许假装有回复"
);
let _ = app.view();
}
/// 失败态②:**未实现路由** ⇒ 另一句具名错误(两种失败不能糊成一句)。
#[test]
fn an_unimplemented_route_shows_its_own_named_error() {
let mut app = app_with(Script::NotImplemented);
submit(&mut app, "hello");
let failure = app.agent_failure().expect("必须给横幅");
let effective = app.lang().catalog();
assert_eq!(failure.body, effective.text(Key::AgentErrorNotImplemented));
// 两条原因码要两句话(中英两套都得分开),不是一句万能错误
for lang in [Lang::En, Lang::ZhCn] {
let catalog = Catalog::of(lang);
assert_ne!(
catalog.text(Key::AgentErrorNotImplemented),
catalog.text(Key::AgentErrorUnavailable),
"{} 下两条原因码糊成一句了",
lang.tag()
);
}
}
/// 失败态③:**这一轮回复中途断流** ⇒ 横幅用 presenter 已脱敏的那句原因,已到的增量保留。
#[test]
fn a_turn_level_failure_keeps_the_partial_answer_and_the_reason() {
let mut app = app_with(Script::FailsMidStream);
submit(&mut app, "读一下日志");
let rail = app.agent_host.rail();
// 用户 + 助手(流式占位)+ 系统条目(`Failed` 会额外追加一条,错误也要能回翻)
assert_eq!(rail.messages.len(), 3, "断流不撤销已到的增量");
assert!(
rail.messages[1].text.contains("日志"),
"已到的增量要留着:{:?}",
rail.messages[1].text
);
assert!(!rail.busy, "断流要收敛掉流式标记");
let failure = app.agent_failure().expect("`Failed` 事件要立横幅");
assert!(
failure.body.contains("read timeout"),
"横幅正文是 presenter 脱敏后的原因:{}",
failure.body
);
}
/// 输入框的草稿会同步给 presenter(切形态不丢的语义在 presenter 那边)。
/// **P2-1**:默认配置下后端是脚本化 rig ⇒ 视图必须知道「这是模拟」(界面据此常驻标记)。
///
/// 抓得住:`is_simulated` 被写死成 `false`、或者视图拿不到这个事实(用户就会以为模型接上了)。
/// 抓不住:标记在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/08-*`)。
#[test]
fn the_default_backend_reports_itself_as_simulated() {
let app = app_with(Script::Qa);
assert!(
app.agent_host.is_simulated(),
"脚本化 rig 必须自报「模拟」,否则界面就能假装模型接上了"
);
// 空会话(最容易被误读成一屏)也要能拿到:视图入参 `Session.simulated` 就是它
let session = crate::shell::agent::Session {
agent: &app.agent,
rail: app.agent_host.rail(),
failure: app.agent_failure(),
simulated: app.agent_host.is_simulated(),
};
assert!(session.simulated);
}
#[test]
fn typing_syncs_the_draft_into_the_presenter() {
let mut app = app_with(Script::Qa);
app.agent.draft = text_editor::Content::with_text("half typed");
let _ = app.update(Message::Agent(AgentMessage::Edit(
text_editor::Action::Edit(text_editor::Edit::Insert('!')),
)));
assert_eq!(
app.agent_host.rail().draft,
app.agent.draft.text(),
"编辑器里是什么,rail 上就该是什么(同一个串,不是它自己的副本)"
);
assert!(app.agent_host.rail().draft.contains('!'), "改动真的进去了");
// 发送成功后草稿两边都清空
let _ = app.update(Message::Agent(AgentMessage::Submit));
assert!(app.agent.draft_is_blank(), "编辑器清空");
assert!(app.agent_host.rail().draft.is_empty(), "presenter 侧也清空");
}
/// 手搓一条按键事件(订阅层搬过来的就是它;`on_agent_key` 只看 `KeyPressed`)。
fn press(named: key::Named) -> iced::keyboard::Event {
let key = key::Key::Named(named);
iced::keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key: Physical::Code(Code::Enter),
location: Location::Standard,
modifiers: Modifiers::default(),
text: None,
repeat: false,
}
}
/// **P2-2 的回归闸**:不碰鼠标、只用键盘消息,能完成一次写操作确认。
///
/// 为什么这条重要:确认条原本只有鼠标路径,而待确认时 `busy` 锁着发送、`Stop` 又走主后端
/// (清不掉 presenter 的 pending)——键盘用户会在这一步**永久卡死**。
///
/// 抓得住:①焦点没主动落到确认条(`Enter` 够不着);②`Enter` / `Space` 没映射成 `Activate`;
/// ③`Tab` 环里没有确认条那两格;④裁决完焦点没收回输入框(下一句话打不进去)。
/// 抓不住:焦点环在像素上的样子(那要出图,见 `DevDocs/artifacts/m119/09-*`)。
#[test]
fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() {
let mut app = app_with(Script::WriteOp);
submit(&mut app, "把下载并发改小");
assert!(
app.agent_host.rail().confirm.is_some(),
"先要有一个待确认请求"
);
// ① 焦点主动落在「允许」(阻塞态),但**键盘闸不替用户打开**(复审 r2 / P2-2)
assert_eq!(
app.agent.focus,
Focus::ConfirmAllow,
"焦点要落在可操作元素上"
);
assert!(
!app.agent.keyboard_mode,
"塔台 r2:不许自动 arm——自动 arm 会让用户随手敲的空格变成「批准」"
);
// ② 第一次 Tab = 「我要用键盘」的表态:只点亮焦点环,**不移动**焦点
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
assert!(app.agent.keyboard_mode, "Tab 之后才算 armed");
assert_eq!(
app.agent.focus,
Focus::ConfirmAllow,
"arming 的那一次不移动焦点(否则 Enter 会反着裁决)"
);
// ③ Enter = 允许
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
assert!(
app.agent_host.rail().confirm.is_none(),
"Enter 必须真的裁决掉"
);
assert_eq!(app.agent_host.audit_len(), 1, "裁决进审计");
assert_eq!(app.agent.focus, Focus::Composer, "裁决完焦点收回输入框");
assert!(!app.agent.keyboard_mode, "阻塞态结束就不再亮焦点环");
// ④ 第二轮走「Tab 点环 → Tab 走到拒绝 → Space 触发」:键盘用户不必背 Enter 是哪一个
submit(&mut app, "那把并发改回来");
assert_eq!(app.agent.focus, Focus::ConfirmAllow);
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
assert_eq!(app.agent.focus, Focus::ConfirmDeny, "Tab 要能走到「拒绝」");
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
assert!(app.agent_host.rail().confirm.is_none(), "Space 也要能裁决");
assert_eq!(app.agent_host.audit_len(), 2);
}
/// **复审 r2 / P2-2 的反向回归**:没 armed 的时候,空格与回车都不许裁决写操作。
///
/// 复审用临时用例复现的路径:WriteOp 提交后(r1 无条件 arm),用户接着打下一句,第一个空格就成了
/// 「允许」。真实场景里编辑器仍持 iced 焦点,所以这条不是理论问题。
///
/// 抓得住:谁把 `sync_confirm_focus` 里的 `keyboard_mode = true` 加回来;谁把 `Space`/`Enter` 的
/// `Activate` 绕到 `keyboard_mode` 闸之外。
/// 抓不住:iced 组件树里空格到底有没有进输入框(那要真窗口,出图另算)——这里只断言「没裁决」。
#[test]
fn a_stray_space_or_enter_never_approves_a_pending_write() {
let mut app = app_with(Script::WriteOp);
submit(&mut app, "把下载并发改小");
assert!(app.agent_host.rail().confirm.is_some());
assert!(!app.agent.keyboard_mode, "待确认不该自动 arm");
// 用户接着打下一句:先敲一个字符(编辑器自己消化),再敲空格
let _ = app.update(Message::Agent(AgentMessage::Edit(
text_editor::Action::Edit(text_editor::Edit::Insert('a')),
)));
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
assert!(
app.agent_host.rail().confirm.is_some(),
"未 armed 时空格不许批准写操作"
);
assert_eq!(app.agent_host.audit_len(), 0, "也不许留下审计");
// 回车同理:未 armed 时它归编辑器(发送键),不归确认条
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
assert!(
app.agent_host.rail().confirm.is_some(),
"未 armed 时回车不许批准写操作"
);
assert_eq!(app.agent_host.audit_len(), 0);
}
@@ -0,0 +1,81 @@
//! M116:窗口圆角的下发落点(`impl App::apply_window_corner`)。
//!
//! ## 为什么单独成文件
//!
//! 圆角与整窗 α 是**两条独立的管线**,只是都必须拿到 HWND、都在同一个时点下发:
//!
//! | 管线 | 判据 | 落点 | 键 |
//! |---|---|---|---|
//! | 整窗 α(D36) | `platform::effective_window_alpha` | `SetLayeredWindowAttributes` | `appearance.window_opacity` |
//! | 圆角(M116) | `platform::plan_corner` | `DwmSetWindowAttribute` | `appearance.window_corner` |
//!
//! 合在一个函数里会让「α 变了要不要重设圆角」这种问题变成实现细节;分开之后两个判据各自可测。
//!
//! ## 日志为什么是**一行、六栏**
//!
//! `corner=policy=… source=dwm|square os=… excluded_reason=… radius=system pref=… readback=… frame=… layered=…`
//!
//! 用户报的现象是「别的窗口都有圆角,只有我们的没有」。没有这一行,事后无法区分
//! 「没生效」「生效了但看不见」「这台系统根本没有这个能力」;`frame` / `layered` 两栏则是
//! 「圆角为什么不生效」的两个现场读数:`frame=`(窗口形状)与 `layered=`(整窗 α 引入的分层位)。
//!
//! ⚠️ **产品窗口不是 `WS_POPUP`**(M116 r4 复审订正):winit 在 `decorations = false` 下给的是
//! `WS_CAPTION|WS_BORDER|WS_CLIPSIBLINGS|WS_SYSMENU`(可缩放时再加 `WS_THICKFRAME` 与 min/max 键),
//! `WS_POPUP` 只在**带 owner** 的弹出窗才加;无边框观感来自 `WM_NCCALCSIZE` 把客户区铺满。
//! 所以真进程日志里 `frame=` 应是 `overlapped`(关掉 min/max 则 `other`),**永远不会是 `popup`**。
//! 真正让系统忽略圆角请求的是**整窗 α 补的 `WS_EX_LAYERED`**(`layered=on`)。
//!
//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
//! (M116 取消了 8/12/16 固定档)。
use crate::platform;
use crate::platform::win32::corner::{self, CornerApplied};
use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome};
use super::super::state::App;
impl App {
/// 把当前圆角策略下发到窗口,并打一行可分辨的日志。
///
/// ## 去重(M116 r1 复审 P2-4)
///
/// 设置页的滑条(整窗 α / 面板 α / 暗化)每动一下都会走一次外观变更,而<b>圆角与那些键无关</b>。
/// 判定完全没变时这里**直接返回**:不动窗口、不打日志。判据是
/// [`corner::needs_redispatch`](纯函数,有「同一策略 N 次只记 1 行」的测试),
/// 指纹只含「策略 / 来源 / 原因码 / 有没有句柄」,不含只有真下发才拿得到的读数。
///
/// 没有句柄时**也打日志**(`window=none`):不能让「没生效」只体现为屏幕上没变化。
/// 句柄从无到有会让指纹变化 ⇒ 首次拿到 HWND 时必然重下一次。
pub(crate) fn apply_window_corner(&mut self, context: &str) {
let policy = self.appearance.window_corner();
let plan = platform::plan_corner(
policy,
platform::current_windows_version(),
self.appearance.backend,
);
// 整窗 α < 1 ⇒ `window_alpha::dispatch` 会给窗口补 `WS_EX_LAYERED`;系统因此忽略圆角请求
// (tower 裁定 A / D47)。这里只记**事实**:α 是否把我们自己的分层位打开了。
let alpha_layered = self.appearance.window_opacity < 1.0;
let suppressed_by = alpha_layered.then_some("window_alpha");
let next = CornerApplied::new(policy, &plan, self.hwnd.is_some(), alpha_layered);
if !corner::needs_redispatch(self.corner_applied.as_ref(), &next) {
return;
}
self.corner_applied = Some(next);
// 行的形状只有一处(`corner::format_line`),因为那里能在没有窗口的环境里被单测钉住。
// 关键的三栏必须在同一行:`pref=`(请求发出去了)、`layered=`(分层位在不在,来自
// 整窗 α)、`frame=`(窗口形状 = `overlapped`,不是 `popup`)——否则以后只能猜是哪一条。
let (outcome, style) = match self.hwnd {
Some(hwnd) => (apply_corner(hwnd, &plan).describe(), style_summary(hwnd)),
None => (
CornerOutcome::NoWindow.describe(),
"frame=? layered=?".to_owned(),
),
};
eprintln!(
"{}",
corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by)
);
}
}
@@ -0,0 +1,26 @@
//! 启动期的取证环境变量解析(`ULL_START_ROUTE`)。
//!
//! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线):
//! **纯函数、无状态**,只是取值表长到了七条。
use unlucky_presenter::router::Route;
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
///
/// 为什么是环境变量:`crates/app-iced/src/cli.rs` 不在 M93 的 scope,而外观矩阵必须在**设置页**上
/// 出图。语义与 `ULL_APPEARANCE_*` / `ULL_TICK_TRACE` 一致:只影响这一次进程。
pub(super) fn env_start_route() -> Option<Route> {
match std::env::var("ULL_START_ROUTE").ok()?.trim() {
"instances" => Some(Route::Instances),
"download" => Some(Route::Download),
"logs" => Some(Route::Logs),
"settings" => Some(Route::Settings),
// M104:AI 独立页也要能直达(三态矩阵的 Detached 那张)
"agent" => Some(Route::Agent),
// M109:内容库(截图矩阵要能直达;页面内的四条侧栏不在路由里)
"content" => Some(Route::Content),
// M114:账户页的 D30 矩阵要能直达(`cli.rs` 不在本 mission 的 scope)
"accounts" => Some(Route::Accounts),
_ => None,
}
}
+143
View File
@@ -0,0 +1,143 @@
//! 节拍:推进一拍 / 派发指令 / 抽干事件(M119 从 `update.rs` 拆出来守 400 行硬线;纯搬不改)。
//!
//! ## 为什么这三件必须在一起
//!
//! `advance` → `pump` → `apply` → `refresh_view` 是同一条链:时间推进、事件抽干、状态落地、
//! 视图重建。拆开看每一段都无害,合起来才是「presenter 的异步事件在 UI 侧怎么被消费」的答案。
//!
//! ## 两个时钟的相位(M90 r3 / P2-2 的老账,代码里有细节)
//!
//! | 场景 | 时钟 | 周期 |
//! |---|---|---|
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms |
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s |
//!
//! ## M119 增加了什么
//!
//! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干,
//! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。
use std::time::{Duration, Instant};
use unlucky_presenter::dto::AppCommand;
use unlucky_presenter::theme::CONFIG_POLL_TICKS;
use super::super::state::TICK_MS;
use super::*;
impl App {
/// 推进一拍:后端消化时间 → 抽干事件 → 过期 toast → 偶发重读配置文件。
///
/// `elapsed` 是真实流逝时间(毫秒级抖动是正常的:`MockBackend` 的脚本按累计时长走)。
pub(in crate::app) fn advance(&mut self, elapsed: Duration) {
self.backend.tick(elapsed);
self.pump();
self.state
.toasts
.expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX));
// 设备码/授权码的倒计时(M114):等待态每秒都要重画一次,否则「剩 900s」永远不动,
// 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
self.view_dirty = true;
}
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
let ticks = u64::try_from(ticks).unwrap_or(u64::MAX).max(1);
let bucket_before = self.ticks / 30;
self.ticks += ticks;
let bucket_after = self.ticks / 30;
// 外部(AI / CLI)改了配置文件:主题跟着换,其余外观项等玻璃/背景 mission。
//
// 判据用**跨越**而不是 presenter 的 `config_poll_due(ticks)`(整除):两个时钟的相位不同
// (帧节拍按 33 ms 累加、空闲心跳按 30 拍累加),整除判定一旦错过一次就**再也命中不了**
// ——实测 ticks=137 之后每拍 +30 永远不整除,配置轮询静默死掉。间隔真源仍是
// `CONFIG_POLL_TICKS`(30 拍 × 33 ms ≈ 1 秒)。截图通道不轮询(D30 可复现)。
let poll_due = self.ticks >= self.config_due_at;
if poll_due {
// 追到「当前 ticks 之后的第一个间隔点」,避免心跳把相位拖飞
self.config_due_at = self.ticks + CONFIG_POLL_TICKS;
}
let reloaded =
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
if reloaded {
eprintln!("[settings] 配置文件变了,重读主题 + 外观");
self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
self.reload_appearance();
}
if self.view_dirty {
self.refresh_view();
}
// 取证开关(`ULL_TICK_TRACE=1`):每跨过 30 拍(≈1 秒)打一行。
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
eprintln!(
"[tick] n={} frames={} refreshes={} clock={}",
self.ticks,
self.frames,
self.refreshes,
if self.needs_frames() {
"frames"
} else {
"idle-1hz"
}
);
}
}
/// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。
pub(in crate::app) fn dispatch(&mut self, command: AppCommand) {
self.backend.dispatch(command);
// 观察窗:命令发出后 3 秒内保持帧订阅,等后端把第一条事件交回来
// (真 host 是异步的,派发瞬间状态还没变;不设这个窗口,空闲态的帧订阅永远不会打开)。
self.watch_until = Some(Instant::now() + WATCH_WINDOW);
self.pump();
}
/// 抽干事件队列并逐条 `apply`(**唯一**的状态写入口)。
pub(in crate::app) fn pump(&mut self) {
// M119:AI 助手的事件队列与 core 的事件队列**同拍**抽干(顺序无关:两个队列互不依赖)。
// 抽出来的这批事件经 `Message::Agent(Events)` 回流,见 `update/agent.rs`。
self.drain_agent();
let events = self.backend.drain_events();
if events.is_empty() {
return;
}
for event in events {
self.state.apply(event);
}
self.view_dirty = true;
self.auto_select();
self.refresh_view();
}
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
///
/// 只做一次:用户点「返回」回到列表后,再刷新实例列表也不会把他弹回详情页。
/// 选中这件事走的是 `AppState::navigate`(路由栈),不是本地选中字段——于是「当前选中」
/// 永远等于「路由栈顶」,用户点返回时栈语义自然成立。
pub(in crate::app) fn auto_select(&mut self) {
if self.auto_selected || self.state.route() != &Route::Instances {
return;
}
let Some(first) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return;
};
self.auto_selected = true;
self.state.navigate(Route::Detail(first));
self.view_dirty = true;
}
}
+339
View File
@@ -0,0 +1,339 @@
//! 内容库页的消息落点(M109):`pages::Message::Content(..)` 的穷尽分发。
//!
//! ## 两件事分开(与 `update/download.rs` 同一条纪律)
//!
//! | 意图 | 谁改状态 | 派指令吗 |
//! |---|---|---|
//! | 关键词草稿 / 选中项目 / 目标实例 | `App::content`(页面内选择) | 不派 |
//! | 检索条件(类型/来源/排序/版本/加载器/关键词提交/翻页/重试) | `AppState::content`(presenter) | 派 `SearchContent` |
//! | 装入 | 谁都不改 | 派 `InstallContent` |
//!
//! 「装入」不改本地状态是关键:装上的证据是 `InstallProgress` 事件回流到安装面板
//! (`pages/download::install_panel`),页面自己不做乐观回显。
//!
//! ## 为什么条件写进 presenter 再派
//!
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
use unlucky_presenter::dto::{
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
ImportRequestView, PackCommand,
};
use crate::pages::content::{Blocker, Content};
use super::*;
impl App {
/// 进入内容库页时补一次检索(首个用户动作不该是「再点一下搜索」)。
///
/// 判据是 `AppState::content` 里还没有结果、也没有请求在飞:重复进出不重复打网络。
pub(super) fn ensure_content_loaded(&mut self) {
if self.state.content.results.is_some() || self.state.content.pending {
return;
}
let query = ContentQuery::first(self.content.kind);
self.search_content(query);
}
/// 页面消息(穷尽分发:漏一支报 E0004)。
pub(super) fn on_content(&mut self, message: pages::content::Message) -> Task<Message> {
match message {
pages::content::Message::SetKind(kind) => {
// 换类型 = 换一份条件(facet 全变):选中项作废,条件回到该类型的首页
self.content.kind = kind;
self.content.selected = None;
let mut query = ContentQuery::first(kind);
// 保留用户已经选好的来源/排序/版本/加载器:换成默认值会让筛选条自己跳回去
query.source = self.state.content.query.source;
query.sort = self.state.content.query.sort;
query.game_version = self.state.content.query.game_version.clone();
query.loader = self.state.content.query.loader;
self.search_content(query);
}
pages::content::Message::QueryChanged(text) => {
self.content.draft = text;
self.view_dirty = true;
}
pages::content::Message::Search => {
let mut query = self.state.content.query.clone();
query.text = self.content.draft.trim().to_string();
query.offset = 0;
self.search_content(query);
}
pages::content::Message::SetSource(source) => {
if source == ContentSource::Curseforge && !self.curseforge_available() {
// 筛选条那条本来就是禁用的;这里再挡一次(防绕过 UI 的消息)
return Task::none();
}
self.with_query(|query| query.source = source);
}
pages::content::Message::SetSort(sort) => self.with_query(|query| query.sort = sort),
pages::content::Message::SetVersion(version) => {
self.with_query(|query| query.game_version = version);
}
pages::content::Message::SetLoader(loader) => {
self.with_query(|query| query.loader = loader)
}
pages::content::Message::Select(id) => {
self.content.selected = Some(id);
self.view_dirty = true;
}
pages::content::Message::SetTarget(id) => {
self.content.target = Some(id);
self.view_dirty = true;
}
pages::content::Message::LoadMore => {
let next = self.state.content.query.next_page();
self.search_content(next);
}
pages::content::Message::Retry => {
let mut query = self.state.content.query.clone();
query.offset = 0;
self.search_content(query);
}
pages::content::Message::Install => {
if let Some(request) = self.content_install_request() {
self.dispatch(AppCommand::InstallContent(Box::new(request)));
}
}
// M113: import/export —— 导入/导出区(细节在 `on_pack`)
pages::content::Message::Pack(message) => self.on_pack(message),
}
Task::none()
}
/// 导入/导出区的消息(M113)。
///
/// 两条口径:
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
PackMessage::Dropped(path) => {
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
self.content.pack.draft = path.display().to_string();
self.content.pack.hover = false;
self.view_dirty = true;
}
PackMessage::Hover(hover) => {
self.content.pack.hover = hover;
self.view_dirty = true;
}
PackMessage::PathChanged(path) => {
self.content.pack.draft = path;
self.view_dirty = true;
}
PackMessage::SetInstance(instance) => {
self.content.pack.instance = instance;
self.view_dirty = true;
}
PackMessage::SetInstanceName(name) => {
self.content.pack.instance_name = name;
self.view_dirty = true;
}
PackMessage::SetFormat(format) => {
self.content.pack.format = format;
self.view_dirty = true;
}
PackMessage::SetExportName(name) => {
self.content.pack.export_name = name;
self.view_dirty = true;
}
PackMessage::SetExportVersion(version) => {
self.content.pack.export_version = version;
self.view_dirty = true;
}
PackMessage::SetOutput(path) => {
self.content.pack.output = path;
self.view_dirty = true;
}
PackMessage::ToggleConflict(path) => {
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
{
None
} else {
Some(path)
};
self.view_dirty = true;
}
PackMessage::OpenExport => {
self.content.pack.export_open = true;
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
if self.content.pack.export_name.trim().is_empty() {
self.content.pack.export_name =
self.content.pack.instance.clone().unwrap_or_default();
}
self.view_dirty = true;
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import => {
if let Some(request) = self.pack_import_request(false) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档)
if let Some(request) = self.pack_import_request(true) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false`
/// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。
fn pack_import_request(&self, overwrite: bool) -> Option<ImportRequestView> {
let draft = self.content.pack.draft.trim();
if draft.is_empty() {
return None;
}
let name = self.content.pack.instance_name.trim();
Some(ImportRequestView {
pack: std::path::PathBuf::from(draft),
instance: self.content.pack.instance.clone(),
instance_name: (!name.is_empty()).then(|| name.to_string()),
overwrite,
})
}
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
///
/// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
fn pack_export_request(&self) -> Option<ExportRequestView> {
let instance = self.content.pack.instance.clone()?;
Some(pages::content::request_export(
&self.content.pack,
&instance,
))
}
/// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。
pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) -> Task<Message> {
// 拖入是「现在就要看到结果」的动作:先把用户带到内容库页,再落草稿
self.state
.navigate(unlucky_presenter::router::Route::Content);
self.on_pack(pages::content::PackMessage::Dropped(path));
self.refresh_view();
Task::none()
}
/// 拖拽悬停状态(有文件在窗口上方时高亮入口)。
pub(super) fn on_pack_hover(&mut self, hover: bool) -> Task<Message> {
// 状态没变就不重画:winit 对同一批文件会连发几次 hover 事件
let changed = self.content.pack.hover != hover;
self.on_pack(pages::content::PackMessage::Hover(hover));
if changed {
self.refresh_view();
}
Task::none()
}
/// 改一个筛选字段并**立刻重查**(第一页)。
fn with_query(&mut self, change: impl FnOnce(&mut ContentQuery)) {
let mut query = self.state.content.query.clone();
change(&mut query);
query.offset = 0;
self.search_content(query);
}
/// 提交一次检索:条件先落进 presenter(迟到事件的判据),再派指令。
fn search_content(&mut self, query: ContentQuery) {
self.state.set_content_query(query.clone());
self.view_dirty = true;
self.dispatch(AppCommand::SearchContent(query));
}
/// CF 源可用吗(结果里带回的那一位;还没拉过 ⇒ `false`)。
fn curseforge_available(&self) -> bool {
self.state
.content
.results
.as_ref()
.is_some_and(|results| results.curseforge)
}
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
///
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
/// 两者语义完全不同,撞名会被编译器拦下(本轮 rebase 撞过)。
///
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
fn content_install_request(&self) -> Option<ContentInstallRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
if !model.can_install() {
// 有原因就打印出来(取证用);没有选中项时什么都不做
for reason in model.blocker_reasons(catalog) {
eprintln!("[content] 装入被挡下:{reason}");
}
if model.blockers.contains(&Blocker::NeedsPipeline) {
eprintln!("[content] 整合包装入未实现(M111)");
}
return None;
}
let hit = model.selected_hit()?;
let target = model
.targets
.iter()
.find(|target| Some(&target.id) == model.target.as_ref())?;
Some(ContentInstallRequest {
project: hit.id.clone(),
title: hit.title.clone(),
// 整合包走不到这里:`ContentKind::Modpack` 的 `dir()` 是 `None` ⇒ 必然带
// `Blocker::NeedsPipeline` ⇒ `can_install()` 为假。所以这里就是「单文件」那一类。
kind: model.kind,
source: hit.source,
instance: target.id.clone(),
game_version: target.mc_version.clone(),
loader: target.loader,
})
}
}
+381
View File
@@ -0,0 +1,381 @@
//! 下载页在 app 层的那几支(M94;M105 补上安装选项)。
//!
//! 为什么单独一个文件:M93(外观三件套接线)合入后 `update.rs` 已经贴到 400 行,下载页再往里塞
//! 几个 arm 就顶破硬线;拆出来是既不动 M93 的代码、又能守住 ≤400 的办法(`app/update/**` 属本
//! mission scope)。
//!
//! ## 各支各自的纪律(M105 后的口径)
//!
//! | 消息 | 落点 | 为什么 |
//! |---|---|---|
//! | [`pages::Message::SelectVersion`] | 只改**草稿** | 选中是页面内状态,不是业务命令;顺手清掉加载器版本(换了原版版本,旧版本号几乎必然不适用) |
//! | [`pages::Message::SetLoader`] | 改草稿 + 必要时派 `ListLoaderVersions` | 加载器版本列表来自官方元数据,页面不能自己编 |
//! | [`pages::Message::SetLoaderVersion`] / `SetInstanceName` | 只改草稿 | 纯输入 |
//! | [`pages::Message::InstallVersion`] | `AppCommand::CreateInstance(草稿)` | **确认才开工**(带加载器 / 加载器版本 / 实例名) |
//! | [`pages::Message::CancelInstall`] | `AppCommand::CancelInstall` | 见 `host/dispatch.rs`:真停 + 作废旧代次 |
//! | [`pages::Message::SetCategory`] | 写分类 → 重建 → `AppCommand::ListVersions` | 见下 |
//!
//! [「刷新下载源」](pages::Action::RefreshSources) 也在这里:它不是占位动作,具体分类 → 重拉该分类、
//! 「全部」→ 让 host 重扫一遍(`RefreshAll`)。
//!
//! ## 分类为什么既写视图模型、又派指令
//!
//! 真 host 的 `AppCommand::ListVersions(_)` 会丢掉分类参数、回全量清单(复审 P2-1),所以**过滤在
//! 本页做**:写进视图模型 → 立刻 `refresh_view()`(它会把 category 带回去、按同一谓词过滤行)→
//! 再派指令。这不是「乐观回显」:写的是**过滤条件本身**,chip 与列表用同一个
//! [`Category::matches`](crate::pages::Category),于是不可能出现「chip 说正式版、列表是全量」的两份真相。
//!
//! ## 草稿为什么住在这里而不是页面模型里
//!
//! `refresh_view()` 每次事件都把 `self.view` 整棵重建(连安装进度都算),页面存不住「用户正在填、
//! 还没确认」的东西。于是草稿放 `App::download_draft`,与分类筛选同一个做法:
//! 改草稿 → `view_dirty = true` → `refresh_view()`。
use unlucky_presenter::dto::CreateRequest;
use super::*;
impl App {
/// 下载页的消息。**处理了返回 `true`**(`on_page` 里就不用再写一遍这些 arm)。
pub(super) fn on_download(&mut self, message: &pages::Message) -> bool {
match message {
pages::Message::SelectVersion(id) => {
if self.download_draft.selected.as_deref() != Some(id.as_str()) {
// 换了原版版本:加载器版本是**按 (加载器, 原版版本)** 取的(见
// `CoreEvent::LoaderVersions` 的缓存键),旧的版本号留着只会误导。
self.download_draft.loader_version.clear();
}
self.download_draft.selected = Some(id.clone());
self.mark_download_dirty();
// 选了加载器版本之后换原版版本,列表要跟着换——已经查过的直接从快照读,不重复派。
self.ensure_loader_versions();
}
pages::Message::SetLoader(loader) => {
self.download_draft.loader = *loader;
self.download_draft.loader_version.clear();
self.mark_download_dirty();
self.ensure_loader_versions();
}
pages::Message::SetLoaderVersion(version) => {
self.download_draft.loader_version = version.clone();
self.mark_download_dirty();
}
pages::Message::SetInstanceName(name) => {
self.download_draft.instance_name = name.clone();
self.mark_download_dirty();
}
pages::Message::InstallVersion(id) => {
// 防守:按钮带的是**页面上那一行**的 id,草稿里的选中项理论上与它一致;
// 万一不一致(例如列表刚换过、用户手快),不能让 A 版本的加载器版本落到 B 版本上。
if self.download_draft.selected.as_deref() != Some(id.as_str()) {
self.download_draft.selected = Some(id.clone());
self.download_draft.loader_version.clear();
}
let request = self.install_request(id);
self.dispatch(AppCommand::CreateInstance(request));
}
pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall),
pages::Message::SetCategory(category) => {
self.view.download.category = *category;
self.view_dirty = true;
self.refresh_view();
if let Some(kind) = category.command() {
self.dispatch(AppCommand::ListVersions(kind));
}
}
// M107:查询词落在快照里(无状态视图的唯一存活方式),再走同一条重建路径过滤。
pages::Message::SetVersionQuery(query) => {
self.rebuild_after_query(query.clone());
}
pages::Message::ClearVersionQuery => {
self.rebuild_after_query(String::new());
}
// M107:主版本桶的选中是纯视图态,不重建、不派命令(同 `SelectVersion`)。
//
// **搜索期间点桶 = 「我就是要看这个桶」**(复审 r2 P2-1(b)):搜索让第二级失效(P2-1 的
// 一致性放宽),如果这里只改选择而不清查询,用户会看到「点了没反应、清空搜索后才生效」
// 的活控件。所以搜索中点桶顺带退出搜索,按新桶过滤——一次点击做完用户想要的两件事。
pages::Message::SelectMajor(key) => {
if self.view.download.has_query() {
// 先退出搜索再落选择:`rebuild_after_query` 会用**全量行**重算桶并回填选择
// (它带回 `selected_major`,且校验桶仍存在)。
self.view.download.selected_major = key.clone();
self.rebuild_after_query(String::new());
} else {
self.view.download.selected_major = key.clone();
}
}
// M107:类型栏的手动折叠也是纯视图态。
pages::Message::ToggleTypeRail => {
self.view.download.type_rail_folded = !self.view.download.type_rail_folded;
}
pages::Message::Action(pages::Action::RefreshSources) => {
match self.view.download.category.command() {
Some(kind) => self.dispatch(AppCommand::ListVersions(kind)),
None => self.dispatch(AppCommand::RefreshAll),
}
}
_ => return false,
}
true
}
/// 改草稿之后统一走这一条:置脏 + 立刻重建(不等下一拍,点了就有反应)。
fn mark_download_dirty(&mut self) {
self.view_dirty = true;
self.refresh_view();
}
/// 确认安装时的 `CreateRequest`(草稿 → 指令载荷)。
///
/// 实例名留空就让 core 用版本号(`CreateRequest::instance_name` 的既有口径),
/// 所以这里**不**自己拼一个默认名——两处各拼一份迟早会漂。
fn install_request(&self, id: &str) -> CreateRequest {
let draft = &self.download_draft;
CreateRequest {
name: draft.instance_name.trim().to_string(),
version: id.to_string(),
loader: draft.loader.kind(),
loader_version: draft.loader_version.trim().to_string(),
}
}
/// 当前草稿需要加载器版本列表、而快照里还没有时,派一次查询。
///
/// 判据是 `state.loader_versions_for(...)` 的 `None`(= 没查过):查过的(成功或失败)都不再重复派,
/// 失败要重试走 `RefreshSources` 那条路,页面上的红色原因行不会因为一次点击就消失。
fn ensure_loader_versions(&mut self) {
let loader = self.download_draft.loader;
if loader == pages::Loader::Vanilla {
return;
}
let Some(version) = self.download_draft.selected.clone() else {
return;
};
// 静态就不支持的组合不联网(否则只会换来一条「取不到」的假原因;真正的原因是
// 「这个加载器没有这个版本的构建」,页面已经用静态规则说清了)。
let kind = self
.state
.versions
.iter()
.find(|summary| summary.id == version)
.map(|summary| summary.kind);
if let Some(kind) = kind {
if loader.supports(&version, kind).is_err() {
return;
}
}
if self
.state
.loader_versions_for(loader.kind(), &version)
.is_some()
{
return;
}
self.dispatch(AppCommand::ListLoaderVersions {
kind: loader.kind(),
mc_version: version,
});
}
/// 搜索词变了:写进快照 → 重建视图 → **把纯视图态捡回来**。
///
/// `refresh_view()` 整块换掉 `self.view`,而它只把 `category` 与**安装草稿**带回去(`app/state.rs`
/// 归 M105/M106)。所以「折叠状态」与「选中的主版本桶」必须在重建前后自己搬一次——否则用户敲第一个
/// 字符,展开着的类型栏就自己收起来、选中的主版本也跳回「全部」。
/// 「搜索时侧栏保留当前位置」这条要求,落点就是这里的四行。
fn rebuild_after_query(&mut self, query: String) {
self.state.download_query = query;
let major = self.view.download.selected_major.take();
let folded = self.view.download.type_rail_folded;
self.view_dirty = true;
self.refresh_view();
// 回填要**校验**:桶是按当前行聚合法出来的,新桶列表里没有的那个选择不该被复活
// (复审 P2-1(a):否则会出现「侧栏没有任何一项点亮」的幽灵选中)。
self.view.download.selected_major = major.filter(|key| {
self.view
.download
.buckets
.iter()
.any(|bucket| bucket.key.as_deref() == Some(key.as_str()))
});
self.view.download.type_rail_folded = folded;
}
}
#[cfg(test)]
mod tests {
//! 搜索链路的**端到端**断言(复审 P2-5)。
//!
//! 为什么非要在 app 层写:这条链是 `Message → on_page → on_download → AppState.download_query
//! → refresh_view → from_snapshot 过滤`,中间任何一环断掉,页面层测试都看不见(页面只拿到
//! 已经过滤好的模型)。复审原话:「把 `rebuild_after_query` 第一行删掉,现有测试全绿,而搜索
//! 变成假控件」——这几条就是补那个洞的。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_presenter::dto::{AppEvent, Backend, CoreEvent, VersionKind, VersionSummary};
use unlucky_presenter::settings_bridge::SettingsBridge;
use super::*;
/// 只记账、不回事件的后端(同 `app/update/download_wiring.rs`):命令出了 UI 层,状态一个字节
/// 都不许变——否则「本地乐观回显」与「事件回流」终态相同,断言会假绿。
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.0.borrow_mut().push(command);
}
}
fn version(id: &str) -> VersionSummary {
VersionSummary {
id: id.to_string(),
kind: VersionKind::Release,
released: "2026-09-20".to_string(),
installed: false,
}
}
/// 一个装好三行版本的 App(`1.21.9` / `1.20.4` / `1.20.1`)。
fn app() -> App {
let families = crate::theme::Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
let mut app = App::with_presenter(
crate::cli::Options::default(),
families,
SchemeFlavor::Light,
Box::new(Recorder(Rc::new(RefCell::new(Vec::new())))),
SettingsBridge::memory_only(),
);
app.state
.apply(AppEvent::Core(CoreEvent::VersionListChanged(vec![
version("1.21.9"),
version("1.20.4"),
version("1.20.1"),
])));
app.refresh_view();
app
}
/// 键入 → 快照 → 列表:一条走完的链。
///
/// 抓得住:`rebuild_after_query` 不再写 `AppState.download_query`(搜索变假控件)、
/// 或 `from_snapshot` 不再按 query 过滤(列表不跟着收窄)。
/// 抓不住:真机上 `text_input` 的按键是否走到这里(那是组件的事,`search_bar` 已接 `on_input`)。
#[test]
fn typing_a_query_reaches_the_state_and_narrows_the_list() {
let mut app = app();
assert_eq!(app.view.download.rows.len(), 3, "初始是全量");
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
assert_eq!(app.state.download_query, "1.20", "查询词落在快照里");
assert_eq!(app.view.download.rows.len(), 2, "列表收窄到两行");
assert_eq!(app.view.download.hits, 2);
assert_eq!(app.view.download.query, "1.20", "模型回显查询词");
// 清空回到全量(`ClearVersionQuery` 那一支)
let _ = app.on_page(pages::Message::ClearVersionQuery);
assert!(app.state.download_query.is_empty());
assert_eq!(app.view.download.rows.len(), 3);
}
/// 查询词**跨事件存活**:再来一次重建,列表仍是收窄的。
///
/// 抓得住:把查询词只写在视图模型里(下一次 `refresh_view` 就丢)——这是「搜索看起来能用、
/// 一来事件就复位」的坏法。
#[test]
fn the_query_survives_a_snapshot_rebuild() {
let mut app = app();
let _ = app.on_page(pages::Message::SetVersionQuery("1.21".to_string()));
assert_eq!(app.view.download.rows.len(), 1);
// 安装进度这类事件回来时,`pump()` 会重建视图:查询词必须还在
app.state
.apply(AppEvent::Core(CoreEvent::InstallProgress(None)));
app.refresh_view();
assert_eq!(app.state.download_query, "1.21", "查询词在快照里,重建不丢");
assert_eq!(app.view.download.rows.len(), 1, "列表仍收窄");
}
/// **搜索期间点主版本桶 = 退出搜索并按该桶过滤**(复审 r2 P2-1(b))。
///
/// 抓得住:`SelectMajor` 只改选择不清查询 —— 那时搜索让第二级失效(P2-1 的一致性放宽),
/// 用户点了当场没反应、要等清空搜索那次点击才生效,就是「可点但无反应」的活控件。
/// 抓不住:真机点击是否走到这里(`ListItem`/`Chip` 的 `on_press` 是组件既有实现)。
#[test]
fn clicking_a_bucket_during_a_search_leaves_the_search() {
let mut app = app();
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
assert_eq!(app.view.download.rows.len(), 2, "此刻在搜 1.20");
// 点「1.21」这个桶
let _ = app.on_page(pages::Message::SelectMajor(Some("1.21".to_string())));
assert!(app.state.download_query.is_empty(), "点桶顺带退出搜索");
assert!(!app.view.download.has_query());
assert_eq!(
app.view.download.selected_major.as_deref(),
Some("1.21"),
"选择落在刚点的那个桶上"
);
assert_eq!(
app.view.download.rows.len(),
3,
"查询词清掉之后模型回到全量(桶过滤发生在渲染时的 visible 里)"
);
assert_eq!(
app.view
.download
.buckets
.iter()
.find(|bucket| bucket.key.as_deref() == Some("1.21"))
.map(|bucket| bucket.count),
Some(1),
"1.21 桶里只有一个版本 ⇒ 列表会显示一行"
);
// 没有搜索词时点桶照旧只改选择(不触发多余重建)
let _ = app.on_page(pages::Message::SelectMajor(None));
assert_eq!(app.view.download.selected_major, None);
}
/// 搜索时侧栏的**位置与折叠**不许丢(`rebuild_after_query` 的三行)。
///
/// 抓得住:删掉那两行回填(敲第一个字符,类型栏自己弹开、主版本跳回「全部」)。
/// 抓不住:其它事件(安装进度)回来时也会复位 —— 那条要 `app/state.rs` 的 `refresh_view`
/// 一起改,不在本 mission scope,已在报告里明确登记「留到 M105 之后」。
#[test]
fn a_query_keeps_the_rail_position_and_the_fold() {
let mut app = app();
app.view.download.selected_major = Some("1.20".to_string());
app.view.download.type_rail_folded = true;
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
assert_eq!(
app.view.download.selected_major.as_deref(),
Some("1.20"),
"桶选择还在(生效值在搜索期间放宽,但选择本身不许被清)"
);
assert!(app.view.download.type_rail_folded, "折叠状态还在");
// 选中一个**新桶列表里不存在**的桶:搜索后不许把它复活成幽灵选中(P2-1(a))
app.view.download.selected_major = Some("9.99".to_string());
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
assert_eq!(
app.view.download.selected_major, None,
"新桶列表里没有 9.99 ⇒ 回填空值,而不是留一个点不亮的幽灵选中"
);
}
}
@@ -0,0 +1,228 @@
//! 下载页(M94)在 app 层的接线测试:**命令出得去、状态不自己变**。
//!
//! 从 `app/update.rs` 外置(那边 348 行 + 这个模块会顶破 400 行硬上限;同一句话的理由见
//! `crates/i18n/src/catalog/tests.rs`)。`use super::*` 依然指向 `crate::app::update`。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_presenter::dto::{AppEvent, Backend, VersionCategory};
use unlucky_presenter::settings_bridge::SettingsBridge;
use super::*;
/// 只记账、**不回事件**的后端(同 `app/tests/presenter.rs`):命令出了 UI 层,状态一个字节都不许变。
/// 不用 `MockBackend`:它同步回事件,「本地乐观回显」与「事件回流」终态相同,断言会**假绿**。
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.0.borrow_mut().push(command);
}
}
/// 四条下载页消息:三条派真指令,`SelectVersion` 只改视图模型(P3-2 的不乐观回显)。
/// 抓得住:意图被写成「本地改状态就算完」、或派错指令;抓不住:host 收到后真的开始下载。
#[test]
fn download_intents_dispatch_real_commands() {
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
let families = crate::theme::Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
let mut app = App::with_presenter(
crate::cli::Options::default(),
families,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&log))),
SettingsBridge::memory_only(),
);
// M95 起 `on_page` 返回 `Task`(剪贴板命令要走返回的 Task):这里只关心派发与视图模型,
// 任务本身没有需要断言的后效,显式丢弃。
let _ = app.on_page(pages::Message::InstallVersion("1.21.9".to_string()));
let _ = app.on_page(pages::Message::CancelInstall);
let _ = app.on_page(pages::Message::SetCategory(pages::Category::Release));
let _ = app.on_page(pages::Message::SelectVersion("1.21.8".to_string()));
// M105:「安装」不再是「立刻装原版」,而是把**草稿里的选项**一起交出去
// (`CreateInstance` 带加载器 / 加载器版本 / 实例名)——这正是用户报的「没有加载器可选」的修复点。
let expected = vec![
AppCommand::CreateInstance(unlucky_presenter::dto::CreateRequest {
version: "1.21.9".to_string(),
loader: unlucky_presenter::dto::LoaderKind::Vanilla,
loader_version: String::new(),
name: String::new(),
}),
AppCommand::CancelInstall,
AppCommand::ListVersions(VersionCategory::Release),
];
assert_eq!(
*log.borrow(),
expected,
"三条各派一条;`SelectVersion` 不派"
);
// M105:选中项住在**草稿**里(`refresh_view` 每次事件都会整棵重建页面模型,
// 页面自己存不住它);recorder 不回 `VersionListChanged` ⇒ 列表是空的 ⇒
// 视图模型里那一行不存在,于是 `selected` 被过滤成 `None`(防「切分类后还留着一个点不动的安装」)。
assert_eq!(app.download_draft.selected.as_deref(), Some("1.21.8"));
assert_eq!(
app.view.download.selected, None,
"选中的行不在列表里时,视图模型不该拿着它"
);
assert_eq!(app.view.download.category, pages::Category::Release);
assert_eq!(
app.view.download.rows.len(),
0,
"recorder 不回事件 ⇒ 列表不该冒出来"
);
// 分类是本地过滤条件:**任何一次视图重建都不许把它打回 `All`**(复审 P2-1 的回归点)。
// 抓得住:`refresh_view` 忘记把分类带回去(那一行删掉这条就红)。
// 抓不住:真 host 是否按分类拉清单(那是 presenter 的 `spawn_list`,已报 finding)。
app.refresh_view();
assert_eq!(
app.view.download.category,
pages::Category::Release,
"重建视图模型之后分类必须还在"
);
}
/// **取消之后面板要回到「未安装态」且能立刻再装**(M105 的验收项,CI 可跑的那一半)。
///
/// 现场(用户原话):「取消下载按钮点了没反应」。真根因是取消生效了又被旧任务的事件盖回去
/// (presenter 侧的代次守门在 `host/cancel_tests.rs`);这一条钉的是**页面这一侧**:
/// 收到 `InstallProgress(None)` 之后,面板必须回到安装选项区、确认键可用、选中的版本还在
/// ——不需要用户重新点一遍版本。
///
/// 抓得住:取消后仍画着进度面板 / 选项区丢了选中 / 确认键还是禁用。
/// 抓不住:真网络下的取消延迟(那条是 `#[ignore]` 的真网络用例)。
#[test]
fn cancelling_gives_the_panel_back_to_the_install_options() {
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
let families = crate::theme::Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
let mut app = App::with_presenter(
crate::cli::Options::default(),
families,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&log))),
SettingsBridge::memory_only(),
);
app.state.apply(AppEvent::Core(
unlucky_presenter::dto::CoreEvent::VersionListChanged(vec![
unlucky_presenter::dto::VersionSummary {
id: "1.21.9".to_string(),
kind: unlucky_presenter::dto::VersionKind::Release,
released: "2026-09-20".to_string(),
installed: false,
},
]),
));
app.refresh_view();
// 选中 → 面板给出选项区
let _ = app.on_page(pages::Message::SelectVersion("1.21.9".to_string()));
let options = app
.view
.download
.options
.as_ref()
.expect("选中版本之后要出现安装选项区");
assert!(options.can_confirm, "原版 + 列表里的版本 = 可以装");
assert_eq!(options.version, "1.21.9");
// 开装:host 回一条「下载中」→ 面板换成进度
app.state.apply(AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstallProgress(Some(
unlucky_presenter::dto::InstallProgressView {
version: "1.21.9".to_string(),
stage: unlucky_presenter::dto::InstallStage::Downloading,
files_done: 9,
files_total: 512,
..unlucky_presenter::dto::InstallProgressView::new()
},
)),
));
app.refresh_view();
assert!(app.view.download.install.is_some(), "装的时候面板讲进度");
assert!(app.view.download.options.is_none(), "进度与选项互斥");
// 取消:host 回 `InstallProgress(None)`(`CancelInstall` 的既有语义)
let _ = app.on_page(pages::Message::CancelInstall);
app.state.apply(AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstallProgress(None),
));
app.refresh_view();
assert!(app.view.download.install.is_none(), "取消后面板必须收回");
let options = app
.view
.download
.options
.as_ref()
.expect("取消后要回到安装选项区(未安装态),而不是只剩一句提示");
assert_eq!(
options.version, "1.21.9",
"选中项要还在,用户不必重新点一遍"
);
assert!(options.can_confirm, "取消后要能立刻再装一次");
}
/// **取消提示只说一次,且说的是页面语言**(复审 P2-2)。
///
/// 修前:`presenter` 在取消时直接推一条**写死的中文** Toast,而下载页此刻渲染英文 ⇒
/// 选项区英文、取消弹窗中文,同屏两语。现在 host 只发信号(`CoreEvent::InstallCancelled`),
/// 文案由页面层按 `Key::DownloadCancelled` 现求,语言跟页面走。
///
/// 抓得住:谁把中文写回 presenter(类型上已经做不到——它没有 i18n 依赖)、
/// 或者 app 层每次 `refresh_view` 都重复弹(下面的 `items().len() == 1`)。
/// 抓不住:`Lang` 本身接得对不对(M106 起语言来自 `App::lang()`,本测试**显式钉**成英文,
/// 否则这条断言会跟着跑测试那台机器的 locale 变——那是假绿)。
#[test]
fn cancelling_announces_once_in_the_page_language() {
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
let families = crate::theme::Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
let mut app = App::with_presenter(
crate::cli::Options::default(),
families,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&log))),
SettingsBridge::memory_only(),
);
assert!(app.state.toasts.is_empty(), "出厂态不该有 Toast");
// M106:语言显式钉成英文。不钉的话「页面语言」就是跑测试那台机器的 locale(英文 CI 绿、
// 中文机器红)——那种测试证明不了任何事。钉住之后期望值仍从目录取,守的是同一条原意:
// **提示语来自 i18n 目录,不是 presenter 写死的中文**。
app.set_language(unlucky_i18n::LangChoice::Fixed(unlucky_i18n::Lang::En));
app.state.apply(AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstallCancelled,
));
app.refresh_view();
let items = app.state.toasts.items();
assert_eq!(items.len(), 1, "取消要提示一条(且只一条)");
let text = items[0].text.clone();
assert_eq!(
text,
pages::cancelled_toast(unlucky_i18n::Lang::En),
"提示语必须来自 i18n 目录(不是 presenter 写死的中文)"
);
assert!(
!text.contains('取') && !text.contains('消'),
"页面此刻是英文,取消提示不该混中文:{text}"
);
// 再重建一次视图:不该重复弹(信号是计数,不是「每拍都算一次」)
app.refresh_view();
assert_eq!(app.state.toasts.items().len(), 1, "同一次取消只提示一次");
let _ = app.state.toasts.len();
}
@@ -0,0 +1,232 @@
//! 页面消息的落点(`on_page`):解 M94 冲突时从 `update.rs` 拆出来。
//!
//! 为什么拆:`main` 上 `update.rs` 已经 **397 行**(M94 自己也是因此把下载页四支拆到
//! `update/download.rs`),日志页再往里塞「剪贴板 + 清日志」两支就顶破 400 硬线。
//! 与 M94 同一种做法:`app/update/**` 属本 mission scope。
//!
//! ## 穷尽性(M94 复审的 p3,本轮收掉)
//!
//! 这里是全 crate **唯一**对 [`pages::Message`] 的穷尽匹配点,所以**没有** `_ => {}` 兜底:
//! 谁往枚举里加变体却忘了接线,编译器立刻报 E0004。原来是 `_ => {}` + 注释「做穷尽性」——
//! 方向正好写反了,它**取消**穷尽性,把「漏接线」变成「点了没反应」。
//!
//! 代价:由前置分支(`on_download` / 本文件里的剪贴板分支)先消费的变体,在 match 里**仍要
//! 显式列出**。那是保住穷尽性的必要代价,也是这条守卫抓得住东西的原因。
//!
//! ## 边界(抓得住什么 / 抓不住什么)
//!
//! - 抓得住:**加一个 `pages::Message` 变体却没接线** → 编译失败(E0004),不会静默;
//! - 抓不住:加一个 `pages::Action` 变体 —— 它落进 `Action(action)` 的占位分支(打印一行
//! `[page] 占位动作 …`),编译期不会报。这是占位期的既有设计,不是本文件能守的。
use unlucky_i18n::{Lang, LangChoice};
use crate::pages;
use crate::ui::lang::LangState;
use super::*;
// --------------------------------------------------------------- 界面语言(M106)
//
// 这三个方法住在本文件而不是 `app/state.rs`:`state.rs` 贴着 400 行硬线(M104 的 `agent`、
// M105 的 `download_draft` 都往那儿长),而 `set_language` 的唯一调用点就是本文件的
// `pages::Message::Language` 分支——落点与调用点放一起,读的人不用来回跳。
// 与 M93 把 appearance 那批 `App` 方法放进 `app/appearance.rs` 是同一手法。
impl App {
/// 页面消息:导航走路由栈,真业务命令派发,纯视图态改本地呈现。
pub(super) fn on_page(&mut self, message: pages::Message) -> Task<Message> {
// 下载页的四支(含「刷新下载源」)在 `update/download.rs`:`update.rs` 早已贴 400 行
if self.on_download(&message) {
return Task::none();
}
match message {
pages::Message::Navigate(route) => {
// M109:进内容库时补一次检索(首个动作不该是「再点一下搜索」)。
// 判据在 `ensure_content_loaded` 里(已有结果/请求在飞就不重复打网络)。
let entering_content = route == Route::Content;
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
if entering_content {
self.ensure_content_loaded();
}
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
// (那一层没有 `_ =>`,见本文件末尾与 `update/settings/tests.rs` 的两条守门)
pages::Message::Settings(group) => return self.on_settings(group),
// M106:换界面语言(落盘 + 重建视图模型;档位不是业务命令,不派 `AppCommand`)。
// 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」,
// 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。
pages::Message::Language(choice) => self.set_language(choice),
// M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`)
pages::Message::Content(message) => return self.on_content(message),
// M95:摘要落剪贴板(文本由页面造,这里只搬)
pages::Message::CopySummary(text) => return iced::clipboard::write(text),
// 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档
pages::Message::SelectVersion(_)
| pages::Message::InstallVersion(_)
| pages::Message::CancelInstall
// M105 的三支 + M107 的四支 + 分类:全部由 `on_download` 提前消化(显式列出保住穷尽性)
| pages::Message::SetLoader(_)
| pages::Message::SetLoaderVersion(_)
| pages::Message::SetInstanceName(_)
| pages::Message::SetCategory(_)
// M107 的四支同属下载页(查询 / 清空 / 主版本桶 / 折叠类型栏)
| pages::Message::SetVersionQuery(_)
| pages::Message::ClearVersionQuery
| pages::Message::SelectMajor(_)
| pages::Message::ToggleTypeRail => debug_assert!(false, "应由 on_download 消费"),
// M95:清空日志是**呈现层**操作(`log_lines` 是 presenter 的公开快照字段,
// 不派业务命令、不写持久状态)
pages::Message::Action(pages::Action::ClearLogs) => {
self.state.log_lines.clear();
self.view_dirty = true;
self.refresh_view();
}
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
pages::Message::Account(message) => return self.on_account(message),
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => {
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
}
}
Task::none()
}
}
impl App {
/// 当前生效的界面语言(= 目录选择;页面文案一律由它派生)。
pub(in crate::app) fn lang(&self) -> Lang {
self.lang.lang()
}
/// 设置页语言行要的两件:档位(画选中态)+ 生效语言(画回显)。
pub(in crate::app) fn language(&self) -> pages::Language {
self.lang.for_settings()
}
/// 换界面语言(设置页语言行 → `pages::Message::Language`)。
///
/// 三件事缺一不可:①档位与结论换掉 ②视图模型重建(`view_dirty` + `refresh_view`,目录是
/// 视图模型的一部分)③落盘(重启后保持所选语言)。字体不在这里重扫:中文档缺字体时的
/// 判据/信号归 `i18n::font::decide` + `fonts/cjk.rs`(见报告「没验的部分」)。
pub(in crate::app) fn set_language(&mut self, choice: LangChoice) {
if choice == self.lang.choice() {
return;
}
self.lang = LangState::of(choice);
self.lang.log();
if !self.bridge.persist_ui_language(choice.setting_value()) {
eprintln!(
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
);
}
self.lang.log_cjk();
self.view_dirty = true;
self.refresh_view();
}
}
#[cfg(test)]
mod tests {
use unlucky_design::color::SchemeFlavor;
use unlucky_i18n::LangChoice;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同型。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// M106:设置页语言行点一下 → 生效语言与视图模型都跟着换。
///
/// 抓得住:①`pages::Message::Language` 没接进 `on_page`(点了没反应,编译器不会报——它接得住,
/// 但接错了分支就静默);②`set_language` 只改字段不重建视图(界面要等下一次刷新才变);
/// ③档位与生效语言各说各话(设置页会画错选中态)。
/// 抓不住:界面真的画成中文了没有(那要截图,见 `DevDocs/artifacts/m106/`)。
#[test]
fn the_language_message_switches_the_catalog_and_rebuilds_the_view() {
let mut app = headless();
assert_eq!(
app.view.lang,
app.lang(),
"首帧的视图模型要已经按生效语言建好"
);
let _ = app.update(crate::app::message::Message::Page(
pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::ZhCn)),
));
assert_eq!(app.lang(), unlucky_i18n::Lang::ZhCn);
assert_eq!(app.view.lang, unlucky_i18n::Lang::ZhCn, "目录要跟着换");
assert_eq!(
app.language(),
pages::Language {
choice: LangChoice::Fixed(unlucky_i18n::Lang::ZhCn),
effective: unlucky_i18n::Lang::ZhCn,
}
);
let refreshes = app.refreshes;
let _ = app.update(crate::app::message::Message::Page(
pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::En)),
));
assert_eq!(app.view.lang, unlucky_i18n::Lang::En);
assert_eq!(app.refreshes, refreshes + 1, "换语言要重建一次视图模型");
// 同一档再点一次:什么都不该发生(不白重建)
let refreshes = app.refreshes;
let _ = app.update(crate::app::message::Message::Page(
pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::En)),
));
assert_eq!(app.refreshes, refreshes, "同一档不该再重建");
}
/// 抓得住:有人给 `on_page` 的 match 重新加 `_ => {}` 兜底 —— 那正是 M94 复审的 p3:
/// 兜底让「加了变体忘了接线」从**编译错误**退化成「点了没反应」,这条测试把它拽回来。
/// 抓不住:别的兜底写法(把 match 拆成 if-else 链、给子枚举加通配之后再漏变体),
/// 也抓不住「加了变体但接错分支」——那两条只有编译器和人读得出来。
#[test]
fn the_page_match_has_no_wildcard_fallback() {
let source = include_str!("page_messages.rs");
let wildcards: Vec<(usize, &str)> = source
.lines()
.enumerate()
.filter(|(_, line)| {
let trimmed = line.trim();
trimmed.starts_with("_ =>") || trimmed.starts_with("_=>")
})
.map(|(index, line)| (index + 1, line))
.collect();
assert!(
wildcards.is_empty(),
"on_page 里出现了 `_ =>` 兜底(行 {:?}):它会取消对 pages::Message 的穷尽性,\
新变体漏接线时编译器不再报 E0004",
wildcards
);
}
}
@@ -0,0 +1,55 @@
//! 设置页分组消息的**二级分发**(M101 `SETTINGSMSG`):
//! `pages::Message::Settings(GroupMessage)` → 四条填页线各自的 handler。
//!
//! ## 为什么分两级
//!
//! `app/update/page_messages.rs` 对 [`pages::Message`] 做穷尽匹配(没有 `_ =>`,有
//! `the_page_match_has_no_wildcard_fallback` 守门)。设置页的四条填页线若各自往 `pages::Message`
//! 里加变体,就都得改那个文件——四路并行必然冲突。M101 把它们收进一个投递口
//! `pages::Message::Settings(GroupMessage)`,本文件再把 `GroupMessage` 分给四条线:
//!
//! ```text
//! GroupMessage::Java(m) → settings/java.rs
//! GroupMessage::Download(m) → settings/download.rs
//! GroupMessage::LanguageAppearance(m) → settings/language_appearance.rs
//! GroupMessage::AiAbout(m) → settings/ai_about.rs
//! ```
//!
//! ## 穷尽性:抓得住什么 / 抓不住什么
//!
//! - **抓得住**:某条线给 `GroupMessage` 加了载荷变体(= 在自己文件里加了一支消息)却忘了在本
//! match 里接线 → **E0004**;四个 handler 里对空枚举的 `match message {}` 一旦有人写成
//! `_ => {}`,`the_group_dispatch_has_no_wildcard_fallback` 会红(兜底会把「漏接线」从编译
//! 错误降级成「点了没反应」,`page_messages.rs` 的同类守卫是这么写的,这里照抄)。
//! - **抓不住**:handler 忽略自己的消息(`let _ = message; Task::none()`)、两个 handler 身体写反
//! 或写成一样——类型对得上就编得过,只有人读能看出来。也因此「接错了组」的一半是编译期兜住的
//! (载荷类型不同 ⇒ 编不过),另一半(接了个空壳)不是。
//!
//! ## 加一支设置页消息要改哪
//!
//! 自己那条线的两个文件:消息口(`pages/settings/<线>.rs`)+ 落点(本目录 `<线>.rs`)。
//! 本文件的 match 只在 `GroupMessage` **加变体**时才动,而那意味着新增一条线,不是加一支消息。
use super::*;
impl App {
/// 设置页的分组消息:分给四条线各自的文件,本文件不认识任何一支具体消息。
pub(super) fn on_settings(&mut self, message: pages::settings::GroupMessage) -> Task<Message> {
match message {
pages::settings::GroupMessage::Java(message) => self.on_settings_java(message),
pages::settings::GroupMessage::Download(message) => self.on_settings_download(message),
pages::settings::GroupMessage::LanguageAppearance(message) => {
self.on_settings_language_appearance(message)
}
pages::settings::GroupMessage::AiAbout(message) => self.on_settings_ai_about(message),
}
}
}
mod ai_about;
mod download;
mod java;
mod language_appearance;
#[cfg(test)]
mod tests;
@@ -0,0 +1,17 @@
//! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。
//!
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
impl App {
/// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_ai_about(
&mut self,
message: pages::settings::ai_about::Message,
) -> Task<Message> {
match message {}
}
}
@@ -0,0 +1,18 @@
//! Downloads 分组消息的落点(M101 `SETTINGSMSG`):**只归 download 线**。
//!
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。与下载页
//! (`pages/download*` 与 `app/update/download.rs`)无关:那是「装哪个版本」,这一组是「怎么下」。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
impl App {
/// Downloads 分组的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_download(
&mut self,
message: pages::settings::download::Message,
) -> Task<Message> {
match message {}
}
}
@@ -0,0 +1,33 @@
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。
//!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。
//!
//! 消息进来的通道有两条,都不经过共享文件:
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`;
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))`
//! (`app::Message::Page` 是既有通道,不用加新变体)。
//!
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的
//! `the_group_dispatch_has_no_catch_all_arm` 会红。
use super::super::*;
impl App {
/// Java 分组的一条消息。
pub(super) fn on_settings_java(
&mut self,
message: pages::settings::java::Message,
) -> Task<Message> {
match message {
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。
#[cfg(test)]
pages::settings::java::Message::Ping => {
self.view_dirty = true;
Task::none()
}
}
}
}
@@ -0,0 +1,19 @@
//! 语言 + 外观两组消息的落点(M101 `SETTINGSMSG`):**只归 language+appearance 线**。
//!
//! 消息口在 `pages/settings/language_appearance.rs`(`pages::settings::language_appearance::Message`)。
//! 为什么两组共用一个消息口:它俩本就是同一条线的两个分组(General 的语言 / 主题 / 启动页,
//! Appearance 的密度 / 圆角 / 动效 / 文字缩放),`GroupMessage` 只给这条线留一个变体。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
impl App {
/// 语言 / 外观线的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_language_appearance(
&mut self,
message: pages::settings::language_appearance::Message,
) -> Task<Message> {
match message {}
}
}
@@ -0,0 +1,267 @@
//! 设置页分组消息分发的**守门测试**(M101 `SETTINGSMSG`)。
//!
//! 判据与 `app/update/page_messages.rs` 里那条 `the_page_match_has_no_wildcard_fallback` 同源:
//! 兜底会把「加了变体忘了接线」从**编译错误**降级成「点了没反应」。两级 dispatch 各守一条:
//! 那一层守 `pages::Message`,本文件守 `GroupMessage` 与四个 handler。
//!
//! ## 判据(r2 复审 P2-3 之后收窄过一次)
//!
//! 上一版的判据是「除已知前缀外,任何含 `=>` 的行都算兜底」——**过宽**:往 handler 里加一个
//! **没有兜底臂**的嵌套 match(`match true { true => …, false => … }`),或者 rustfmt 把长
//! or-pattern 折行成 `| X =>`,都会让它假红。四条填页线一写真实逻辑就撞,守门反而推高成本。
//!
//! 现在的判据是**结构化的**:只找 `match message {` 这一层(用大括号深度计数把嵌套 match 排除
//! 在外),**只看深度 1 的臂**——
//!
//! - `app/update/settings.rs` 的 `on_settings`:恰好 **4 条**臂,每条以 `pages::settings::GroupMessage::`
//! (或短写 `GroupMessage::`)开头;
//! - 四个 handler:每条臂以该组自己的 `pages::settings::<线>::Message::`(或短写 `Message::`)开头,
//! 还没有臂的(空枚举 `match message {}`)一条臂都没有;
//! - 以 `|` 开头的折行 or-pattern 续行放行(它不可能是兜底)。
//!
//! ## 抓得住什么 / 抓不住什么
//!
//! 抓得住:
//! 1. `on_settings` 里加 `_ =>` 或 binding 臂 `other =>`(`GroupMessage` 不再穷尽);
//! 2. handler 把 `match message {}` 改成 `_ => Task::none()` / `other => { … }`;
//! 3. `on_settings` 的臂数不是 4(删了一条、或把两条合并);
//! 4. handler 的臂写成别的组的变体路径(前缀不对)。
//!
//! 抓不住(老实列出来):
//! 1. **嵌套 match 里的兜底**——深度 ≥2 的臂不看。这是刻意的:嵌套 match 的穷尽性由它自己的类型
//! 保证,跟 `GroupMessage` 漏接线无关;代价是没人守「嵌套 match 自己写了个 `_ =>`」;
//! 2. **跨行 pattern 分两种,别当一种**:以 `|` 开头的 or-pattern 续行**放行**(不误红);
//! 但**花括号形状**的多行 pattern(例如 `Message::SetRuntime {` 折成多行、`=>` 落在后面)
//! 会被**静默跳过**——那一行不带 `=>`,于是既不算臂也不算违规(臂数 0、零违规),是**漏检**
//! 而不是误红。两种都是洞,当前代码风格都不出现;真要用这种写法时把规则补上,别把守门删掉;
//! 3. **字符串/字符字面量里的 `{` `}`** 会扰乱深度计数(本文件与四个 handler 都没有这种写法);
//! 4. handler 忽略消息(`let _ = message; Task::none()`)、两个 handler 身体写反或写成一样
//! ——类型对得上就编得过,只有人读能看出来;
//! 5. `match message` 被改名或换变量名时找不到匹配,臂数为 0 —— 这时
//! `the_group_dispatch_has_exactly_four_arms` 会红(不是静默通过)。
//!
//! 站位:本文件是 `app::update::settings::tests`,`include_str!` 的路径相对本文件所在目录。
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::message::Message;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
/// `on_settings` 的臂允许的前缀。
const GROUP_ARMS: &[&str] = &["pages::settings::GroupMessage::", "GroupMessage::"];
/// 一行的净大括号增量(`{` 加、`}` 减)。
fn brace_delta(line: &str) -> i32 {
let open = line.matches('{').count() as i32;
let close = line.matches('}').count() as i32;
open - close
}
/// 扫一条 `match message { … }`,只看**本层**(深度 1)的臂。
///
/// 返回 `(本层的合法臂数, 违规行)`:违规 = 本层里含 `=>`、又不是「已知前缀开头」的行
/// (`|` 开头的折行 or-pattern 续行放行)。嵌套 match 的臂在深度 ≥2,整段跳过。
fn dispatch_arms(path: &str, source: &str, allowed: &[&str]) -> (usize, Vec<String>) {
let mut arms = 0usize;
let mut offending = Vec::new();
let mut depth = 0i32;
let mut inside = false;
for (index, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
if !inside {
if trimmed.contains("match message {") {
inside = true;
depth = 1;
}
continue;
}
if depth == 1 && trimmed.contains("=>") {
let pattern = trimmed.trim_start_matches('|').trim_start();
if allowed.iter().any(|prefix| pattern.starts_with(prefix)) {
arms += 1;
} else if !trimmed.starts_with('|') {
offending.push(format!("{path}:{}: {trimmed}", index + 1));
}
}
depth += brace_delta(trimmed);
if depth <= 0 {
break;
}
}
(arms, offending)
}
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同一副骨架。
fn headless() -> App {
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
// --------------------------------------------------------------- 守门本体
/// `on_settings` 与四个 handler 的**本层**臂里不许出现任何形式的兜底(`_ =>` 或 `other =>`)。
#[test]
fn the_group_dispatch_has_no_catch_all_arm() {
let sources: [(&str, &str, &[&str]); 5] = [
(
"app/update/settings.rs",
include_str!("../settings.rs"),
GROUP_ARMS,
),
(
"app/update/settings/java.rs",
include_str!("java.rs"),
&["pages::settings::java::Message::", "Message::"],
),
(
"app/update/settings/download.rs",
include_str!("download.rs"),
&["pages::settings::download::Message::", "Message::"],
),
(
"app/update/settings/language_appearance.rs",
include_str!("language_appearance.rs"),
&[
"pages::settings::language_appearance::Message::",
"Message::",
],
),
(
"app/update/settings/ai_about.rs",
include_str!("ai_about.rs"),
&["pages::settings::ai_about::Message::", "Message::"],
),
];
let mut offending = Vec::new();
for (path, source, allowed) in sources {
let (_, bad) = dispatch_arms(path, source, allowed);
offending.extend(bad);
}
assert!(
offending.is_empty(),
"设置页分组消息的 dispatch 本层出现了兜底臂({offending:?}):\
任何 catch-all / binding 臂都会取消 `GroupMessage` 的穷尽性,\
新变体漏接线时编译器不再报 E0004"
);
}
/// `on_settings` 必须**恰好四条**臂,一条一条显式写全(不是「至少」)。
///
/// 抓得住:删掉一条臂、把两条合并、或者把 `match message` 改名(改名后扫不到臂 ⇒ 0 ≠ 4)。
/// 抓不住:臂的顺序(顺序不影响语义)、每条臂接到了哪个 handler(类型对得上就编得过)。
#[test]
fn the_group_dispatch_has_exactly_four_arms() {
let (arms, _) = dispatch_arms(
"app/update/settings.rs",
include_str!("../settings.rs"),
GROUP_ARMS,
);
assert_eq!(
arms, 4,
"`on_settings` 应该恰好四条臂(java / download / language_appearance / ai_about),实际 {arms} 条"
);
}
/// **守门自身不误伤**:嵌套 match 与折行的 or-pattern 都不算兜底(r2 复审 P2-3 的探针)。
///
/// 这条是上一版判据假红的回归测试:复审往 handler 里加 `match true { true => …, false => … }`
/// 就让它 FAILED;现在这类写法必须放行。
#[test]
fn the_guard_ignores_nested_matches_and_folded_or_patterns() {
let synthetic = "\
impl App {
fn on_settings_java(&mut self, message: pages::settings::java::Message) -> Task<Message> {
match message {
pages::settings::java::Message::Probe => {
let nested = match true { true => 10u8, false => 11u8 };
let _ = nested;
Task::none()
}
| pages::settings::java::Message::Retry => Task::none(),
}
}
}
";
let (arms, offending) = dispatch_arms(
"synthetic",
synthetic,
&["pages::settings::java::Message::"],
);
assert!(
offending.is_empty(),
"正常代码被误判成兜底(这正是 r2 复审 P2-3 的假红):{offending:?}"
);
assert_eq!(arms, 2, "嵌套 match 里的两条臂不算本层臂");
}
/// **守门仍然有牙**:`_ =>` 与 binding 臂 `other =>` 都要被本层判成兜底。
#[test]
fn the_guard_still_catches_both_catch_all_shapes() {
for arm in [
"_ => Task::none(),",
"other => { let _ = other; Task::none() }",
] {
let synthetic = format!(
"impl App {{\n fn on_settings_java(&mut self, message: M) -> Task<Message> {{\n match message {{\n {arm}\n }}\n }}\n}}\n"
);
let (_, offending) = dispatch_arms("synthetic", &synthetic, GROUP_ARMS);
assert_eq!(
offending.len(),
1,
"`{arm}` 是兜底,必须被判红(实际 {offending:?})"
);
}
}
// --------------------------------------------------------------- 活的端到端证据
/// **活的端到端证据**:一条组消息真的从外壳那条既有通道走到该组的 handler(r1 复审要求)。
///
/// 路径:`app::Message::Page` → `on_page` → `pages::Message::Settings` → `on_settings` →
/// `on_settings_java`,最后改到 `App` 的状态。r1 之前这条链只有编译期证据(`GroupMessage` 是
/// uninhabited,运行时是死代码);现在至少有一支真的跑得起来。
///
/// 抓得住:漏斗某一环接断了(`on_page` 没转交、`on_settings` 转错组、handler 忽略消息)——那时
/// `view_dirty` 不会翻。抓不住:真实用户点击(本条没有 UI 控件,构造点只有测试)。
#[test]
fn a_group_message_reaches_its_handler_through_the_funnel() {
let mut app = headless();
app.view_dirty = false;
let _ = app.update(Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping),
)));
assert!(
app.view_dirty,
"java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了"
);
}
+195
View File
@@ -0,0 +1,195 @@
//! `view`:presenter 快照 → 页面视图模型 → 控件树。
//!
//! 展示转换(快照字段 → 显示文本/网格)**全部在这里**,页面层只认 [`pages::Model`]。
//!
//! 实例页那一段(`1.20.4 · Fabric`、运行状态、元信息卡)M120 搬进 [`instances`]:本文件
//! 当时贴着 400 行硬线,而 i18n 还要在这个函数里接一批阶段标签。
use iced::{Element, Theme};
use unlucky_i18n::Lang;
use unlucky_presenter::router::Route;
use unlucky_presenter::state::AppState;
use crate::app::components::interaction::Interaction;
use crate::cli::Screen;
use crate::pages::{self, Row};
use crate::shell::{self, agent, NavItem};
use super::message::{nav_item_of, Message};
use super::state::{log_first_frame, App};
use super::APP_TITLE;
mod account_page;
mod instances;
mod logs;
mod rail;
use instances::{detail_of, runtime_status, supporting_line};
use logs::logs_model;
impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
pub(super) fn theme(&self) -> Theme {
self.ui().theme()
}
/// 窗口标题(自绘标题栏另画一份,这里是给系统/截图工具看的)。
pub(super) fn title(&self) -> String {
APP_TITLE.to_owned()
}
/// 整棵树。
pub(super) fn view(&self) -> Element<'_, Message> {
log_first_frame();
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
// 这里只做一次 map,组件库与业务 Message 依然解耦。
if self.options.screen == Screen::Gallery {
return super::components::gallery::view(&self.ui).map(Message::Demo);
}
// 产品页走**内容槽位**:外壳(标题栏 / 拖动与缩放热区 / 三个 caption 键 / rail)由
// `shell` 画,中间那块才是页面(`DragWindow`/`ResizeStart`/`Minimize`/`ToggleMaximize`/`Close`
// 五种消息的构造点只在外壳里,页面树自己画的标题栏拖不动窗口)。
//
// M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读
// 的那份状态。M101 r2 起 ③ 走 `page_with_settings`:外观状态 + 各组自己的状态一起递进去。
let background =
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
.map(|element| element.map(Message::Page));
// 右栗(M104):拆出档返回 `None`(会话已在独立页里);拖拽期间另有铺满窗口的取指针层。
let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
// M119:会话内容 / 未读 / busy / 待确认全部来自 presenter 的 rail,失败横幅是 app 层
// 备好的**展示输入**(标题 catalog 键 + 已脱敏正文)。壳在这里不做任何脱敏。
// 界面语言的真值源是 `App::lang`(M106):右栏的静态文案也走它,不再有 `const LANG`
let agent_catalog = self.lang().catalog();
// 会话入参:rail 借 presenter、失败横幅由 app 层备好、`simulated` 是后端的事实
let session = agent::Session {
agent: &self.agent,
rail: self.agent_host.rail(),
failure: self.agent_failure(),
simulated: self.agent_host.is_simulated(),
};
let account = self.rail_account();
match pages::Screen::of(self.state.route()) {
Some(_) => shell::view_with_account(
&self.ui,
shell::NavRail {
selected: nav_item_of(self.state.route()),
account: account.clone(),
},
APP_TITLE,
background,
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
pages::page_with_settings(
&self.ui,
&self.view,
pages::settings::States {
appearance: Some(&self.appearance),
groups: &self.settings,
},
// M106:设置页 General 组要画「当前档位 + 生效语言」。
self.language(),
)
.map(Message::Page),
agent::docked(&self.ui, session, agent_catalog, self.width)
.map(|element| element.map(Message::Agent)),
overlay,
self.options.force_state,
self.lang(),
),
// 账户页(M114):它不走 `pages::Screen` 那四页的 `Model`,自己一棵树
// (树在 `app/view/account_page.rs`,与其它页共用 M104 的中栏可用宽)。
None if self.state.route() == &Route::Accounts => {
self.account_page(&account, background, overlay)
}
// `Route::Agent`:独立页与右栏共用同一个 `conversation::view`(`agent::page` 内部调的它)。
None => shell::view_with_account(
&self.ui,
shell::NavRail {
selected: NavItem::Ai,
account: account.clone(),
},
APP_TITLE,
background,
agent::page(&self.ui, session, agent_catalog, self.width).map(Message::Agent),
None,
overlay,
self.options.force_state,
self.lang(),
),
}
}
}
// --------------------------------------------------------------- 快照 → 视图模型
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
///
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
///
/// `width` 是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝,由 `AgentUi::content_width` 算),不是窗口宽:
/// 页面布局系统仍按「一个窗口宽」扣左轨与页边距,所以右栗占掉的那一段必须在这里先减掉——
/// 否则助手栏一展开,页面还按 1180 排版,内容就顶到面板底下了(任务里的「收缩而不是溢出」)。
pub(super) fn model(
state: &AppState,
width: f32,
forced: Option<crate::cli::ForcedState>,
category: pages::Category,
draft: &pages::InstallDraft,
lang: Lang,
content: &pages::content::PageState,
) -> pages::Model {
let route = state.route().clone();
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
// 实例页的文案真源:`App::lang` 的目录(M120)。列表 overline / 元信息标签 / 空态都从它取。
let catalog = lang.catalog();
let rows: Vec<Row> = state
.tiles()
.into_iter()
.map(|cell| Row {
id: cell.instance.id.clone(),
name: cell.instance.name.clone(),
supporting: supporting_line(cell.instance, catalog),
status: runtime_status(&cell.instance.runtime, catalog),
})
.collect();
let detail = match &route {
Route::Detail(id) => state
.instances
.iter()
.find(|instance| &instance.id == id)
.map(|instance| detail_of(instance, catalog)),
_ => None,
};
pages::Model {
screen,
window_width: width,
active: route.nav(),
instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
logs: logs_model(state).with_status(lang),
lang,
forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
crate::cli::ForcedState::Rest => Interaction::Enabled,
crate::cli::ForcedState::Hover => Interaction::Hovered,
crate::cli::ForcedState::Pressed => Interaction::Pressed,
}),
}
}
#[cfg(test)]
#[path = "view/tests.rs"]
mod tests;
/// 测试只借这一条(其余三条 `model()` 自己用)。
#[cfg(test)]
use instances::last_played;
@@ -0,0 +1,73 @@
//! 账户页(M114)的**整页装配**:从 `App` 的状态拼出内容树,再交给外壳。
//!
//! 为什么单独一个文件:`app/view.rs` 贴着 400 行硬线(M104 的右栏分支已经把余量吃掉了),
//! 而这段是纯装配(没有状态数学),搬出来不改变任何语义。
//!
//! 宽度口径:用 `AccountPage::width`(= M104 的 `AgentUi::content_width`,中栏可用宽),
//! 与其它页、与 `refresh_view` 同一份来源——右栏展开时账户页**一起收缩**,不会顶到面板底下。
use iced::Element;
use crate::app::Message;
use crate::pages;
use crate::pages::account::Page as AccountPage;
use crate::shell::{self, agent, NavItem, RailAccount};
use super::super::state::App;
use super::super::APP_TITLE;
impl App {
/// 账户页视图模型的重建(`refresh_view` 的账户那一步)。
///
/// 与 `view` 同一次门控;语言跟着 M106 的 `LangState` 走(M114 早期钉死 `Lang::En`
/// 的写法已删),宽度用 M104 的中栏可用宽。放在这里是为了让 `app/state.rs` 守住 400 行。
pub(in crate::app) fn rebuild_account_page(&mut self, width: f32) {
let lang = self.lang();
self.account = AccountPage::build(
&self.state,
&self.account_form,
&mut self.avatars,
lang,
width,
);
}
/// 账户页(走 `pages::account` 自己的树,而不是 `pages::Screen` 那四页的 `Model`)。
pub(super) fn account_page<'a>(
&'a self,
account: &RailAccount,
background: Option<Element<'a, Message>>,
overlay: Option<Element<'a, Message>>,
) -> Element<'a, Message> {
let metrics =
pages::layout::frame_metrics(pages::layout::PaneKit::Single, self.account.width);
shell::view_with_account(
&self.ui,
shell::NavRail {
selected: NavItem::Account,
account: account.clone(),
},
APP_TITLE,
background,
// M106:账户页的文案跟着**生效语言**走(`LangState::boot` 自己读 `ULL_LANG`,
// 所以 M114 早期那个「截图专用语言开关」已删——两套开关必然漂)。
pages::account::view(&self.ui, metrics, &self.account, self.lang()).map(Message::Page),
// M119:右栗的会话内容 / 未读 / busy 全部来自 presenter 的 rail(`agent_host`)
agent::docked(
&self.ui,
agent::Session {
agent: &self.agent,
rail: self.agent_host.rail(),
failure: self.agent_failure(),
simulated: self.agent_host.is_simulated(),
},
self.lang().catalog(),
self.width,
)
.map(|element| element.map(Message::Agent)),
overlay,
self.options.force_state,
self.lang(),
)
}
}
+162
View File
@@ -0,0 +1,162 @@
//! 实例页的**展示转换**(M120 从 `app/view.rs` 原样搬出:那边给 i18n 让出行数)。
//!
//! | 函数 | 产物 | 谁读 |
//! |---|---|---|
//! | [`supporting_line`] | 列表行 / 详情副标题 `1.20.4 · Fabric 0.16.5` | [`crate::pages::Row`] / [`crate::pages::Detail`] |
//! | [`runtime_status`] | 运行状态行(列表 overline + 详情状态行共用一份) | 同上 |
//! | [`detail_of`] | 详情列的全部字段 | [`crate::pages::Detail`] |
//! | [`last_played`] | `2026-10-29 19:22 UTC` / `Never` | 元信息卡 `LAST PLAYED` |
//!
//! 这里只做「快照字段 → 要画什么」,不碰控件树。**文案的语言由调用方的 [`Catalog`] 决定**
//! (`view::model` 把 `App::lang` 的目录传进来),不再是 M90 时期的英文基线字面量。
//!
//! ## 两条接法上的取舍
//!
//! 1. **原版徽标走词表**:`LoaderInfo::badge()` 对原版返回**中文**「原版」(那是 presenter 的
//! 面向中文日志的 `label()`),英文界面里直接画它就是「中文漏进英文界面」。所以原版这一支
//! 改走 [`Key::LoaderVanilla`],装加载器时仍是 `Fabric 0.16.5`(品牌 + 版本号,不翻译);
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
use crate::pages::{Detail, Meta};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
format!(
"{} · {}",
instance.mc_version,
loader_badge(instance, catalog)
)
}
/// 加载器徽标:原版走词表,其余交给 `LoaderInfo::badge()`(品牌 + 版本号,原样)。
///
/// **判据只能是「加载器类型本身是原版」**。曾经写成「原版 **或** 版本号为空」⇒
/// `1.20.1-forge` / `1.20.1-neoforge` / `1.20.1-OptiFine` 这类**取不到版本段**的 id(`badge()`
/// 对它们给的就是品牌名 `Forge` / `NeoForge` / `OptiFine`)会被显示成「原版 / Vanilla」——
/// 那是 M120 r1 的 p2-1 回归,`the_loader_badge_keeps_the_brand_when_the_version_is_missing` 守着。
fn loader_badge(instance: &InstanceSummary, catalog: Catalog) -> String {
match instance.loader.kind {
// 唯一需要过词表的一支:`LoaderKind::label()` 对原版返回的是**中文**「原版」
LoaderKind::Vanilla => catalog.text(Key::LoaderVanilla).to_string(),
// 其余品牌名不翻译;版本号为空时 `badge()` 自己就只给品牌名
_ => instance.loader.badge(),
}
}
/// 隔离档位:`isolated` / `shared` 是两个稳定 id(`core::game::IsolationMode::as_str`),
/// 直接上屏就是英文界面漏中文、中文界面漏英文。只认这两档,**其余原样**(`inherit` / `custom` /
/// 未来新增的值不许在这里被猜成一个意思)。
fn isolation(raw: &str, catalog: Catalog) -> String {
match raw {
"isolated" => catalog.text(Key::InstancesIsolationIsolated).to_string(),
"shared" => catalog.text(Key::InstancesIsolationShared).to_string(),
other => other.to_string(),
}
}
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime {
InstanceRuntime::Idle => None,
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
InstanceRuntime::Busy { stage, progress } => Some(format!(
"{} · {:.0}%",
stage_label(*stage, catalog),
progress.ratio() * 100.0
)),
InstanceRuntime::Running { pid } => {
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
}
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案
InstanceRuntime::Failed { reason } => {
Some(catalog.render(Key::InstancesFailed, &[("reason", reason)]))
}
}
}
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
catalog.text(match stage {
LaunchStage::Download => Key::InstancesStageDownload,
LaunchStage::Verify => Key::InstancesStageVerify,
LaunchStage::Launch => Key::InstancesStageLaunch,
LaunchStage::Window => Key::InstancesStageWindow,
})
}
/// 详情列的内容(字段全部来自 [`InstanceSummary`])。
pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail {
Detail {
id: instance.id.clone(),
name: instance.name.clone(),
subtitle: supporting_line(instance, catalog),
meta: vec![
meta(
catalog.text(Key::InstancesMetaVersion),
&instance.mc_version,
),
meta(
catalog.text(Key::InstancesMetaLoader),
&loader_badge(instance, catalog),
),
meta(
catalog.text(Key::InstancesMetaIsolation),
&isolation(&instance.isolation, catalog),
),
meta(
catalog.text(Key::InstancesMetaVersionId),
&instance.version_id,
),
meta(
catalog.text(Key::InstancesMetaFiles),
catalog.text(if instance.version_present {
Key::InstancesPresentOnDisk
} else {
Key::InstancesMissing
}),
),
meta(
catalog.text(Key::InstancesMetaLastPlayed),
&last_played(instance.last_played.as_deref(), catalog),
),
],
// 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段
note: None,
status: runtime_status(&instance.runtime, catalog),
can_launch: matches!(
instance.runtime,
InstanceRuntime::Idle | InstanceRuntime::Failed { .. }
),
can_stop: matches!(
instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
),
}
}
fn meta(label: &str, value: &str) -> Meta {
Meta {
label: label.to_string(),
value: value.to_string(),
}
}
/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。
///
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等时间格式 mission。
/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。
pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
let Some(text) = raw else {
return catalog.text(Key::InstancesNeverPlayed).to_string();
};
match (text.get(0..10), text.get(11..16)) {
(Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => {
format!("{date} {time} UTC")
}
_ => text.to_string(),
}
}
+144
View File
@@ -0,0 +1,144 @@
//! 日志页的展示转换:presenter 快照 → [`pages::Logs`](从 `app/view.rs` 拆出来,M95 r3)。
//!
//! ## 跨会话串场(M95 r3 的 p2,代码复审抓的)
//!
//! r2 的写法先按 `state.diagnosis` 定「这是哪一场」:
//!
//! ```ignore
//! let session = report.and_then(|r| log_sessions.find(|s| s.id == r.session)).or_else(|| …selected…);
//! ```
//!
//! 而 `state.diagnosis` **全仓只写不清**(`presenter::state::apply_core` 里
//! `CoreEvent::DiagnosisReady` 是唯一赋值点,没有任何清零点),host 也只在崩溃 / 打开会话 /
//! 导入崩溃文件时发它。于是「崩溃一场 → 再启动一场正常跑」这个最普通的路径下:
//! 页头把**新**会话标成上一场的 `The game crashed`、诊断卡挂着上一场的结论,而用户复制去贴
//! issue 的摘要里 `lines=` 是新的那一场的行数——**结论与行数不同源**,用户会把错诊断递出去。
//!
//! 现在的口径(不动 presenter 的写入口,只在页面侧判别):
//!
//! | 概念 | 取值 |
//! |---|---|
//! | **身份**(这一场是谁) | `live_session`(正在跑)→ `selected_session`(打开/刚结束的),**不从 `diagnosis` 反推** |
//! | 诊断结论 | 见下表的三个分支(r4 修正了 r3 的一条**回归**:r3 只认身份,把「导入崩溃文件」与「打开会话」两条路径的诊断一并关掉了) |
//! | 实例/时间/结局(给页头描述) | 身份优先;身份为空才退到报告的 session |
//!
//! 归属判据(`report.session` 与身份的关系):
//!
//! | `live_session` | `selected_session` | 报告算数的条件 |
//! |---|---|---|
//! | `Some(live)` | 任意 | `report.session == live` —— 只有它在线时日志行是它自己在追加,别人的报告挂上来就是串场 |
//! | `None` | `Some(sel)` | `report.session == sel` **或** 报告不属于任何已知会话(`ImportCrashLog` 用 `file:<文件名>` 当会话 id,永远不在索引里) |
//! | `None` | `None` | 全部算数 —— 此时日志行只可能来自 `open_session` 或导入,两者都与报告同一次调用发出 |
//!
//! 已知残留(今天 iced 侧不可达,登记在报告里):`open_session(A)` 在 `selected_session` 仍停在上
//! 一场 B 时会被第三行判据挡掉(A 是已知会话且 ≠ B)——真正要修得让 presenter 在
//! `LogLinesChanged` 时把「日志行属于哪一场」记下来(`AppState` 今天没有这个字段)。
//!
//! 「没有命中规则」的**占位报告**也不画:host 的 `open_session` 在规则引擎无结论时会发一份
//! `rule: ""` / `confidence: 0` / `title: "该会话没有命中已知规则"` 的假报告(`CoreEvent::DiagnosisReady`
//! 是非 Option,host 无法表达「没有结论」)——它是标记不是结论,画出来等于给用户看一条「严重度提示」。
//!
//! 已知缺口(已报 finding,不在本 mission 修):`clear_logs` 之后 `selected_session` 不清,
//! 身份会继续指向那一场;导入崩溃文件只发 `DiagnosisReady`(不带日志行),所以页面上会出现
//! 「结论 + 上一场的日志正文」——行由 `open_session` 负责,那是 presenter 的缺口。
use unlucky_presenter::dto::{Diagnosis, LogLevel, LogOutcome};
use unlucky_presenter::state::AppState;
use crate::pages;
/// 日志页视图模型:presenter 快照 → 要画的东西。
///
/// 两处 DTO 侧的**信息丢失**(已报 finding):`dto::Diagnosis` 没有 core 的 `severity`
/// (所以页面按置信度档分组,不冒称严重度)与 `cause.key`(`title`/`hint` 只能当数据画)。
pub(super) fn logs_model(state: &AppState) -> pages::Logs {
let live = state.live_session.as_deref();
let selected = state.selected_session.as_deref();
let identity = live.or(selected);
// 归属判据(r4 修正 r3 的**回归**:r3 的「有选中就只认选中」把导入/打开这两条路径的诊断
// 一起关掉了)。三条命中任一即认:
//
// 1. 报告属于**正在跑**的那一场 —— 这一条必须单独成立:只有它在线时日志行是它自己在追加,
// 别的会话的报告挂上来就是串场(r3 修的正是这个);
// 2. 报告属于**当前选中**的那一场(没有 live 会话时);
// 3. 报告的会话**不在会话索引里** —— `ImportCrashLog` 用 `file:<文件名>` 当会话 id,
// `open_session` 也不一定把 id 写进 `selected_session`;这两条路径与报告是同一次调用发出的,
// 把它们一并关掉就是把「导入崩溃日志」这条功能画成空(r3 的实测回归)。
let known = |id: &str| state.log_sessions.iter().any(|session| session.id == id);
let report = state.diagnosis.as_ref().filter(|report| {
let id = report.session.as_str();
match (live, selected) {
// 有会话在跑:日志行是它自己在追加,别的会话的报告挂上来就是串场(r3 修的 P1)。
(Some(live), _) => id == live,
// 没有在跑、但有选中项:报告要么是它的,要么是「会话索引里没有的那一份」
// (`ImportCrashLog` 用 `file:<文件名>`;`open_session` 不写 `selected_session`,
// 所以只能靠「不在索引里」认它)。
(None, Some(selected)) => id == selected || !known(id),
// 什么身份都没有:日志行只可能来自 `open_session` 或导入,两者与报告同一次发出。
(None, None) => true,
}
});
// 身份为空时,描述的来源只能退到报告(见模块文档的表格)。
let described = identity.or_else(|| report.map(|report| report.session.as_str()));
let session =
described.and_then(|id| state.log_sessions.iter().find(|session| session.id == id));
pages::Logs {
instance: session.map(|session| session.instance.clone()),
when: session.map(|session| session.when.clone()),
outcome: session.map(|session| log_outcome(session.outcome)),
// 运行中的会话不在 `log_sessions` 里(索引只在结束时追加),所以页头要靠这个标记
// 走「正在运行」那条文案,而不是「当前没有运行中的实例」。
live: live.is_some(),
lines: state
.log_lines
.iter()
.map(|line| pages::Line {
text: line.text.clone(),
level: log_level(line.level),
})
.collect(),
findings: report
.filter(|report| !report.diagnosis.rule.is_empty())
.map(|report| diagnosis_of(&report.diagnosis))
.into_iter()
.collect(),
..pages::Logs::default()
}
}
/// 日志等级:presenter 枚举 → 页面等级(**结构化出口**,不是认字符串)。
fn log_level(level: LogLevel) -> pages::Level {
match level {
LogLevel::Debug => pages::Level::Debug,
LogLevel::Info => pages::Level::Info,
LogLevel::Warn => pages::Level::Warn,
LogLevel::Error => pages::Level::Error,
}
}
/// 会话结局:presenter 枚举 → 页面结局(文案走 `LaunchOutcome*` key,页面不画 core 的中文串)。
fn log_outcome(outcome: LogOutcome) -> pages::Outcome {
match outcome {
LogOutcome::Ok => pages::Outcome::Ok,
LogOutcome::Crashed => pages::Outcome::Crashed,
LogOutcome::SilentFail => pages::Outcome::SilentFail,
}
}
/// 一条诊断结论 → 页面结论(置信度档见 [`pages::Confidence`])。
fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding {
pages::Finding {
rule: diagnosis.rule.clone(),
confidence_band: pages::Confidence::of(diagnosis.confidence),
title: diagnosis.title.clone(),
hint: diagnosis.hint.clone(),
confidence: diagnosis.confidence,
action: diagnosis.fix.clone(),
}
}
#[cfg(test)]
mod tests;
+242
View File
@@ -0,0 +1,242 @@
//! 日志页展示转换的守门测试(从 `app/view/logs.rs` 拆出:那边已经 382 行,贴着 400 硬上限)。
//!
//! 全部走**真实 reducer 路径**(`AppState::apply_core`),不造注入源——r3 的教训是
//! 「测试全走注入源而真实路径零覆盖」,所以这里的夹具就是线上那串事件本身。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
CoreEvent, DiagnosisReport, LaunchFinished, LiveLogBatch, LogLevel, LogLine, LogOutcome,
LogSession,
};
use unlucky_presenter::state::AppState;
use crate::pages;
use super::logs_model;
/// 崩溃的那一场。
const CRASH: &str = "s-1029-1840";
/// 崩溃之后又启动的、正常跑的一场。
const OK_RUN: &str = "s-1029-1922";
fn session(id: &str, when: &str, outcome: LogOutcome) -> LogSession {
LogSession {
id: id.to_string(),
instance: "开发测试".to_string(),
when: when.to_string(),
outcome,
duration_secs: 62,
}
}
fn line(text: &str, level: LogLevel) -> LogLine {
LogLine {
text: text.to_string(),
level,
}
}
fn report(session: &str, rule: &str) -> DiagnosisReport {
DiagnosisReport {
session: session.to_string(),
diagnosis: pages_dto(rule),
}
}
fn pages_dto(rule: &str) -> unlucky_presenter::dto::Diagnosis {
unlucky_presenter::dto::Diagnosis {
rule: rule.to_string(),
title: "显卡驱动过旧(OpenGL 版本不足)".to_string(),
confidence: 92,
hint: "升级显卡驱动".to_string(),
fix: Some("change_graphics.driver".to_string()),
}
}
/// **真实数据路径**(复审要求:不许造注入源绕过去):
/// 崩溃一场跑完 → 再启动一场正常跑。`state.diagnosis` 在这里**故意不清**,
/// 因为 presenter 里根本没有清零点——这就是线上发生串场的条件。
fn crashed_only() -> AppState {
let mut state = AppState::new();
state.apply_core(CoreEvent::LogSessionsChanged(vec![
session(CRASH, "10-29 18:40", LogOutcome::Crashed),
session(OK_RUN, "10-29 19:22", LogOutcome::Ok),
]));
// 第一场跑起来(live_session = CRASH)→ 日志 → 规则结论 → 收尾(selected_session = CRASH)
state.apply_core(CoreEvent::LogAppended(LiveLogBatch {
session: CRASH.to_string(),
lines: vec![line("[18:40] boom: GLFW error", LogLevel::Error)],
running: true,
}));
state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old")));
state.apply_core(CoreEvent::LaunchFinished(LaunchFinished {
id: "dev".to_string(),
session: CRASH.to_string(),
outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(),
}));
state
}
/// 崩溃之后又启动一场正常跑:`live_session` 前进,`diagnosis` 仍是上一场的。
fn after_a_new_run() -> AppState {
let mut state = crashed_only();
state.apply_core(CoreEvent::LogAppended(LiveLogBatch {
session: OK_RUN.to_string(),
lines: vec![line("[19:22] launching 1.20.4", LogLevel::Info)],
running: true,
}));
state
}
#[test]
fn a_new_run_does_not_inherit_the_previous_crash() {
let state = after_a_new_run();
let logs = logs_model(&state).with_status(Lang::En);
// 抓得住:身份被上一场的 `diagnosis.session` 顶掉(r2 的 bug)——那时 outcome 会是
// Crashed、findings 挂着 gpu.driver_old、摘要里 `lines=` 与结论不同源。
// 抓不住:presenter 自己 `clear_logs` 后 `selected_session` 不清的缺口(已报 finding),
// 以及多场并行会话(本仓一次只跑一场)。
assert_eq!(
logs.outcome,
Some(pages::Outcome::Ok),
"身份应该是正在跑的这一场"
);
let status = logs.status.clone().expect("有会话身份就该有副标题");
assert!(status.contains("Finished normally"), "{status}");
assert!(
!status.contains("The game crashed"),
"页头把新会话标成了上一场的崩溃:{status}"
);
assert!(
logs.findings.is_empty(),
"上一场的结论不能挂到这一场:{:?}",
logs.findings
);
// 摘要必须与身份同源:outcome 是 ok、不含旧规则、行数就是当前这些行。
let summary = logs.summary();
assert!(summary.contains("outcome=ok"), "{summary}");
assert!(!summary.contains("gpu.driver_old"), "{summary}");
assert!(
summary.contains(&format!("lines={}", logs.lines.len())),
"{summary}"
);
assert!(!summary.contains("findings="), "{summary}");
}
#[test]
fn the_crash_itself_is_still_reported() {
// 上一条测试的**对照**:崩溃当场必须画结论,否则那条断言「findings 为空」是空的。
// 抓得住:把判别写成「永远不画诊断」(用空断言骗过 P2)。
// 抓不住:结论排序/去重(那是 core 的规则引擎的事)。
let state = crashed_only();
let logs = logs_model(&state).with_status(Lang::En);
assert_eq!(logs.outcome, Some(pages::Outcome::Crashed));
assert_eq!(logs.findings.len(), 1, "{:?}", logs.findings);
assert_eq!(logs.findings[0].rule, "gpu.driver_old");
let status = logs.status.clone().unwrap_or_default();
assert!(status.contains("The game crashed"), "{status}");
assert!(logs.summary().contains("gpu.driver_old:high(92)"));
}
#[test]
fn an_imported_crash_file_still_shows_its_findings() {
// r3 的**回归**:r3 把归属判据写成「有选中就只认选中」,于是导入崩溃日志
// (`ImportCrashLog` → `DiagnosisReady` 带 `file:<文件名>` 的会话 id,且**不发**
// `LogLinesChanged`)被整条丢掉。今天 iced 侧还没有 dispatch 站点(那些还在遗留的
// Slint shell 里),所以复审没判 p1;这条测试是为了**将来接线的人**不再踩。
// 抓得住:只有「属于当前选中/live 会话」才画的窄判据(导入的 `file:` 会话不等于任何
// 索引里的会话,会被误杀)。
// 抓不住:导入后日志正文该显示什么(`ImportCrashLog` 不载入日志行,那是 presenter 的事)。
let mut state = AppState::new();
state.apply_core(CoreEvent::LogSessionsChanged(vec![session(
CRASH,
"10-29 18:40",
LogOutcome::Crashed,
)]));
// 上一场刚结束 ⇒ selected_session = CRASH(过期选中项),随后用户导入一份崩溃文件
state.apply_core(CoreEvent::LaunchFinished(LaunchFinished {
id: "dev".to_string(),
session: CRASH.to_string(),
outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(),
}));
state.apply_core(CoreEvent::DiagnosisReady(report(
"file:crash-2026-11-02_19.40.15-client.txt",
"integrity.unsatisfied_link",
)));
let logs = logs_model(&state).with_status(Lang::En);
assert_eq!(
logs.findings.len(),
1,
"导入的崩溃日志必须画出它的结论:{:?}",
logs.findings
);
assert_eq!(logs.findings[0].rule, "integrity.unsatisfied_link");
}
#[test]
fn a_live_run_says_it_is_running_instead_of_nothing_runs() {
// 视觉复审 r3 的 P2-3:session 索引只在**结束**时追加(`host::logs::sessions`),
// 所以运行中的那一场永远不在 `log_sessions` 里 ⇒ 拿不到实例名与结局 ⇒ r3 的页头在
// 每次实时运行都说「当前没有运行中的实例」。demo 伪造了身份,截图上根本照不出来。
// 抓得住:页头退回 Idle 文案(这次走真实 reducer:只有 `LogAppended(running=true)`)。
// 抓不住:文案长度在某尺寸下会不会撞右侧动作区(那要看截图)。
let mut state = AppState::new();
state.apply_core(CoreEvent::LogSessionsChanged(vec![session(
CRASH,
"10-29 18:40",
LogOutcome::Crashed,
)]));
state.apply_core(CoreEvent::LogAppended(LiveLogBatch {
session: OK_RUN.to_string(),
lines: vec![line("[19:22] launching", LogLevel::Info)],
running: true,
}));
let logs = logs_model(&state).with_status(Lang::En);
assert!(logs.live, "live_session 非空就该标记为运行中");
let status = logs.status.clone().expect("运行中必须有副标题");
let idle = unlucky_i18n::catalog::Catalog::of(Lang::En)
.text(unlucky_i18n::catalog::Key::LogsSubtitleIdle);
assert_ne!(status, idle, "运行中的页头不能说「没有运行」");
assert!(status.contains("Running now"), "{status}");
}
#[test]
fn the_hosts_no_rule_matched_placeholder_is_not_a_finding() {
// host 的 `open_session` 在规则引擎无结论时发一份 `rule: ""` 的假报告(非 Option 逼的)。
// 抓得住:把「没命中」画成一条严重度结论(用户会去修一个不存在的毛病)。
// 抓不住:host 该不该改发 `None`(那是 presenter 的 finding)。
let mut state = AppState::new();
state.apply_core(CoreEvent::LogSessionsChanged(vec![session(
CRASH,
"10-29 18:40",
LogOutcome::Crashed,
)]));
state.apply_core(CoreEvent::LogLinesChanged(vec![line(
"[18:40] nothing to see",
LogLevel::Info,
)]));
state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "")));
assert!(
logs_model(&state).findings.is_empty(),
"空 rule 是『没命中』的占位,不是结论"
);
state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old")));
assert_eq!(
logs_model(&state).findings.len(),
1,
"换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)"
);
}
+35
View File
@@ -0,0 +1,35 @@
//! 导航轨底部**账户头像位**的状态换算(M114)。
//!
//! 为什么单独一个文件:`app/view.rs` 贴着 400 行硬线,而这块逻辑与页面树无关——
//! 它只回答「轨底那个头像位现在该显示成未登录,还是某个账户」。
use crate::shell;
use super::super::state::App;
/// 导航轨收成「图标条」的窗口高度(M114)。
///
/// 判据不是「项数」而是**真实可用高度**:四项 + 底部头像位在 48 标题栏之上还要放得下
/// 4 × 52 + 4 × 4 + 52 ≈ 300;留出一屏正文的最小高度(424 行宽 + 页头 + 边距),
/// 低于 720 就把标签收掉,只留图标与头像位(头像位**永远可见**,任务要求)。
const RAIL_COMPACT_HEIGHT: f32 = 720.0;
impl App {
/// 导航轨底部头像位的状态(M114):未登录 / 已登录两态要一眼能分。
pub(super) fn rail_account(&self) -> shell::RailAccount {
let compact = self.height < RAIL_COMPACT_HEIGHT;
match self.state.active_account() {
Some(account) => {
shell::RailAccount::signed_in(account.monogram(), account.name.clone())
.compact(compact)
}
// M106:未登录标签跟着**生效语言**走(早期钉死 `Lang::En` 的写法已删)。
None => shell::RailAccount::signed_out(
self.lang()
.catalog()
.text(unlucky_i18n::Key::AccountRailSignedOut),
)
.compact(compact),
}
}
}
+320
View File
@@ -0,0 +1,320 @@
//! `app/view.rs` 的守门测试(rebase 到 5adac23 之后 view.rs 贴 400 行上限,按「测试独立成文件」拆出)。
use super::*;
use unlucky_i18n::Key;
use unlucky_presenter::dto::{
InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress,
};
use unlucky_presenter::mock_data::mock_instances;
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary {
id: "survival".to_string(),
name: "Survival".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: Some("2026-10-29T19:22:00Z".to_string()),
runtime,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
}
}
/// **取证不变量(M100)**:`ForcedState` 的三档在产品路径上必须映射成「页面控件怎么画」。
///
/// 抓得住:① 谁把 `Rest` 映射成 Hovered/Pressed(那会让 `--screenshot` 的默认值反而画出状态层,
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿 / **给定语言**。
fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang) -> pages::Model {
let draft = pages::InstallDraft::default();
let page = pages::content::PageState::default();
model(
state,
1180.0,
forced,
pages::Category::All,
&draft,
lang,
&page,
)
}
/// 同 [`at_lang`],语言取英文(绝大多数断言只关心「快照 → 模型」的映射,不关心译文)。
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
at_lang(state, forced, Lang::En)
}
#[test]
fn the_forced_state_maps_to_the_expected_interaction() {
let state = AppState::new();
let forced = |forced| at(&state, Some(forced)).forced;
assert_eq!(
forced(crate::cli::ForcedState::Rest),
Some(Interaction::Enabled),
"Rest = 静息态(压掉系统光标 hover)"
);
assert_eq!(
forced(crate::cli::ForcedState::Hover),
Some(Interaction::Hovered)
);
assert_eq!(
forced(crate::cli::ForcedState::Pressed),
Some(Interaction::Pressed)
);
assert_eq!(at(&state, None).forced, None);
}
#[test]
fn the_snapshot_becomes_the_page_model() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()),
));
state.navigate(Route::Detail("survival".to_string()));
let model = at(&state, None);
assert_eq!(model.screen, pages::Screen::Instances);
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
assert_eq!(model.window_width, 1180.0);
assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例");
assert_eq!(model.instances.rows[0].id, "survival");
let detail = model.instances.detail.expect("路由是 Detail,要有详情");
assert_eq!(detail.id, "survival");
assert_eq!(detail.meta.len(), 6);
assert!(detail.can_launch, "空闲实例可启动");
assert!(!detail.can_stop);
assert!(detail.note.is_none(), "真快照没有正文段");
}
#[test]
fn a_busy_instance_offers_stop_instead_of_play() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance(
InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: 750,
total: 1_500,
},
},
)]),
));
state.navigate(Route::Detail("survival".to_string()));
let model = at(&state, None);
assert_eq!(
model.instances.rows[0].status.as_deref(),
Some("Downloading · 50%")
);
let detail = model.instances.detail.expect("有详情");
assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)");
assert!(detail.can_stop, "启动中要能 Stop");
}
#[test]
fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · exit 1")
);
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
#[test]
fn timestamps_are_sliced_not_guessed() {
let en = Lang::En.catalog();
assert_eq!(
last_played(Some("2026-10-29T19:22:00Z"), en),
"2026-10-29 19:22 UTC"
);
assert_eq!(last_played(None, en), "Never");
// 短串 / 非 ASCII 原样返回(不 panic)
assert_eq!(last_played(Some("2026-10"), en), "2026-10");
assert_eq!(last_played(Some("昨天"), en), "昨天");
// 「从未游玩」是唯一走词表的那一支
assert_eq!(last_played(None, Lang::ZhCn.catalog()), "从未游玩");
}
/// **快照 → 页面文案跟着 `App::lang` 走**(M120 的核心接线断言,模型这一层)。
///
/// 抓得住:`view::model` 又把语言写死成 `Lang::En`(M106 之前的原样)、阶段/元信息标签还留着
/// 英文基线字面量、原版徽标直接画 presenter 的中文「原版」。这三种都不会让任何别的测试红。
/// 抓不住:页面把 key 画没画上屏(那是 `pages/instances/tests/i18n.rs` 的取样口 + 出图)。
#[test]
fn the_runtime_lines_and_meta_labels_follow_the_language() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![
instance(InstanceRuntime::Running { pid: 1234 }),
instance(InstanceRuntime::Busy {
stage: LaunchStage::Verify,
progress: Progress {
done: 750,
total: 1_500,
},
}),
]),
));
state.navigate(Route::Detail("survival".to_string()));
let en = at_lang(&state, None, Lang::En);
let zh = at_lang(&state, None, Lang::ZhCn);
// 列表 overline:中英各一份,数字与分隔符不动
assert_eq!(
en.instances.rows[0].status.as_deref(),
Some("Running · pid 1234")
);
assert_eq!(
zh.instances.rows[0].status.as_deref(),
Some("运行中 · pid 1234")
);
assert_eq!(
en.instances.rows[1].status.as_deref(),
Some("Verifying · 50%")
);
assert_eq!(zh.instances.rows[1].status.as_deref(), Some("校验中 · 50%"));
// 元信息卡的标签与值
let (en_detail, zh_detail) = (
en.instances.detail.expect("有详情"),
zh.instances.detail.expect("有详情"),
);
let labels = |detail: &pages::Detail| -> Vec<String> {
detail.meta.iter().map(|cell| cell.label.clone()).collect()
};
assert_eq!(
labels(&en_detail),
vec![
"VERSION",
"LOADER",
"ISOLATION",
"VERSION ID",
"FILES",
"LAST PLAYED"
]
);
assert_eq!(
labels(&zh_detail),
vec!["版本", "加载器", "隔离方式", "版本标识", "文件", "上次游玩"]
);
// 值里只有「是否在磁盘上」与「上次游玩」是可翻译的;版本号 / 隔离方式 / 版本标识是数据
// 隔离档位是 core 的稳定 id(`isolated` / `shared`),也要换成人话;认不出来的原样上屏
assert_eq!(en_detail.meta[2].value, "Isolated");
assert_eq!(zh_detail.meta[2].value, "独立目录");
assert_eq!(en_detail.meta[4].value, "Present on disk");
assert_eq!(zh_detail.meta[4].value, "已在磁盘上");
assert_eq!(
en_detail.meta[5].value, "2026-10-29 19:22 UTC",
"时间戳是数据:只切片,不随语言变形"
);
assert_eq!(zh_detail.meta[5].value, "2026-10-29 19:22 UTC");
// 详情状态行与列表 overline 同源
assert_eq!(zh_detail.status.as_deref(), Some("运行中 · pid 1234"));
}
/// 原版徽标:`LoaderInfo::badge()` 对原版返回**中文**「原版」,英文界面里必须换回 `Vanilla`。
///
/// 抓得住:`loader_badge` 退回直接调 `badge()`(M90 ~ M119 的形状)——那样英文界面会漏中文,
/// 而中文界面又是对的,所以只有这条双语断言抓得住它。
#[test]
fn the_vanilla_badge_follows_the_language() {
let mut bare = instance(InstanceRuntime::Idle);
bare.loader = LoaderInfo {
kind: LoaderKind::Vanilla,
version: String::new(),
};
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![bare]),
));
state.navigate(Route::Detail("survival".to_string()));
let en = at_lang(&state, None, Lang::En);
let zh = at_lang(&state, None, Lang::ZhCn);
assert_eq!(en.instances.rows[0].supporting, "1.20.4 · Vanilla");
assert_eq!(zh.instances.rows[0].supporting, "1.20.4 · 原版");
}
/// **品牌名不许被「版本号为空」吞掉**(M120 r1 复审的 p2-1 回归)。
///
/// 背景:`host::game::loader_from_id("1.20.1-forge")` 这类 id 里取不到版本段(它按 `forge-`
/// 找,而 id 的写法是 `-forge` 结尾),于是 `LoaderInfo { kind: Forge, version: "" }`。
/// `LoaderInfo::badge()` 对这种输入给的正是品牌名 `Forge`——所以判据只能是「类型是原版」,
/// 不能是「版本号为空」。
///
/// 三个 id 各一条断言(复审点名的三档);`Vanilla` 那一支由
/// `the_vanilla_badge_follows_the_language` 守,两条合起来把 `loader_badge` 的两侧都钉住。
///
/// 抓得住:`loader_badge` 把「非原版 + 空版本号」也送进 `Key::LoaderVanilla`
/// (r1 的真实形状:`left: "1.20.4 · Vanilla" / right: "1.20.4 · Forge"`);
/// 抓不住:`loader_from_id` 认不出自家 `-fabric-` / `-quilt-` 口径这个**更老**的 bug
/// (在 presenter 里,另一条 mission)。
#[test]
fn the_loader_badge_keeps_the_brand_when_the_version_is_missing() {
// (版本 id, 期望徽标) —— id 形状取自 `host::game::loader_from_id` 的三条真实输入
let cases = [
("1.20.1-forge", LoaderKind::Forge, "Forge"),
("1.20.1-neoforge", LoaderKind::NeoForge, "NeoForge"),
("1.20.1-OptiFine", LoaderKind::OptiFine, "OptiFine"),
];
for (id, kind, brand) in cases {
let mut one = instance(InstanceRuntime::Idle);
one.loader = LoaderInfo {
kind,
// `loader_from_id` 对这三条 id 取不到版本段 ⇒ 空串
version: String::new(),
};
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![one]),
));
state.navigate(Route::Detail("survival".to_string()));
// 品牌名两套语言都不翻译 ⇒ 中英逐字相同
for lang in [Lang::En, Lang::ZhCn] {
let model = at_lang(&state, None, lang);
assert_eq!(
model.instances.rows[0].supporting,
format!("1.20.4 · {brand}"),
"{id} 的徽标被显示成了别的东西(新增的判据「版本号为空 ⇒ 原版」就是这条要拦的)"
);
let detail = model.instances.detail.expect("有详情");
assert_eq!(
detail.meta[1].label,
lang.catalog().text(Key::InstancesMetaLoader)
);
assert_eq!(detail.meta[1].value, brand, "{id} 的 LOADER 卡值");
}
}
}
#[test]
fn the_agent_route_has_no_page() {
let mut state = AppState::new();
state.navigate(Route::Agent);
let model = at(&state, None);
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
assert_eq!(pages::Screen::of(state.route()), None);
assert!(model.instances.detail.is_none());
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
}
+134
View File
@@ -0,0 +1,134 @@
//! 整窗 α 的**执行侧**:句柄探测 → `WS_EX_LAYERED` → `SetLayeredWindowAttributes` → 一行日志。
//!
//! ## 为什么句柄要绕一圈 iced
//!
//! iced 0.14 只给 `window::Id`(它自己的编号),没有「给我 HWND」的公开 API;唯一的入口是
//! `window::run(id, callback)`——回调里拿到的 `&dyn Window` 实现了 `HasWindowHandle`
//! (raw-window-handle 0.6),于是能取到 `RawWindowHandle::Win32(hwnd)`。
//!
//! ## 三层责任(顺序不能换)
//!
//! 1. **探测句柄**:[`probe_hwnd`],纯取;拿不到就整条链跳过(并如实打日志,不许假装成功);
//! 2. **补分层样式**:`ensure_layered`(唯一 `unsafe` 落点,`platform/win32/transparency.rs`);
//! `SetLayeredWindowAttributes` 在没有 `WS_EX_LAYERED` 的窗口上**必然失败**(Win32 返回 0,
//! 本机 `GetLastError` 常见 87),所以这一步是硬前提;
//! 3. **下发 α**:`apply_window_alpha`。`α ≥ 1` 时它返回 `Opaque` 并且**不调 FFI**,那时也就不必
//! 改扩展样式(1、2 步都是白改),[`dispatch`] 里按这个条件短路。
//!
//! ## 为什么日志要记「下发字节」而不是「看起来多透明」
//!
//! 本机是 RDP + 4 个虚拟显示适配器:`LWA_ALPHA` 的**观感**在这里不可信。所以证据链是
//! `配置值 → 判据 → 字节 → Win32 返回值`,全部经 [`super::App::log_appearance`] 落成一行文本。
use iced::window;
use crate::platform::win32::transparency::{apply_window_alpha, ensure_layered, AlphaOutcome};
/// 一次「补样式 + 下发」的结果(日志与单测共用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Dispatch {
/// `WS_EX_LAYERED` 是否就位(`α ≥ 1` 或 `α = None` 时为 `false`:那次根本没动扩展样式)。
pub layered: bool,
/// 下发的结局。
pub alpha: AlphaOutcome,
}
impl Dispatch {
/// 日志尾巴:`layered=ok outcome=Applied { byte: 205 }`。
///
/// `layered` 有三个值,刻意区分「没用到」与「失败」:
/// - `n/a`:α ≥ 1 或被拒绝 —— 那次**根本不需要**分层窗口,不是错误;
/// - `ok`:这一位在(原本就有或本条补上);
/// - `FAILED`:需要补但补不上(无效句柄等)—— 只有这一种是坏消息。
pub(super) fn describe(&self) -> String {
let layered = match self.alpha {
AlphaOutcome::Opaque | AlphaOutcome::Rejected(_) => "n/a",
AlphaOutcome::Applied { .. } | AlphaOutcome::Failed { .. } if !self.layered => "FAILED",
_ => "ok",
};
format!("layered={layered} outcome={:?}", self.alpha)
}
}
/// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。
pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> {
use window::raw_window_handle::RawWindowHandle;
let handle = handle.window_handle().ok()?;
match handle.as_raw() {
#[cfg(target_os = "windows")]
RawWindowHandle::Win32(win32) => Some(win32.hwnd.get()),
#[cfg(not(target_os = "windows"))]
_ => None,
#[cfg(target_os = "windows")]
_ => None,
}
}
/// 补样式 + 下发(**幂等**:`α ≥ 1` 时什么都不做,返回 `Opaque`)。
pub(super) fn dispatch(hwnd: isize, alpha: Option<f64>) -> Dispatch {
// α 不透明 / 被拒绝时不需要分层窗口:`SetLayeredWindowAttributes` 那次调用也不会发生,
// 改扩展样式纯属副作用(还会让 DWM 换一条合成路径),所以短路掉。
let needs_layered = alpha.is_some_and(|alpha| alpha < 1.0);
let layered = needs_layered && ensure_layered(hwnd);
Dispatch {
layered,
alpha: apply_window_alpha(hwnd, alpha),
}
}
#[cfg(test)]
mod tests {
use super::dispatch;
use crate::platform::win32::transparency::{AlphaOutcome, AlphaRejection};
/// 抓得住:①`α = None` / `α ≥ 1` / `α = 0` 时**不去碰扩展样式**(短路条件写反会在这里红);
/// ②拒绝路径的返回值与 `transparency` 的判据一致。
/// 抓不住:真实窗口(有效句柄)上 `WS_EX_LAYERED` 是否真的加上了——那要靠本机日志的 `layered=ok`。
#[test]
fn opaque_or_refused_alpha_never_touches_the_window_style() {
for none in [None, Some(1.0), Some(1.5), Some(0.0), Some(-1.0)] {
let result = dispatch(1234, none);
assert!(!result.layered, "{none:?} 不该改扩展样式");
}
assert_eq!(
dispatch(1234, None).alpha,
AlphaOutcome::Rejected(AlphaRejection::NoAlpha)
);
assert_eq!(dispatch(1234, Some(1.0)).alpha, AlphaOutcome::Opaque);
// 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。
let invalid = dispatch(1, Some(0.5));
assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED");
assert!(
matches!(invalid.alpha, AlphaOutcome::Failed { .. }),
"{:?}",
invalid.alpha
);
}
/// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`),
/// 这里只钉日志格式:`Applied { byte: 205 }` 必须能一眼读出字节,且「没用到样式」不等于失败。
#[test]
fn the_log_tail_carries_the_byte_and_distinguishes_skipped_from_failed() {
let applied = super::Dispatch {
layered: true,
alpha: AlphaOutcome::Applied { byte: 205 },
};
assert!(applied.describe().starts_with("layered=ok outcome=Applied"));
assert!(applied.describe().contains("205"));
// α = 1.0:没补样式是**正常**的,不许写成 FAILED(否则报告里会出现假红)。
let opaque = super::Dispatch {
layered: false,
alpha: AlphaOutcome::Opaque,
};
assert!(opaque.describe().starts_with("layered=n/a"));
// 真的要补却没补上:这才是 FAILED。
let failed = super::Dispatch {
layered: false,
alpha: AlphaOutcome::Failed { code: 87 },
};
assert!(failed.describe().starts_with("layered=FAILED"));
}
}
+287
View File
@@ -0,0 +1,287 @@
//! 命令行参数(骨架期 + 组件库 harness 需要的那些;业务开关等页面 mission 再加)。
//!
//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认
//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。
//!
//! ## 参数总表
//!
//! | 参数 | 取值 | 语义 |
//! |---|---|---|
//! | `--software-renderer` | — | 等价 `--renderer=cpu`(D27 的 CPU 档) |
//! | `--renderer` | `auto`/`gpu`/`cpu` | 渲染后端请求 |
//! | `--theme` | `light`/`dark` | 主题;不给则跟随系统(仅交互模式) |
//! | `--screenshot` / `--screenshot-theme` | 路径 / `light`·`dark` | 截图通道(出图后退出)与截图专用主题 |
//! | `--screen` | `shell`/`gallery` | 启动画面;**`gallery` = 组件库视觉验收 harness**(D39 §5) |
//! | `--scale-factor` | `>0` | 强制缩放因子(验收要 100%,即 `1`) |
//! | `--diff` / `--diff-scan` | 两个路径 / 非负整数 | 逐像素对比(不开窗)与 ±n px 平移扫描 |
//! | `--force-state` | `rest`/`hover`/`pressed` | 把整屏可交互控件强制成某态(取证);**给了 `--screenshot` 就默认 `rest`** |
//! | `--data-dir` | 路径 | 翻牌兼容:**接受并忽略**(CI 冒烟在用) |
//!
//! **截图主题默认 Light(门禁依赖,不许改回 None)**,理由与不变量见
//! [`Options::capture_flavor`] 与 `app.rs` 的截图路径。
use std::path::PathBuf;
use unlucky_design::color::SchemeFlavor;
/// 渲染后端请求(D27)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Backend {
/// 探测后决定:有硬件 GPU 就让 iced 自己走 wgpu→tiny-skia 回退;没有就强制 CPU 起步。
#[default]
Auto,
/// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`;拿不到适配器仍会降级,不硬失败)。
Gpu,
/// 强制 CPU(`--software-renderer` 的等价物,写 `ICED_BACKEND=tiny-skia`)。
Cpu,
}
impl Backend {
/// 稳定名字(日志用)。
pub const fn name(self) -> &'static str {
match self {
Backend::Auto => "auto",
Backend::Gpu => "gpu",
Backend::Cpu => "cpu",
}
}
/// 截图日志用的后端说明(`cfg!(feature = "gpu")` 决定有没有 wgpu 这一档)。
pub fn note(self) -> &'static str {
if !cfg!(feature = "gpu") {
return "cpu(build without wgpu)";
}
match self {
Backend::Cpu => "cpu(forced)",
Backend::Gpu => "gpu(preferred)",
Backend::Auto => "auto",
}
}
}
/// 启动画面(D39 §5:`gallery` 是视觉验收 harness,不是产品页面)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Screen {
/// 产品骨架:自绘标题栏 + 导航轨 + 内容区。
#[default]
Shell,
/// 组件库 harness:整窗只放状态矩阵。
Gallery,
}
impl Screen {
/// 稳定名字(日志用)。
pub const fn name(self) -> &'static str {
match self {
Screen::Shell => "shell",
Screen::Gallery => "gallery",
}
}
}
/// 解析后的启动参数。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Options {
/// 渲染后端请求。
pub backend: Backend,
/// 主题:`None` = 探测系统主题。
pub theme: Option<SchemeFlavor>,
/// 截图通道:给了路径就出图后退出。
pub screenshot: Option<PathBuf>,
/// `--screenshot-theme`(优先级高于 `--theme`)。
pub screenshot_theme: Option<SchemeFlavor>,
/// 强制窗口缩放因子(验收要 100% DPI,即 `1`;`None` = 用系统 DPI)。
pub scale_factor: Option<f32>,
/// `--diff <left> <right>` 与 `--diff-scan <n>`:逐像素对比(不开窗)与 ±n px 平移扫描。
pub diff: Option<(PathBuf, PathBuf)>,
/// 见上(默认 0 = 不扫)。
pub diff_scan: u32,
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
pub force_state: Option<ForcedState>,
/// `--screen=shell|gallery`:启动画面(`gallery` = 组件库视觉验收 harness)。
pub screen: Screen,
/// `--data-dir <dir>`:当前**接受并忽略**(现役 Slint 版 CI 冒烟在用;
/// 真正的消费由后续页面 mission 接 `host`/`state` 时做)。
pub data_dir: Option<PathBuf>,
}
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
///
/// 五态完整矩阵由 `--screen=gallery` 的 `Button::state(..)` 出,本开关只给整屏取证。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForcedState {
/// **常态**(静息):压掉系统光标带来的 hover/pressed。
///
/// 截图通道(给了 `--screenshot`)**默认就是它**,理由是实测:M99 复审在同一个 exe 上连跑 8 次,
/// 第 6 次给出第三个 hash,差异区是某个按钮被画成 `Hovered`——因为 harness/产品都开真窗口,
/// `button::Status` 由系统光标位置决定。取证图不能因为「指针恰好压在某个按钮上」而变色。
/// 显式 `--force-state=hover|pressed` 仍然优先(那是**故意**要画那个态)。
///
/// ⚠️ **语义边界**:`Rest` 落成 `Interaction::Enabled`,而 `components::buttons::assemble()`
/// 把「没有 `on_press`」的按钮默认画成 **Disabled** —— 于是 `Rest` 会把一个本应禁用的按钮
/// 画成可用。当前零影响(所有 `forced_state(` 调用点与 shell 的 caption / nav 按钮都带消息),
/// 但给「无消息按钮」出取证图时别套 `Rest`。
Rest,
/// 所有可交互控件按 hover 画。
Hover,
/// 所有可交互控件按 pressed 画。
Pressed,
}
impl Options {
/// 从进程参数解析(错误信息是人话,不 panic、不 `exit`)。
///
/// # Errors
/// 取值不合法(未知 `--renderer`/`--theme`/`--screen`、非法 `--scale-factor`)时返回人话。
pub fn from_env() -> Result<Self, String> {
Self::parse(std::env::args().skip(1))
}
/// 从任意参数序列解析(`argv[1..]`,测试直接喂数组)。
///
/// # Errors
/// 同 [`Options::from_env`]。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut options = Self::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let (key, inline) = match arg.split_once('=') {
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
None => (arg.clone(), None),
};
match key.as_str() {
"--software-renderer" => options.backend = Backend::Cpu,
"--renderer" => {
let value = take(&mut args, inline, "--renderer")?;
options.backend = match value.as_str() {
"auto" => Backend::Auto,
"gpu" => Backend::Gpu,
"cpu" => Backend::Cpu,
other => {
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"));
}
};
}
"--theme" => {
options.theme = Some(flavor(&take(&mut args, inline, "--theme")?)?);
}
"--screenshot-theme" => {
options.screenshot_theme =
Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
}
"--screenshot" => {
options.screenshot =
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
}
"--scale-factor" => {
let raw = take(&mut args, inline, "--scale-factor")?;
let value: f32 = raw
.parse()
.map_err(|_| format!("--scale-factor 需要数字,收到 {raw:?}"))?;
if !(value.is_finite() && value > 0.0) {
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
}
options.scale_factor = Some(value);
}
"--diff" => {
let left = take(&mut args, inline, "--diff")?;
let right = args
.next()
.ok_or_else(|| "--diff 需要两张图的路径".to_owned())?;
options.diff = Some((PathBuf::from(left), PathBuf::from(right)));
}
"--diff-scan" => {
let raw = take(&mut args, inline, "--diff-scan")?;
options.diff_scan = raw
.parse()
.map_err(|_| format!("--diff-scan 需要非负整数,收到 {raw:?}"))?;
}
"--force-state" => {
let raw = take(&mut args, inline, "--force-state")?;
options.force_state = Some(match raw.as_str() {
"rest" => ForcedState::Rest,
"hover" => ForcedState::Hover,
"pressed" => ForcedState::Pressed,
other => {
return Err(format!(
"--force-state 只认 rest|hover|pressed,收到 {other:?}"
));
}
});
}
"--screen" => {
let raw = take(&mut args, inline, "--screen")?;
options.screen = match raw.as_str() {
"shell" => Screen::Shell,
"gallery" => Screen::Gallery,
other => {
return Err(format!("--screen 只认 shell|gallery,收到 {other:?}"));
}
};
}
// 翻牌前的兼容参数:接受并忽略(见字段文档)
"--data-dir" => {
options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?));
}
other => return Err(format!("未知参数 {other:?}")),
}
}
// **截图通道默认强制 `Rest`**(M100):取证图不许跟着系统指针的位置变。
// 交互模式保持 `None`——那是用户的真窗口,不许替用户把状态钉死(钉死了就永远看不到 hover)。
if options.screenshot.is_some() && options.force_state.is_none() {
options.force_state = Some(ForcedState::Rest);
}
Ok(options)
}
/// 截图时实际使用的主题(**只表达用户显式给了什么**;默认值见 [`Options::capture_flavor`])。
pub fn shot_flavor(&self) -> Option<SchemeFlavor> {
self.screenshot_theme.or(self.theme)
}
/// 本次进程真正要画的主题(**唯一取值口**)。
///
/// 截图模式(给了 `--screenshot`)**永远返回 `Some`**:`--screenshot-theme` > `--theme` > Light。
/// 交互模式只有显式指定才有值(`None` = 等系统主题)。
///
/// ⚠️ **门禁依赖,不许把截图分支改回 `None`**:CI 的 runtime-smoke 打
/// `--software-renderer --screenshot <png> --data-dir <dir>`(不带主题),xvfb 里没有桌面主题
/// 会话,M68 的「等系统主题到达再出图」闸门在这条路径上会把 runner 挂到超时(不是红,更难查)。
pub fn capture_flavor(&self) -> Option<SchemeFlavor> {
match self.screenshot {
Some(_) => Some(self.shot_flavor().unwrap_or(SchemeFlavor::Light)),
None => self.shot_flavor(),
}
}
/// 是否要问系统主题。截图模式**永远不问**——这就是「无主题环境不会卡住」的不变量。
pub fn wants_system_theme(&self) -> bool {
self.capture_flavor().is_none()
}
}
fn take(
args: &mut impl Iterator<Item = String>,
inline: Option<String>,
flag: &str,
) -> Result<String, String> {
inline
.or_else(|| args.next())
.ok_or_else(|| format!("{flag} 需要一个值"))
}
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
match raw {
"light" => Ok(SchemeFlavor::Light),
"dark" => Ok(SchemeFlavor::Dark),
other => Err(format!("--theme 只认 light|dark,收到 {other:?}")),
}
}
#[cfg(test)]
#[path = "cli/tests.rs"]
mod tests;
+172
View File
@@ -0,0 +1,172 @@
//! `cli::Options` 的解析断言(M100 从 `cli.rs` 内联的 `mod tests` 搬出来:那边 394 行,
//! 加一个 `ForcedState::Rest` 就必然破 400)。
//!
//! 搬移**不改一行断言**:只去掉了 `mod tests { }` 那一层缩进,外加 M100 新增的
//! `screenshots_default_to_the_rest_state`(见文件末尾)。
//!
//! 站位:本文件是 `crate::cli::tests`,`use super::*;` 就够(`Options` / `ForcedState` /
//! `Backend` / `Screen` 都在 `cli` 里)。
use super::*;
fn parse(args: &[&str]) -> Result<Options, String> {
Options::parse(args.iter().map(|arg| (*arg).to_owned()))
}
#[test]
fn defaults_are_auto_system_theme_and_shell() {
let options = parse(&[]).expect("空参数要能解析");
assert_eq!(options.backend, Backend::Auto);
assert_eq!((options.theme, options.screenshot), (None, None));
assert_eq!(options.screen, Screen::Shell);
}
#[test]
fn software_renderer_is_an_alias_of_cpu() {
assert_eq!(
parse(&["--software-renderer"]).unwrap().backend,
Backend::Cpu
);
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
assert_eq!(parse(&["--renderer", "gpu"]).unwrap().backend, Backend::Gpu);
}
#[test]
fn both_argv_forms_are_accepted() {
let spaced = parse(&["--screenshot", "out.png"]).unwrap();
let inline = parse(&["--screenshot=out.png"]).unwrap();
assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png")));
assert_eq!(spaced.screenshot, inline.screenshot);
assert_eq!(
parse(&["--diff", "a.png", "b.png"]).unwrap().diff,
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
);
assert_eq!(
parse(&["--screen", "gallery"]).unwrap().screen,
Screen::Gallery
);
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
assert_eq!(
parse(&["--force-state", "hover"]).unwrap().force_state,
Some(ForcedState::Hover)
);
}
#[test]
fn screenshot_theme_wins_over_theme() {
let o = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
assert_eq!(
(o.theme, o.shot_flavor()),
(Some(SchemeFlavor::Dark), Some(SchemeFlavor::Light))
);
}
#[test]
fn bad_values_are_reported_not_panicked() {
for args in [
&["--renderer=metal"][..],
&["--theme=blue"],
&["--scale-factor=0"],
&["--scale-factor=abc"],
&["--screenshot"],
&["--diff", "only-one.png"],
&["--screen=settings"],
&["--force-state=focus"],
&["--diff-scan=-1"],
&["--nope"],
] {
assert!(parse(args).is_err(), "{args:?} 应该报错");
}
}
#[test]
fn data_dir_is_accepted_and_ignored_for_the_flip() {
// 现役 CI 冒烟:--software-renderer --screenshot <png> --data-dir <dir>
let options = parse(&[
"--software-renderer",
"--screenshot",
"shot.png",
"--data-dir",
"/d/tmp/x",
])
.expect("翻牌前必须兼容 --data-dir");
assert_eq!(options.backend, Backend::Cpu);
assert_eq!(options.screenshot, Some(PathBuf::from("shot.png")));
assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x")));
}
/// **取证不变量(M100)**:截图通道默认 Rest,交互模式保持 `None`,显式要态仍然优先。
///
/// 抓得住:① 谁把截图默认值改回 `None`(系统指针 hover 会重新漏进图,M99 复审实测翻盘过一次);
/// ② 谁顺手在交互模式也钉死状态(那会让真窗口永远看不到 hover)。
/// 抓不住:**产品页**上是否还有不消费 forced 的控件——那要出图比对,测试管不了。
#[test]
fn screenshots_default_to_the_rest_state() {
assert_eq!(
parse(&["--force-state=rest"]).unwrap().force_state,
Some(ForcedState::Rest),
"rest 是显式的合法取值"
);
assert_eq!(
parse(&["--screenshot", "out.png"]).unwrap().force_state,
Some(ForcedState::Rest),
"截图通道必须默认 Rest"
);
assert_eq!(
parse(&["--screenshot", "o.png", "--force-state=hover"])
.unwrap()
.force_state,
Some(ForcedState::Hover),
"显式要态时按用户给的画(状态矩阵图靠它)"
);
assert_eq!(
parse(&["--screenshot", "o.png", "--force-state=pressed"])
.unwrap()
.force_state,
Some(ForcedState::Pressed)
);
assert_eq!(
parse(&[]).unwrap().force_state,
None,
"交互模式不许替用户钉死状态"
);
assert_eq!(parse(&["--theme=dark"]).unwrap().force_state, None);
}
/// **门禁依赖**:CI 的 runtime-smoke 不带主题,截图模式必须有确定主题。
/// 退化成 `None` 的话,xvfb 里的 runner 会挂到超时(不是红,更难查)。
#[test]
fn screenshot_without_theme_defaults_to_light() {
let options = parse(&["--screenshot", "out.png"]).unwrap();
assert_eq!(options.shot_flavor(), None, "用户没给主题");
assert_eq!(
options.capture_flavor(),
Some(SchemeFlavor::Light),
"截图模式必须给确定主题(CI 冒烟依赖这条)"
);
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
}
#[test]
fn explicit_themes_still_win_in_screenshot_mode() {
let dark = parse(&["--screenshot", "a.png", "--theme=dark"]).unwrap();
assert_eq!(dark.capture_flavor(), Some(SchemeFlavor::Dark));
let both = parse(&[
"--screenshot",
"a.png",
"--theme=dark",
"--screenshot-theme=light",
]);
assert_eq!(both.unwrap().capture_flavor(), Some(SchemeFlavor::Light));
}
#[test]
fn interactive_mode_still_follows_the_system_theme() {
let interactive = parse(&[]).unwrap();
assert_eq!(interactive.capture_flavor(), None);
assert!(interactive.wants_system_theme(), "交互模式等系统主题");
let pinned = parse(&["--theme=dark"]).unwrap();
assert_eq!(pinned.capture_flavor(), Some(SchemeFlavor::Dark));
assert!(!pinned.wants_system_theme());
}
Loaded 100 of 1079 files, more files were not shown because too many files have changed in this diff. Show more