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
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:
commit
8a8e5ca0c0
1079 files changed
+208035
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
edition = "2021"
|
||||||
|
max_width = 100
|
||||||
|
newline_style = "Unix"
|
||||||
|
use_field_init_shorthand = true
|
||||||
|
use_try_shorthand = true
|
||||||
Generated
+6343
File diff suppressed because it is too large.
Load diff
+164
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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,特此致谢。
|
||||||
@@ -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
@@ -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 玩家走了很多年,
|
||||||
|
本项目在设计取舍与实现细节上都从中受益良多。
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(®istry),
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("对话历史为空"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(®istry);
|
||||||
|
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(®istry).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(®istry()) {
|
||||||
|
let value = Value::Object((*attr.input_schema).clone());
|
||||||
|
assert_eq!(value["type"], json!("object"), "{}", attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(®istry)
|
||||||
|
};
|
||||||
|
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(®istry), 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(®istry(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(®istry(Arc::new(MockAccounts::empty())));
|
||||||
|
assert_eq!(content["count"], json!(0));
|
||||||
|
assert_eq!(content["accounts"], json!([]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("磁盘只读"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("需要更多信息"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(®istry(), 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(®istry(), 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(®istry(), 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 目录不可读"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("不存在"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! **双投影契约测试(MCP 侧)**:注册表 → rmcp `Tool` 必须与 rig 侧逐项相等。
|
||||||
|
//!
|
||||||
|
//! research/02 §6.2 建议进 CI 的那条断言,M16 落的是「注册表自身是纯投影」+「rig 适配层
|
||||||
|
//! 只做直投」,M39 补上 rmcp 侧:
|
||||||
|
//!
|
||||||
|
//! 1. `mcp::projection::tool_attrs(®istry)` 的 `(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(®istry);
|
||||||
|
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(®istry)
|
||||||
|
.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(®istry()) {
|
||||||
|
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(®istry).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()));
|
||||||
|
}
|
||||||
@@ -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(®istry)
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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(®istry),
|
||||||
|
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 })
|
||||||
|
}
|
||||||
@@ -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, "对话历史不能带凭据明文");
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] 外观未落盘(数据根解析不出来,内存态运行)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 优先级最高,系统主题不许盖"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`:消息漏斗在某一环断了"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
"换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 页不高亮任何导航项");
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
Reference in new issue
Block a user