Unlucky Launcher v0.1.0
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run

单提交快照:main 只保留已发布版本的代码树,开发史在 dev。
This commit is contained in:
AskaEth committed 2026-11-01 20:02:49 +08:00
commit 5f8382565e
570 files changed
+104468

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
# 换行策略:仓库内一律 LF(rustfmt 设 newline_style = "Unix",CI 在 Linux 上跑 fmt --check)。
# eol=lf 对工作区同样生效 —— Windows 克隆也不会被转换成 CRLF,避免「本地 fmt 过、CI fmt 挂」。
* text=auto eol=lf
# 明确为文本(显式列出,防止被误判成二进制)
*.rs text eol=lf
*.toml text eol=lf
*.md text eol=lf
*.slint text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
Cargo.lock text eol=lf
# 二进制:不做换行转换、不做文本 diff
*.png binary
*.ico binary
*.icns binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.zip binary
*.7z binary
*.gz binary
*.tar binary
*.exe binary
*.dll binary
*.lib binary
*.pdb binary
+92
View File
@@ -0,0 +1,92 @@
name: CI
# 主干门禁:fmt / clippy / test / build 双平台矩阵。
# 架构守门(core 零 UI、文件 ≤400 行、依赖许可)见 guards.yml。
# 平台基线 Win10+(标准工具链产物),平台矩阵见 AGENTS.md §7。
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 / femtovg 编译期需要)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- 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
- 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
- run: cargo build --workspace --release
+81
View File
@@ -0,0 +1,81 @@
name: Guards
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
# 3. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
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"
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 ;;
esac
n=$(wc -l < "$f")
if [ "$n" -gt "$LIMIT" ]; then
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
failed=$((failed + 1))
fi
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json')
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 由后续安全轮次单独接管
command: check licenses bans sources
+276
View File
@@ -0,0 +1,276 @@
name: Release
# 发布流水线:tag `v*` → 三平台(四槽位)矩阵构建 → 单文件产物 + SHA256SUMS
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
#
# 契约唯一真相源:packaging/README.md(命名/目录/运行时依赖)。
# 打包本体在 packaging/make_artifacts.py(本地与 CI 同一份脚本,逐字节同布局)。
# 手动触发(workflow_dispatch)默认只跑构建不建 Release —— 用来验证矩阵仍然编得过。
# 注意 `inputs.prerelease` 只在 **tag ref** 上 dispatch 才可达:Actions 页面的
# "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。
# 分支 ref 的 dispatch 与 push 事件一样被 release job 的 `if: github.ref_type == 'tag'`
# 挡在发布之外,所以「dispatch 只构建不发布」与「dispatch 可关 pre-release」并不矛盾。
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
prerelease:
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: build (${{ matrix.slot }})
strategy:
fail-fast: false
matrix:
include:
# Windows:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
- slot: windows-x64
os: windows-latest
rustflags: -C target-feature=+crt-static
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 = 2.39)。
- slot: linux-x64
os: ubuntu-22.04
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
- slot: macos-arm64
os: macos-latest
- slot: macos-x64
os: macos-15-intel
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: cargo build --release --locked
env:
RUSTFLAGS: ${{ matrix.rustflags }}
run: cargo build --release --locked --bin unlucky-launcher --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
run: |
set -euo pipefail
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
# 可再发行包,那就不是"单文件"了,直接 fail。
#
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位):
# ① vswhere:VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/安装布局无关;
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# -find 的模式优先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
for pattern in 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' \
'VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe'; do
hit=$("$VSWHERE" -latest -products '*' -find "$pattern" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=${hit//\\//}
VIA="vswhere"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
# 镜像里的 VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),
# 固定层数的 glob 会在 <年>/<版次> 这两级踩空,所以按路径模式扫。
for root in "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio"; do
[ -d "$root" ] || continue
hit=$(find "$root" -maxdepth 11 -type f \
-path '*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe' 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="find: $root"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="PATH"
fi
fi
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
exit 1
fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
"$DUMPBIN" -nologo -dependents target/release/unlucky-launcher.exe | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
exit 1
fi
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
args=()
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
ls -l dist
- name: 归档内容自检(二进制定位 + 合规文本)
shell: bash
run: |
set -euo pipefail
for archive in dist/*.tar.gz; do
[ -e "$archive" ] || continue
listing=$(tar -tzf "$archive")
for required in unlucky-launcher LICENSE NOTICE README.md; do
if ! grep -q "/$required$" <<<"$listing"; then
echo "::error file=$archive::$archive 缺少 $required"
exit 1
fi
done
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
done
if ls dist/*.exe >/dev/null 2>&1; then
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
fi
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.slot }}
path: dist
if-no-files-found: error
retention-days: 14
release:
name: GitHub Release
needs: build
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: 矩阵完整性 + 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
for slot in windows-x64 linux-x64 macos-arm64 macos-x64; do
if ! compgen -G "dist/*-$slot*" >/dev/null; then
echo "::error::矩阵缺产物:$slot"
exit 1
fi
done
# 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher-* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib
version = os.environ["TAG"].lstrip("v")
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
print(body[:400])
PY
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 标记只在**显式要求**时才断言:
# PRERELEASE=true / false —— 来自 tag ref 上的 workflow_dispatch 输入;
# PRERELEASE 空 —— push 触发的运行或它的 Re-run,此时**不碰**该标记,
# 否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release。
# 首次创建(create)仍保持「pre-release 默认开」的老契约:空值按 true 处理。
create_mark=()
edit_mark=()
case "${PRERELEASE:-}" in
true)
create_mark=(--prerelease)
edit_mark=(--prerelease)
;;
false)
edit_mark=(--prerelease=false)
;;
"")
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上,
# 保证每个下载平台的许可条款都随包分发。
assets=(dist/unlucky-launcher-* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明,
# 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。
gh release upload "$TAG" "${assets[@]}" --clobber
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
else
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
fi
+28
View File
@@ -0,0 +1,28 @@
# Reference source repositories (local-only, git-ignored)
/HMCL/
/PCL/
# Local development docs
/DevDocs/
# Local acceptance screenshots (screenshot channel output, never committed)
/.screens/
/.tmp-screens/
# Agent workspace (local-only)
/.tower/
# Build outputs
build/
dist/
out/
target/
# IDE
.idea/
.vscode/
*.iml
# OS
Thumbs.db
.DS_Store
+5
View File
@@ -0,0 +1,5 @@
edition = "2021"
max_width = 100
newline_style = "Unix"
use_field_init_shorthand = true
use_try_shorthand = true
Generated
+7415
View File
File diff suppressed because it is too large. Load diff
+88
View File
@@ -0,0 +1,88 @@
[workspace]
resolver = "2"
members = ["crates/core", "crates/agent", "crates/app", "crates/cli"]
[workspace.package]
version = "0.1.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"
# 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"
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 AskaEth (YeiJ)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+30
View File
@@ -0,0 +1,30 @@
Unlucky-Launcher (ULL)
Copyright 2026 AskaEth (YeiJ)
本产品采用 Apache License 2.0(见 LICENSE)。
--------------------------------------------------------------------------
第三方组件声明
--------------------------------------------------------------------------
1. Slint(https://slint.dev)
本产品的 GUI 层使用 Slint。Slint 以 Royalty-Free 2.0 许可(LicenseRef-Slint-Royalty-free-2.0)
使用,未修改 Slint 本体源码。
Slint Royalty-Free 2.0 的唯一义务:应用「关于」页显示官方 AboutSlint 部件,
或在公开下载页放置「Made with Slint」徽章。本项目的落地位置见 crates/app 的关于页(P2 排期)
与 GitHub Release 说明页;在此之前由本文件的声明承担告知义务。
Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。
2. Rust crates 生态
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)依赖不得进入主干。
3. 参考实现致谢
本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2
(https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。
+124
View File
@@ -0,0 +1,124 @@
# Unlucky Launcher (ULL)
**English** · [简体中文](README.zh-CN.md)
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher
Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust
(Apache-2.0, non-commercial), with a Slint + Metro UI. It embeds an AI agent client (chat /
configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP.
Minimum baseline: Windows 10.
- Runtime: a single native Rust binary — no JVM, no WebView, no external runtime
- UI: Slint (Royalty-Free 2.0; GL preferred, CPU software-rendering fallback so older machines
without a usable GPU still get the full UI)
- License: Apache-2.0 © AskaEth (YeiJ)
## Compliance statement
Authentication uses only official Microsoft/Mojang endpoints — Microsoft OAuth 2.0 device code
flow, Xbox Live, XSTS, and api.minecraftservices.com — so players sign in with their own Microsoft
account and launch the game they already own. Account tokens are stored only on the user's local
machine and are never sent to any server operated by the project. ULL does not distribute, mirror,
proxy or modify any Minecraft game files. Online play always requires a Microsoft account with
valid entitlements. Local offline profiles never contact Microsoft or Mojang services and cannot
join online-mode servers. Not affiliated with Mojang or Microsoft.
## Status
The core engine is complete (workspace: 983+ tests passing):
- **Microsoft sign-in**: OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services, with
entitlement verification
- **Account credentials are stored only on the local machine** — never uploaded, and inaccessible
to the AI features
- Mod loaders: Forge / NeoForge / Fabric / Quilt, verified on real machines
- Java management: Temurin / Oracle / Microsoft / Zulu with a regional fallback chain
- Multi-source download engine; YAML rule-based crash diagnosis
- Usable as an MCP server for external agent tooling (`ull agent serve`)
- Local offline profiles: for local / isolated-network testing only; they perform no Microsoft
authentication and cannot join servers with online-mode enabled
**CLI (`ull`) is fully usable**: `install / launch / accounts / java / diagnose / agent`.
**GUI in development**: `crates/app` is currently a Slint + Metro skeleton; the theme system and
the full interface ship with subsequent versions — use the CLI until then.
## 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 shared core)
├── crates/app # Slint UI: the only crate allowed to depend on a UI framework
├── crates/cli # CLI (bin `ull`): headless, zero UI, depends on core + agent
└── packaging # single-file artifact matrix and packaging assets (planned)
```
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 / local offline profiles |
| `addon` | mod loader installation, modpacks, CurseForge / Modrinth |
| `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; components `rustfmt` /
`clippy`)
- Windows: MSVC toolchain (used by CI) or MinGW-w64 (`x86_64-pc-windows-gnu`)
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev`
(Slint winit backend + femtovg build dependencies)
```bash
cargo fmt --all --check # formatting
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
cargo test --workspace # tests
cargo build --release # artifact: target/release/unlucky-launcher[.exe]
```
Software-rendering switch (machines without a usable GPU / with broken drivers):
`unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`).
Dependency license check (optional locally, required in CI):
`cargo deny check licenses bans sources`.
## CI gates
| Workflow | Content |
|----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + build, Windows / Linux matrix |
| `.github/workflows/guards.yml` | zero-UI guard for core, 400-line-per-file guard, cargo-deny license guard |
## Platform baseline
Minimum baseline: **Windows 10**. Windows 8.1 / 8 / 7 are not supported:
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU) — no custom target specs, no
`-Zbuild-std`, no import-table allowlists
- Other platforms: macOS 12+ first class; Linux x64 with X11 preferred, Wayland as the long-term
direction; Android Termux experimental, not committed
- UI fallback: Slint software rendering (fully usable on machines without a GPU or with broken
drivers)
- Platform independence is expressed through **separate test matrices**, not separate artifact
pipelines; 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)
- The GUI uses [Slint](https://slint.dev) (Royalty-Free 2.0 track). Per the RF 2.0 obligations an
`AboutSlint` widget will be placed on the about page (P2); until then NOTICE and this section
serve the attribution duty.
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and
[PCL2](https://github.com/Meloong-Git/PCL) (both GPL-3.0) — these two launchers have accompanied
the vast majority of Minecraft players for many years. ULL learned a great deal about design and
implementation from them, and this project is dedicated to those two pioneers.
+99
View File
@@ -0,0 +1,99 @@
# Unlucky Launcher (ULL)
[English](README.md) · **简体中文**
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
- 运行时:纯 Rust 原生二进制——无 JVM、无 WebView、无外部运行时
- UI:Slint(Royalty-Free 2.0;GL 优先,CPU 软渲染兜底,无可用 GPU 的老机也有完整 UI)
- 许可:Apache-2.0 © AskaEth (YeiJ)
## 当前状态
**核心引擎已完成**(workspace 983+ 测试全绿):
- **正版登录**:Microsoft OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services,校验游戏拥有状态
- **账户凭据仅保存在本机**,不上传任何服务器,AI 功能无法访问
- 加载器:Forge / NeoForge / Fabric / Quilt 四家真机验证
- Java 管理:Temurin / Oracle / Microsoft / Zulu 四发行版回退链
- 多源下载引擎、崩溃诊断规则引擎
- 可作为 MCP server 接入外部 Agent 工具(`ull agent serve`)
- 本地离线档案:仅用于本地/隔离网络环境的测试,不经过 Microsoft 账户认证,无法连接启用正版验证的服务器
**CLI(`ull`)完整可用**:`install / launch / accounts / java / diagnose / agent` 等全量命令。
**图形界面(GUI)开发中**:`crates/app` 当前为 Slint + Metro 风格骨架,主题系统与完整界面
随后续版本交付——在此之前请使用 CLI。
## 模块地图
```
Unlucky-Launcher (cargo workspace)
├── crates/core # 领域库:零 UI 依赖,八模块
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
├── crates/app # Slint UI:唯一允许依赖 UI 框架的 crate
├── crates/cli # CLI(bin `ull`):headless,零 UI,只依赖 core + agent
└── packaging # 单文件产物矩阵与打包资源(规划中)
```
`crates/core` 的八个模块:
| 模块 | 职责 |
|------|------|
| `setting` | 三模式数据目录、schema 配置、原子写、迁移 |
| `download` | 多源健康度、镜像、分块下载、边下边算 |
| `game` | 版本/实例仓库、快照与继承解析 |
| `launch` | 10 阶段启动管线、进程、JSONL 事件流 |
| `auth` | Microsoft 正版登录 / 本地离线档案 |
| `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`
(Slint winit 后端 + femtovg 编译依赖)
```bash
cargo fmt --all --check # 格式
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
cargo test --workspace # 测试
cargo build --release # 产物:target/release/unlucky-launcher[.exe]
```
软渲染开关(无可用 GPU / 烂驱动的老机):`unlucky-launcher --software-renderer`
(等价于 `SLINT_BACKEND=winit-software`)。
依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。
## CI 门禁
| Workflow | 内容 |
|----------|------|
| `.github/workflows/ci.yml` | fmt + clippy + test + build,Windows / Linux 矩阵 |
| `.github/workflows/guards.yml` | core 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可守门 |
## 平台基线
最低基线 **Windows 10**。Windows 8.1 / 8 / 7 不在支持范围内:
- Windows 10/11 走**标准工具链**产物(MSVC / GNU),无自定义 target spec、无 `-Zbuild-std`、无导入表白名单
- 其他平台:macOS 12+ 一等;Linux x64 X11 优先、Wayland 为**大方向**;Android Termux 试验级不承诺
- UI 兜底:Slint 软渲染(无 GPU / 烂驱动的老机可完整使用)
- 平台独立性体现在**独立测试矩阵**,而非独立产物链;能力差异用 feature gate + 运行期探测表达
## 许可证与致谢
- 本项目:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)
- GUI 使用 [Slint](https://slint.dev)(Royalty-Free 2.0 轨道)。按 RF 2.0 义务,关于页将放置
`AboutSlint` 部件(P2),在此之前由 NOTICE 与本节承担告知义务
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)(均 GPL-3.0)这两个启动器陪伴了绝大多数 MC 玩家好多年。
ULL 的设计与实现从它们身上学到了太多,谨以此项目向两位前辈致敬。
+7
View File
@@ -0,0 +1,7 @@
# 与 Cargo.toml 的 rust-version 同步;改 MSRV 时两处一起改。
msrv = "1.92"
# core 的模块边界很薄,参数多于 8 个基本说明该拆结构体了。
too-many-arguments-threshold = 8
# 领域类型(版本号、哈希、URL)普遍是短名,不做最小名长惩罚。
min-ident-chars-threshold = 2
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "unlucky-agent"
description = "Unlucky-Launcher AI Agent:工具注册表 + 内置客户端 + MCP server(双形态共用一个内核)"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 依赖方向:agent 依赖 core 的公共 API,core 不知道 agent 的存在(AGENTS.md §2)。
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies]
unlucky-core = { path = "../core", version = "0.1" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。
serde.workspace = true
serde_json.workspace = true
# LLM Provider + agent 循环。**锁精确版本**:rig 12 周 5 个 minor、CHANGELOG 满是 breaking
# (DevDocs/PlanDoc/02-ai-agent-design.md §0 + research/02 §7 R1)。升级必须单独提交并跑回放回归。
# 依赖面收口:整个 crate 只有 src/brain/rig.rs 允许 `use rig_*`。
# feature `reqwest`:rig-core 0.45 的 default 只有 derive + rustls,HTTP 客户端要显式开;TLS 走 rustls
# (免 OpenSSL,对 Win7/MinGW 友好,见 research/02 §7 R3)。
rig-core = { version = "=0.45.0", features = ["reqwest"] }
# TODO(M16-P1): 目前**声明但未使用**——对话循环/工具编排是自研的(要在中间插确认与审计)。
# 保留是为了把版本按 02 §0 钉死;P1 接 rig 的 agent 运行时(工具服务句柄 / rig-cassette 回放 /
# rig-rmcp 让内置 agent 吃 MCP)时再真正消费。若 P1 决定彻底不用,删这一行即可。
rig-agent = "=0.45.0"
# 异步运行时(rig 的 HTTP/流式需要)与流工具(消费 rig 的 Stream)。
tokio.workspace = true
futures-util.workspace = true
# MCP server(M39):rmcp 3.8 投影 + stdio / Streamable HTTP 传输。
# 依赖面收口纪律与 rig 相同——整个 crate 只有 src/mcp/ 允许 `use rmcp::`。
rmcp.workspace = true
axum.workspace = true
[lints]
workspace = true
+283
View File
@@ -0,0 +1,283 @@
//! 审计日志:**每一次工具调用**都留痕(02 文档 §3.1 末条 / mission M16 任务 4)。
//!
//! 落盘位置由装配处给(`<data_root>/agent/audit/audit-<日期>.jsonl`,按天分文件,
//! 见 `PlanDoc/04-data-and-config.md` §2)。一行一条 JSON,只追加不修改。
//!
//! 三条纪律:
//! 1. **参数先脱敏再落盘**([`crate::redact::redact_args`])——`set_config` 能改 AI 端点,
//! 参数里会有 api_key;
//! 2. 写失败**不打断**工具执行(审计是旁路),但要把失败打到 stderr 留痕;
//! 3. YOLO 与「是否经确认」分别记录(`yolo=true, confirmed=false` 与
//! `yolo=false, confirmed=true` 是两件事,事故复盘的差别就在这)。
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::clock::{date_of_unix_ms, format_unix_ms, SharedClock};
use crate::redact::redact_args;
/// 一次工具调用的结局(审计用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditOutcome {
/// 成功返回。
Ok,
/// 工具返回错误(含端口失败、未找到会话等)。
ToolError,
/// 参数没过 schema 校验(模型给错了参数)。
InvalidArgs,
/// 用户/策略拒绝。
Denied,
/// 只发出了确认请求,尚未执行(编排层在把请求交给闸门之前先记一条,
/// 这样「AI 想改 X 但被拒」和「用户点了允许」在审计里是两条独立的证据)。
AwaitingConfirmation,
/// 模型调了不存在的工具。
UnknownTool,
}
/// 一条审计记录。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AuditRecord {
/// RFC3339 UTC。
pub ts: String,
/// 会话 id(agent 对话框的会话,不是游戏会话)。
pub agent_session_id: String,
/// 工具名。
pub tool: String,
/// 参数(已脱敏)。
pub args: Value,
/// 结局。
pub outcome: AuditOutcome,
/// 一句话结果摘要。
pub summary: String,
/// 是否运行在 YOLO 模式。
pub yolo: bool,
/// 是否经过用户确认(YOLO 下为 false——自动放行不是"确认")。
pub confirmed: bool,
/// 工具执行耗时(毫秒)。
pub duration_ms: u64,
}
/// 审计出口。
pub trait AuditSink: Send + Sync {
/// 记录一条(实现方不应阻塞工具执行链;失败自行吞掉并留痕)。
fn record(&self, record: &AuditRecord);
}
/// 按天滚动的 JSONL 审计(生产实现)。
pub struct JsonlAudit {
dir: PathBuf,
clock: SharedClock,
guard: Mutex<()>,
}
impl JsonlAudit {
/// `dir` 通常是 `<data_root>/agent/audit`;目录按需创建。
#[must_use]
pub fn new(dir: impl Into<PathBuf>, clock: SharedClock) -> Self {
Self {
dir: dir.into(),
clock,
guard: Mutex::new(()),
}
}
/// 给定时刻对应的文件路径(测试与 UI「查看审计」共用)。
#[must_use]
pub fn path_for(&self, unix_ms: u64) -> PathBuf {
self.dir
.join(format!("audit-{}.jsonl", date_of_unix_ms(unix_ms)))
}
/// 追加一行;失败返回 `std::io::Error`(由 [`AuditSink::record`] 吞掉并打日志)。
fn append(&self, record: &AuditRecord) -> std::io::Result<()> {
let path = self.path_for(self.clock.now_unix_ms());
let line = serde_json::to_string(record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let _guard = self.guard.lock();
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")
}
}
impl AuditSink for JsonlAudit {
fn record(&self, record: &AuditRecord) {
if let Err(err) = self.append(record) {
// 审计是旁路:写不进去也不能让工具调用失败,但要留下痕迹。
eprintln!("[unlucky-agent] 审计写入失败: {err}");
}
}
}
/// 内存审计(测试 + 「P0 不落盘」的降级路径)。
#[derive(Debug, Default)]
pub struct MemoryAudit {
records: Mutex<Vec<AuditRecord>>,
}
impl MemoryAudit {
/// 空审计。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 已记录条数。
#[must_use]
pub fn len(&self) -> usize {
self.records.lock().map(|r| r.len()).unwrap_or(0)
}
/// 是否一条都没有。
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 取快照。
#[must_use]
pub fn records(&self) -> Vec<AuditRecord> {
self.records.lock().map(|r| r.clone()).unwrap_or_default()
}
/// 按工具名筛出记录(测试断言用)。
#[must_use]
pub fn records_for(&self, tool: &str) -> Vec<AuditRecord> {
self.records()
.into_iter()
.filter(|r| r.tool == tool)
.collect()
}
}
impl AuditSink for MemoryAudit {
fn record(&self, record: &AuditRecord) {
if let Ok(mut records) = self.records.lock() {
records.push(record.clone());
}
}
}
/// 丢弃审计(不推荐,只给"连内存都不想留"的极端场景/基准测试)。
#[derive(Debug, Default, Clone, Copy)]
pub struct NullAudit;
impl AuditSink for NullAudit {
fn record(&self, _record: &AuditRecord) {}
}
/// 组装一条记录时的"其余字段"(时间戳从时钟取,参数先脱敏)。
#[derive(Debug, Clone, Copy)]
pub struct AuditDraft<'a> {
/// 工具名。
pub tool: &'a str,
/// 原始参数(内部会脱敏)。
pub args: &'a Value,
/// 结局。
pub outcome: AuditOutcome,
/// 一句话结果摘要。
pub summary: &'a str,
/// 是否 YOLO。
pub yolo: bool,
/// 是否经用户确认。
pub confirmed: bool,
/// 耗时(毫秒)。
pub duration_ms: u64,
}
/// 组装一条审计记录(时间戳从时钟取、参数脱敏,调用方不用自己拼)。
#[must_use]
pub fn make_record(
clock: &dyn crate::clock::Clock,
agent_session_id: &str,
draft: AuditDraft<'_>,
) -> AuditRecord {
AuditRecord {
ts: format_unix_ms(clock.now_unix_ms()),
agent_session_id: agent_session_id.to_string(),
tool: draft.tool.to_string(),
args: redact_args(draft.args),
outcome: draft.outcome,
summary: draft.summary.to_string(),
yolo: draft.yolo,
confirmed: draft.confirmed,
duration_ms: draft.duration_ms,
}
}
/// 便捷:给出数据根下的审计目录(`<data_root>/agent/audit`)。
#[must_use]
pub fn audit_dir(data_root: &Path) -> PathBuf {
data_root.join("agent").join("audit")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FixedClock;
use serde_json::json;
use std::sync::Arc;
#[test]
fn record_redacts_args_before_store() {
let audit = MemoryAudit::new();
let clock = FixedClock::new(1_793_304_753_123);
let args = json!({ "path": "ai.api_key", "value": "sk-x" });
let rec = make_record(
&clock,
"s1",
AuditDraft {
tool: "set_config",
args: &args,
outcome: AuditOutcome::Ok,
summary: "ok",
yolo: false,
confirmed: true,
duration_ms: 3,
},
);
audit.record(&rec);
let stored = &audit.records()[0];
assert_eq!(stored.args["value"], json!("***"), "敏感路径的值必须掩码");
assert_eq!(stored.ts, "2026-10-29T20:12:33.123Z");
}
#[test]
fn jsonl_audit_writes_one_line_per_record_per_day() {
let dir = std::env::temp_dir().join(format!("unlucky-agent-audit-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let audit = JsonlAudit::new(&dir, Arc::clone(&clock));
let args = json!({});
let rec = make_record(
clock.as_ref(),
"s1",
AuditDraft {
tool: "get_config",
args: &args,
outcome: AuditOutcome::Ok,
summary: "ok",
yolo: false,
confirmed: false,
duration_ms: 1,
},
);
audit.record(&rec);
audit.record(&rec);
let path = dir.join("audit-2026-10-29.jsonl");
let content = std::fs::read_to_string(&path).expect("审计文件应存在");
assert_eq!(content.lines().count(), 2);
let first: AuditRecord = serde_json::from_str(content.lines().next().unwrap()).unwrap();
assert_eq!(first.tool, "get_config");
let _ = std::fs::remove_dir_all(&dir);
}
}
+267
View File
@@ -0,0 +1,267 @@
//! 对话协议类型 + [`ChatProvider`] trait(四态端点配置在 [`super::provider`])。
//!
//! 这里**没有协议方言**:OpenAI/Ollama/Anthropic 的报文差异全部由实现方
//! (阶段 B 的 [`super::rig`])吃掉。本模块只定义循环需要的三样东西:
//! 消息、请求/回复、流式增量回调。
//!
//! 一次工具调用的完整往返:
//! `Assistant { tool_calls }` → 编排层执行 → `Tool { call_id, name, content }` → 再次补全。
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::provider::{ProviderConfig, ProviderError};
use crate::event::{AgentEvent, AgentEventSink};
use crate::registry::ToolDefinition;
/// 一条工具调用请求(模型产出)。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
/// 调用 id(OpenAI 方言的 `tool_call_id`;Ollama 原生没有 id,由适配层生成)。
pub id: String,
/// 工具名。
pub name: String,
/// 参数(JSON 对象)。
pub arguments: Value,
}
/// 对话消息。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ChatMessage {
/// 系统提示词(每轮由 [`super::BrainConfig::system_prompt`] 重新注入,不进历史)。
System {
/// 提示词正文。
content: String,
},
/// 用户输入。
User {
/// 正文。
content: String,
},
/// 模型回复(可能带工具调用)。
Assistant {
/// 文本正文(工具调用轮可能为空)。
text: String,
/// 工具调用。
tool_calls: Vec<ToolCall>,
},
/// 工具结果回灌。
Tool {
/// 对应调用 id。
call_id: String,
/// 工具名(Ollama 原生靠 `tool_name` 配对,不是 id)。
name: String,
/// 结果 JSON 字符串。
content: String,
},
}
impl ChatMessage {
/// 便捷:系统消息。
#[must_use]
pub fn system(content: impl Into<String>) -> Self {
Self::System {
content: content.into(),
}
}
/// 便捷:用户消息。
#[must_use]
pub fn user(content: impl Into<String>) -> Self {
Self::User {
content: content.into(),
}
}
/// 便捷:工具结果消息。
#[must_use]
pub fn tool(
call_id: impl Into<String>,
name: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self::Tool {
call_id: call_id.into(),
name: name.into(),
content: content.into(),
}
}
}
/// 一次补全请求。
#[derive(Debug, Clone, PartialEq)]
pub struct ChatRequest {
/// 模型名。
pub model: String,
/// 消息(system 在首位)。
pub messages: Vec<ChatMessage>,
/// 工具定义(双投影的 rig 侧载荷)。
pub tools: Vec<ToolDefinition>,
/// 采样温度。
pub temperature: Option<f32>,
/// token 上限。
pub max_tokens: Option<u32>,
}
/// token 用量(本地模型可能不报)。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
/// 输入 token。
pub prompt_tokens: Option<u32>,
/// 输出 token。
pub completion_tokens: Option<u32>,
}
/// 一次补全结果。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ChatReply {
/// 文本正文。
pub text: String,
/// 工具调用(非空 ⇒ 循环继续)。
pub tool_calls: Vec<ToolCall>,
/// 用量。
pub usage: Option<Usage>,
}
impl ChatReply {
/// 纯文本回复。
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
tool_calls: Vec::new(),
usage: None,
}
}
/// 带工具调用的回复。
#[must_use]
pub fn with_tool_calls(tool_calls: Vec<ToolCall>) -> Self {
Self {
text: String::new(),
tool_calls,
usage: None,
}
}
}
/// 流式增量接收者。实现方必须**非阻塞**。
pub trait DeltaObserver: Send + Sync {
/// 收到一段文本增量。
fn on_delta(&self, text: &str);
}
/// 丢弃增量(不关心流式的调用方)。
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopObserver;
impl DeltaObserver for NoopObserver {
fn on_delta(&self, _text: &str) {}
}
/// 把增量转成 [`AgentEvent::TextDelta`](P1 聊天面板逐字渲染就靠它)。
#[derive(Clone)]
pub struct EventDeltaObserver {
sink: Arc<dyn AgentEventSink>,
}
impl EventDeltaObserver {
/// 用事件流构造。
#[must_use]
pub fn new(sink: Arc<dyn AgentEventSink>) -> Self {
Self { sink }
}
}
impl DeltaObserver for EventDeltaObserver {
fn on_delta(&self, text: &str) {
self.sink.emit(AgentEvent::TextDelta {
text: text.to_string(),
});
}
}
/// 收集增量(测试断言流式拼接)。
#[derive(Debug, Default)]
pub struct CollectingObserver {
chunks: std::sync::Mutex<Vec<String>>,
}
impl CollectingObserver {
/// 收完的内容。
#[must_use]
pub fn collected(&self) -> String {
self.chunks.lock().map(|c| c.concat()).unwrap_or_default()
}
}
impl DeltaObserver for CollectingObserver {
fn on_delta(&self, text: &str) {
if let Ok(mut chunks) = self.chunks.lock() {
chunks.push(text.to_string());
}
}
}
/// 补全未来的箱子类型。
pub type ProviderFuture<'a> =
Pin<Box<dyn Future<Output = Result<ChatReply, ProviderError>> + Send + 'a>>;
/// LLM 端点抽象:**这是 agent 唯一的异步边界**。
///
/// P0 实现 = 测试里的 mock;阶段 B 实现 = [`super::rig`] 的 rig 适配器。
/// 换实现(genai / 自写循环)只需要满足本 trait,[`super::Brain`] 不动。
pub trait ChatProvider: Send + Sync {
/// 当前配置(循环用它取 model/temperature,也用它判断是否启用)。
fn config(&self) -> &ProviderConfig;
/// 一次补全。流式增量通过 `observer` 回吐。
fn complete<'a>(
&'a self,
request: &'a ChatRequest,
observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a>;
}
/// 装配期用的 provider 工厂:按配置造实现(阶段 B 在这里接 rig,测试接 mock)。
pub type ProviderFactory =
Arc<dyn Fn(&ProviderConfig) -> Result<Arc<dyn ChatProvider>, ProviderError> + Send + Sync>;
#[cfg(test)]
mod tests {
use super::*;
use crate::event::MemoryEventSink;
#[test]
fn collecting_observer_concatenates_chunks() {
let observer = CollectingObserver::default();
observer.on_delta("正在");
observer.on_delta("读取日志");
assert_eq!(observer.collected(), "正在读取日志");
}
#[test]
fn event_observer_emits_text_delta() {
let sink = Arc::new(MemoryEventSink::new());
let observer = EventDeltaObserver::new(sink.clone());
observer.on_delta("嗨");
assert_eq!(sink.streamed_text(), "嗨");
}
#[test]
fn message_helpers_round_trip_through_json() {
let messages = vec![
ChatMessage::system("你是助手"),
ChatMessage::user("看看日志"),
ChatMessage::tool("call_1", "read_game_log", "{}"),
];
let json = serde_json::to_string(&messages).expect("可序列化");
let back: Vec<ChatMessage> = serde_json::from_str(&json).expect("可反序列化");
assert_eq!(back, messages);
assert!(json.contains("\"role\":\"tool\""));
}
}
+347
View File
@@ -0,0 +1,347 @@
//! 工具执行编排:schema 校验 → 分级确认 → 执行 → 审计 → 事件。
//!
//! **YOLO 只在这里**(mission M16 任务 3 的分工):工具自己不判断 policy,
//! 只声明「这次需要确认 + diff」;本模块按 [`ConfirmPolicy`] 决定问不问。
//!
//! 一次工具调用最多两条审计记录:
//! 1. 需要确认时先记一条 `awaiting_confirmation`(「AI 想改 X」本身必须留痕);
//! 2. 结局记一条 `ok` / `denied` / `tool_error`(带 `yolo` 与 `confirmed` 标记)。
//!
//! 这两条区分了三种在复盘里完全不同的事:用户点了拒绝、YOLO 自动放行、用户点了允许。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use serde_json::{json, Value};
use crate::audit::{make_record, AuditDraft, AuditOutcome, AuditSink};
use crate::brain::chat::ToolCall;
use crate::clock::SharedClock;
use crate::confirm::{ConfirmDecision, ConfirmGate, ConfirmPolicy, ConfirmRequest, DenyGuidance};
use crate::event::{AgentEvent, AgentEventSink};
use crate::registry::validate::validate;
use crate::registry::{ToolCtx, ToolError, ToolRegistry};
/// 一次工具执行的结局(回灌给模型的内容 + 成败标记)。
#[derive(Debug, Clone, PartialEq)]
pub struct ToolExecOutcome {
/// 回灌内容(结构化)。
pub content: Value,
/// 是否成功(UI/事件用;模型看的是 `content`)。
pub ok: bool,
}
impl ToolExecOutcome {
/// 成功。
#[must_use]
pub fn ok(content: Value) -> Self {
Self { content, ok: true }
}
/// 失败(`code` 给模型看,帮助它改正后重试)。
#[must_use]
pub fn error(code: &str, message: impl Into<String>) -> Self {
Self {
content: json!({ "status": "error", "code": code, "message": message.into() }),
ok: false,
}
}
/// 序列化成回灌给模型的字符串(工具消息的 `content`)。
#[must_use]
pub fn to_model_content(&self) -> String {
serde_json::to_string(&self.content).unwrap_or_else(|_| {
json!({ "status": "error", "code": "internal", "message": "结果序列化失败" })
.to_string()
})
}
}
/// 工具执行器(无状态,可跨轮复用)。
pub struct ToolExecutor {
registry: Arc<ToolRegistry>,
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
guidance: DenyGuidance,
confirm_seq: AtomicU64,
}
impl ToolExecutor {
/// 装配(默认拒绝口径 = 内置对话:[`DenyGuidance::InApp`])。
pub fn new(
registry: Arc<ToolRegistry>,
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
) -> Self {
Self {
registry,
audit,
gate,
policy,
clock,
sink,
guidance: DenyGuidance::InApp,
confirm_seq: AtomicU64::new(0),
}
}
/// 换拒绝口径(外部 MCP 客户端用 [`DenyGuidance::External`]:没有弹窗可点)。
///
/// 只改**文案**:策略、闸门、审计标记一个字都不变——这正是「同一注册表、
/// 同一安全策略、同一审计」在代码上的落点。
#[must_use]
pub fn with_guidance(mut self, guidance: DenyGuidance) -> Self {
self.guidance = guidance;
self
}
/// 执行一次工具调用(含确认与审计)。
pub async fn execute(
&self,
agent_session_id: &str,
call: &ToolCall,
step: usize,
) -> ToolExecOutcome {
let started_at = self.clock.now_unix_ms();
self.sink.emit(AgentEvent::ToolCallStarted {
tool: call.name.clone(),
// 与审计同一套脱敏:事件(P1 会持久化)不能带出凭据明文。
args: crate::redact::redact_args(&call.arguments),
step,
});
let Some(spec) = self.registry.get(&call.name).cloned() else {
let message = format!(
"没有名为 `{}` 的工具。可用工具:{}",
call.name,
self.registry.names().join("、")
);
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::UnknownTool,
&message,
false,
false,
started_at,
);
self.finish_event(&call.name, false, &message, false);
return ToolExecOutcome::error("unknown_tool", message);
};
if let Err(errors) = validate(&spec.input_schema, &call.arguments) {
let message = format!("参数不合法:{}", errors.join(";"));
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::InvalidArgs,
&message,
false,
false,
started_at,
);
self.finish_event(&call.name, false, &message, false);
return ToolExecOutcome::error("invalid_args", message);
}
let ctx = ToolCtx {
agent_session_id: agent_session_id.to_string(),
confirmed: false,
};
let quoted = match (spec.handler)(&ctx, &call.arguments) {
Ok(output) => output,
Err(err) => return self.fail(agent_session_id, call, err, started_at),
};
let Some(confirmation) = quoted.confirmation.clone() else {
return self.succeed(agent_session_id, call, quoted, false, false, started_at);
};
// ── 需要确认:决定问不问(YOLO 只影响这里)──
let asked = self.policy.requires_confirmation(confirmation.sensitivity);
let yolo_auto = !asked && confirmation.sensitivity == crate::confirm::Sensitivity::High;
if asked {
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::AwaitingConfirmation,
&confirmation.summary,
false,
false,
started_at,
);
let request = ConfirmRequest {
id: format!(
"confirm-{}",
self.confirm_seq.fetch_add(1, Ordering::Relaxed) + 1
),
tool: call.name.clone(),
summary: confirmation.summary.clone(),
details: confirmation.details.clone(),
sensitivity: confirmation.sensitivity,
};
self.sink.emit(AgentEvent::ConfirmRequested {
request: Box::new(request.clone()),
});
if self.gate.request(request).await == ConfirmDecision::Denied {
let message = self.guidance.message().to_string();
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::Denied,
&message,
false,
false,
started_at,
);
self.sink.emit(AgentEvent::ToolDenied {
tool: call.name.clone(),
reason: message.clone(),
});
self.finish_event(&call.name, false, &message, true);
return ToolExecOutcome::error("denied", message);
}
}
// ── 已获授权(用户允许或 YOLO):带 confirmed 重放 ──
let confirmed_ctx = ToolCtx {
agent_session_id: agent_session_id.to_string(),
confirmed: true,
};
match (spec.handler)(&confirmed_ctx, &call.arguments) {
Ok(output) => {
if let Some(second) = output.confirmation {
// 工具在"已确认"状态下仍要确认 = 实现写错了,明确报出来而不是死循环。
let message = format!(
"工具 `{}` 在已确认状态下仍请求确认:{}",
call.name, second.summary
);
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::ToolError,
&message,
yolo_auto,
asked,
started_at,
);
self.finish_event(&call.name, false, &message, true);
return ToolExecOutcome::error("internal", message);
}
self.succeed(agent_session_id, call, output, asked, yolo_auto, started_at)
}
Err(err) => {
self.fail_after_confirm(agent_session_id, call, err, asked, yolo_auto, started_at)
}
}
}
fn succeed(
&self,
agent_session_id: &str,
call: &ToolCall,
output: crate::registry::ToolOutput,
confirmed: bool,
yolo: bool,
started_at: u64,
) -> ToolExecOutcome {
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::Ok,
&output.summary,
yolo,
confirmed,
started_at,
);
self.finish_event(
&call.name,
true,
&output.summary,
output.confirmation.is_some(),
);
ToolExecOutcome::ok(output.content)
}
fn fail(
&self,
agent_session_id: &str,
call: &ToolCall,
err: ToolError,
started_at: u64,
) -> ToolExecOutcome {
self.fail_after_confirm(agent_session_id, call, err, false, false, started_at)
}
fn fail_after_confirm(
&self,
agent_session_id: &str,
call: &ToolCall,
err: ToolError,
confirmed: bool,
yolo: bool,
started_at: u64,
) -> ToolExecOutcome {
let message = err.message().to_string();
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::ToolError,
&message,
yolo,
confirmed,
started_at,
);
self.finish_event(&call.name, false, &message, false);
ToolExecOutcome::error(err.code(), message)
}
#[allow(clippy::too_many_arguments)]
fn record(
&self,
agent_session_id: &str,
tool: &str,
args: &Value,
outcome: AuditOutcome,
summary: &str,
yolo: bool,
confirmed: bool,
started_at: u64,
) {
let duration_ms = self.clock.now_unix_ms().saturating_sub(started_at);
let draft = AuditDraft {
tool,
args,
outcome,
summary,
yolo,
confirmed,
duration_ms,
};
self.audit
.record(&make_record(self.clock.as_ref(), agent_session_id, draft));
}
fn finish_event(&self, tool: &str, ok: bool, summary: &str, requires_confirmation: bool) {
self.sink.emit(AgentEvent::ToolCallFinished {
tool: tool.to_string(),
ok,
summary: summary.to_string(),
requires_confirmation,
});
}
}
+323
View File
@@ -0,0 +1,323 @@
//! Agent 大脑:Provider 抽象 + 对话循环 + 流式事件。
//!
//! 选型(M10 定稿):`rig-core` + `rig-agent`,**版本锁 `=0.45.x`**——上游 12 周 5 个 minor
//! 的 breaking 频率决定了必须锁死;依赖面只在本文件收口,其它模块不许直接碰 rig,
//! 以便随时换 genai / 自写循环逃生(`DevDocs/PlanDoc/02-ai-agent-design.md` §0)。
//!
//! Provider 形态(02 §2):OpenAI 兼容(默认,含本地 llama.cpp `127.0.0.1:PORT/v1`)、
//! Anthropic 原生、Ollama 原生、以及「禁用」——禁用时规则引擎仍可用。
//!
//! 统一 trait(M16 落地):`stream_chat(messages, tools) -> Stream<Event>`,含 tool_call 事件。
//! 本 crate 的落地形态是 [`chat::ChatProvider::complete`](`request + observer → ChatReply`):
//! rig 0.45 的流在 [`rig`] 适配层里展开成增量回调,循环侧只消费「文本增量 + 工具调用」两件事。
//! **rig 依赖面收口在 [`rig`] 一个文件**,本文件不 `use rig_*`。
//!
//! ## 对话循环(02 文档 §3.3)
//!
//! ```text
//! run_turn(user_input)
//! ├─ provider.complete(request) ← 流式增量经 EventDeltaObserver → UI
//! ├─ 有 tool_calls → ToolExecutor.execute(校验/确认/YOLO/审计)→ Tool 消息回灌
//! └─ 没有 tool_calls → 结束,返回最终文本
//! ```
//!
//! 三条不变量:
//! 1. **system prompt 每轮重新注入**,不进历史(改提示词立刻生效,也不会被压缩逻辑吃掉);
//! 2. 工具结果**永远回灌**(包括失败),模型才有机会改正;
//! 3. 步数上限 [`DEFAULT_MAX_STEPS`] 兜底,防止模型在两个工具间打转烧 token。
pub mod chat;
pub mod exec;
pub mod provider;
pub mod rig;
use std::fmt;
use std::sync::Arc;
use crate::audit::AuditSink;
use crate::clock::SharedClock;
use crate::confirm::{ConfirmGate, ConfirmPolicy};
use crate::event::{AgentEvent, AgentEventSink};
use crate::registry::ToolRegistry;
pub use chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, EventDeltaObserver,
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
};
pub use exec::{ToolExecOutcome, ToolExecutor};
pub use provider::{ProviderConfig, ProviderError, ProviderKind};
/// 一轮对话最多几轮工具调用。
pub const DEFAULT_MAX_STEPS: usize = 8;
/// 内置系统提示词(P0 版本;P2 会按实例上下文按需注入)。
#[must_use]
pub fn default_system_prompt() -> String {
[
"你是 Unlucky-Launcher(ULL,Minecraft 启动器)的内置助手。",
"你能调用工具:读配置(get_config)、改配置(set_config)、列出游戏会话(list_game_sessions)、",
"读会话日志(read_game_log)、跑崩溃规则引擎(diagnose_crash)。",
"纪律:",
"1. 先查后改:改任何配置前先用 get_config 确认键名与现值。",
"2. 崩溃问题先看 diagnose_crash 的规则结论;规则未命中或需要更多信息时,才用 read_game_log",
" 取 stderr / exit / crash_found,不要拉全量日志。",
"3. 只能改 get_config 列出的可写键。敏感键会要求用户确认:被拒绝就换方案,不要重复尝试同一改动。",
"4. 用中文回答,给用户可以立即执行的下一步;不确定时直说不确定,不要编造规则或日志内容。",
]
.join("\n")
}
/// 循环参数。
#[derive(Debug, Clone)]
pub struct BrainConfig {
/// 最多几轮工具调用。
pub max_steps: usize,
/// 系统提示词。
pub system_prompt: String,
}
impl Default for BrainConfig {
fn default() -> Self {
Self {
max_steps: DEFAULT_MAX_STEPS,
system_prompt: default_system_prompt(),
}
}
}
impl BrainConfig {
/// 自定义提示词(步数用默认)。
#[must_use]
pub fn with_system_prompt(system_prompt: impl Into<String>) -> Self {
Self {
max_steps: DEFAULT_MAX_STEPS,
system_prompt: system_prompt.into(),
}
}
}
/// 循环的旁路依赖(审计/确认/时钟/事件)。
pub struct BrainDeps {
/// 审计出口。
pub audit: Arc<dyn AuditSink>,
/// 确认闸门(P0 = [`crate::confirm::NoUiGate`])。
pub gate: Arc<dyn ConfirmGate>,
/// YOLO 等策略快照。
pub policy: ConfirmPolicy,
/// 时间源。
pub clock: SharedClock,
/// 事件出口。
pub sink: Arc<dyn AgentEventSink>,
}
impl BrainDeps {
/// 装配。
pub fn new(
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
) -> Self {
Self {
audit,
gate,
policy,
clock,
sink,
}
}
}
/// 一轮对话的结局。
#[derive(Debug, Clone, PartialEq)]
pub struct TurnOutcome {
/// 最终文本(可能是空的,例如撞上步数上限且模型最后一句只有工具调用)。
pub text: String,
/// 用掉的轮次。
pub steps: usize,
/// 工具调用总次数。
pub tool_calls: usize,
/// 为什么停下来。
pub stopped_reason: StopReason,
}
/// 停止原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
/// 模型给出了最终回答。
Completed,
/// 撞上 [`BrainConfig::max_steps`]。
StepLimit,
}
/// 循环级错误。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BrainError {
/// AI 被禁用(规则引擎仍可用)。
Disabled,
/// provider 报错。
Provider(ProviderError),
}
impl fmt::Display for BrainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Disabled => f.write_str(DISABLED_MESSAGE),
Self::Provider(err) => write!(f, "LLM 调用失败:{err}"),
}
}
}
impl std::error::Error for BrainError {}
/// AI 被禁用时给用户的话(装配层直接展示,不用先构造 Brain)。
pub const DISABLED_MESSAGE: &str =
"AI 已禁用:请在设置 → AI 中配置端点。规则引擎崩溃诊断不受影响。";
/// 对话循环。
pub struct Brain {
provider: Arc<dyn ChatProvider>,
registry: Arc<ToolRegistry>,
executor: ToolExecutor,
config: BrainConfig,
sink: Arc<dyn AgentEventSink>,
}
impl Brain {
/// 装配。
pub fn new(
provider: Arc<dyn ChatProvider>,
registry: Arc<ToolRegistry>,
deps: BrainDeps,
config: BrainConfig,
) -> Self {
let executor = ToolExecutor::new(
Arc::clone(&registry),
Arc::clone(&deps.audit),
Arc::clone(&deps.gate),
deps.policy,
Arc::clone(&deps.clock),
Arc::clone(&deps.sink),
);
Self {
provider,
registry,
executor,
config,
sink: deps.sink,
}
}
/// 当前 provider 配置(设置页/UI 展示用)。
#[must_use]
pub fn provider_config(&self) -> &ProviderConfig {
self.provider.config()
}
/// 跑一轮对话。`history` 由调用方持有(P1 落盘为 `agent/sessions/*.jsonl`)。
///
/// # Errors
/// AI 被禁用([`BrainError::Disabled`])或 provider 报错([`BrainError::Provider`])。
pub async fn run_turn(
&self,
agent_session_id: &str,
history: &mut Vec<ChatMessage>,
user_input: &str,
) -> Result<TurnOutcome, BrainError> {
if !self.provider.config().is_enabled() {
self.sink.emit(AgentEvent::Error {
message: DISABLED_MESSAGE.to_string(),
});
return Err(BrainError::Disabled);
}
self.sink.emit(AgentEvent::TurnStarted {
session_id: agent_session_id.to_string(),
user_text: user_input.to_string(),
});
history.push(ChatMessage::user(user_input));
let observer = EventDeltaObserver::new(Arc::clone(&self.sink));
let mut tool_calls = 0usize;
let mut last_text = String::new();
let mut used_steps = 0usize;
for step in 1..=self.config.max_steps {
used_steps = step;
let request = self.build_request(history);
let reply = match self.provider.complete(&request, &observer).await {
Ok(reply) => reply,
Err(err) => {
self.sink.emit(AgentEvent::Error {
message: err.to_string(),
});
return Err(BrainError::Provider(err));
}
};
if !reply.text.trim().is_empty() {
last_text = reply.text.clone();
self.sink.emit(AgentEvent::AssistantText {
text: reply.text.clone(),
});
}
if reply.tool_calls.is_empty() {
history.push(ChatMessage::Assistant {
text: reply.text.clone(),
tool_calls: Vec::new(),
});
self.sink.emit(AgentEvent::TurnFinished {
steps: step,
text: reply.text.clone(),
});
return Ok(TurnOutcome {
text: reply.text,
steps: step,
tool_calls,
stopped_reason: StopReason::Completed,
});
}
history.push(ChatMessage::Assistant {
text: reply.text.clone(),
tool_calls: reply.tool_calls.clone(),
});
for call in &reply.tool_calls {
let outcome = self.executor.execute(agent_session_id, call, step).await;
tool_calls += 1;
history.push(ChatMessage::tool(
call.id.clone(),
call.name.clone(),
outcome.to_model_content(),
));
}
}
self.sink.emit(AgentEvent::TurnFinished {
steps: used_steps,
text: last_text.clone(),
});
Ok(TurnOutcome {
text: last_text,
steps: used_steps,
tool_calls,
stopped_reason: StopReason::StepLimit,
})
}
fn build_request(&self, history: &[ChatMessage]) -> ChatRequest {
let config = self.provider.config();
let mut messages = Vec::with_capacity(history.len() + 1);
messages.push(ChatMessage::system(self.config.system_prompt.clone()));
messages.extend_from_slice(history);
ChatRequest {
model: config.model.clone(),
messages,
tools: self.registry.definitions(),
temperature: config.temperature,
max_tokens: config.max_tokens,
}
}
}
+340
View File
@@ -0,0 +1,340 @@
//! Provider 四态端点配置(02 文档 §2)。
//!
//! | 形态 | 端点 | 备注 |
//! |------|------|------|
//! | OpenAI 兼容(默认) | `base_url` + `api_key` + `model` | 覆盖 OpenAI/DeepSeek/Qwen/GLM/**本地 llama.cpp server**(`http://127.0.0.1:8080/v1`)/LM Studio |
//! | Anthropic 原生 | `api_key` + `model` | Claude 系列(没有可用的 Rust 单厂商 SDK,见 research/02 §3.6) |
//! | Ollama 原生 | `http://127.0.0.1:11434` | 本地另一种主流 |
//! | 禁用 | — | 完全关闭 agent,规则引擎(`diagnose_crash`)仍可用 |
//!
//! 本文件只放**配置与校验**;对话协议在 [`super::chat`]。这样 rig 的 pre-1.0 破坏面
//! (research/02 §7 R1)被关在 [`super::rig`] 一个文件里,这里连 rig 的名字都不出现。
use std::fmt;
use serde::{Deserialize, Serialize};
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
/// Ollama 默认端点(research/02 §5.2)。
pub const OLLAMA_DEFAULT_BASE_URL: &str = "http://127.0.0.1:11434";
/// Anthropic 官方 API 端点。
pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
/// Provider 四态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
/// OpenAI 兼容(含本地 llama.cpp / LM Studio / DeepSeek / Qwen…)。
OpenAiCompatible,
/// Anthropic 原生。
Anthropic,
/// Ollama 原生。
Ollama,
/// 完全禁用 agent。
Disabled,
}
impl ProviderKind {
/// 该形态是否必须提供 API key(本地推理通常不需要)。
#[must_use]
pub fn requires_api_key(self) -> bool {
matches!(self, Self::Anthropic)
}
/// 该形态的默认端点(OpenAI 兼容没有统一默认值,必须显式填;
/// 本地 llama.cpp 走 [`ProviderConfig::llamacpp_local`] 带的显式默认)。
#[must_use]
pub fn default_base_url(self) -> &'static str {
match self {
Self::OpenAiCompatible => "",
Self::Anthropic => ANTHROPIC_DEFAULT_BASE_URL,
Self::Ollama => OLLAMA_DEFAULT_BASE_URL,
Self::Disabled => "",
}
}
/// 设置页展示名。
#[must_use]
pub fn display_name(self) -> &'static str {
match self {
Self::OpenAiCompatible => "OpenAI 兼容",
Self::Anthropic => "Anthropic 原生",
Self::Ollama => "Ollama 原生",
Self::Disabled => "已禁用",
}
}
}
/// Provider 配置(设置 → AI 的一份快照)。
///
/// `api_key` 只在进程内存在,**任何日志/审计/事件都不得带它**——
/// `Debug` 是手写的(掩码 key),要连形态一起看用 [`ProviderConfig::redacted_debug`]。
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderConfig {
/// 四态。
pub kind: ProviderKind,
/// 端点;空串表示用 [`ProviderKind::default_base_url`]。
pub base_url: String,
/// 模型名;`Disabled` 时可为空。
pub model: String,
/// API key(本地推理可空)。
pub api_key: Option<String>,
/// 采样温度。
pub temperature: Option<f32>,
/// 单次回复 token 上限。
pub max_tokens: Option<u32>,
}
impl Default for ProviderConfig {
fn default() -> Self {
Self::disabled()
}
}
impl ProviderConfig {
fn base(kind: ProviderKind, base_url: &str, model: &str) -> Self {
Self {
kind,
base_url: base_url.to_string(),
model: model.to_string(),
api_key: None,
temperature: None,
max_tokens: None,
}
}
/// OpenAI 兼容端点(云端或自建)。
#[must_use]
pub fn openai_compatible(base_url: &str, model: &str) -> Self {
Self::base(ProviderKind::OpenAiCompatible, base_url, model)
}
/// 本机 llama.cpp server(用户就在跑 llama-server 的默认场景)。
#[must_use]
pub fn llamacpp_local(model: &str) -> Self {
Self::base(
ProviderKind::OpenAiCompatible,
LLAMACPP_DEFAULT_BASE_URL,
model,
)
}
/// Anthropic 原生。
#[must_use]
pub fn anthropic(model: &str) -> Self {
Self::base(ProviderKind::Anthropic, ANTHROPIC_DEFAULT_BASE_URL, model)
}
/// 本机 Ollama。
#[must_use]
pub fn ollama(model: &str) -> Self {
Self::base(ProviderKind::Ollama, OLLAMA_DEFAULT_BASE_URL, model)
}
/// 禁用 agent。
#[must_use]
pub fn disabled() -> Self {
Self::base(ProviderKind::Disabled, "", "")
}
/// 带上 API key。
#[must_use]
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
/// 带上温度。
#[must_use]
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}
/// agent 是否启用。
#[must_use]
pub fn is_enabled(&self) -> bool {
self.kind != ProviderKind::Disabled
}
/// 实际使用的端点(空则回落默认值)。
#[must_use]
pub fn resolved_base_url(&self) -> &str {
if self.base_url.trim().is_empty() {
self.kind.default_base_url()
} else {
self.base_url.trim()
}
}
/// 配置是否可用(装配期调用一次,错就进设置页提示而不是等到对话时炸)。
///
/// # Errors
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
pub fn validate(&self) -> Result<(), ProviderError> {
if !self.is_enabled() {
return Err(ProviderError::new(
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
));
}
if self.model.trim().is_empty() {
return Err(ProviderError::new("没有配置模型名(model)"));
}
if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::new(format!(
"{} 需要填写端点(base_url)",
self.kind.display_name()
)));
}
if self.kind.requires_api_key()
&& self
.api_key
.as_deref()
.is_none_or(|key| key.trim().is_empty())
{
return Err(ProviderError::new(format!(
"{} 需要 API key",
self.kind.display_name()
)));
}
Ok(())
}
/// 可打印的形态描述(**key 已掩码**,给日志/审计/设置页用)。
#[must_use]
pub fn redacted_debug(&self) -> String {
let key = match self.api_key.as_deref() {
None => "无".to_string(),
Some(k) if k.trim().is_empty() => "无".to_string(),
Some(_) => crate::redact::MASK.to_string(),
};
format!(
"{} base_url={} model={} api_key={key}",
self.kind.display_name(),
self.resolved_base_url(),
if self.model.is_empty() {
"(未设置)"
} else {
&self.model
},
)
}
}
impl fmt::Debug for ProviderConfig {
/// 手写 Debug:**key 掩码**(`api_key` 是唯一不能出现在日志里的字段)。
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("base_url", &self.resolved_base_url())
.field("model", &self.model)
.field(
"api_key",
&self.api_key.as_deref().map(|_| crate::redact::MASK),
)
.field("temperature", &self.temperature)
.field("max_tokens", &self.max_tokens)
.finish()
}
}
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderError {
message: String,
}
impl ProviderError {
/// 从可读文本构造。
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for ProviderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ProviderError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config =
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
}
+368
View File
@@ -0,0 +1,368 @@
//! rig 适配层:[`ChatProvider`] 的**唯一**实现,也是整个 crate 唯一允许 `use rig_*` 的文件。
//!
//! 依赖面收窄纪律(`DevDocs/PlanDoc/02-ai-agent-design.md` §0 + research/02 §7 R1):
//! rig 是 pre-1.0、12 周 5 个 minor、CHANGELOG 满是 `[**breaking**]`(0.43 把 MCP 拆出 rig-agent、
//! 0.45 升 rmcp 3.5 并改 owned connection)。所以把它关在这一个文件里:它变了,只有这里编译失败,
//! [`super::ChatProvider`] 以上的循环/编排/审计一行不用动。
//!
//! ## 0.45 的接线(实读源码,非照文档猜)
//!
//! ```text
//! ProviderConfig ─┬─ OpenAiCompatible → OpenAIConfig::new(key).with_base_url(url).client().chat(model)
//! ├─ Anthropic → AnthropicConfig::new(key).with_base_url(url).client().completion(model)
//! ├─ Ollama → OllamaConfig::new().with_base_url(url).client().completion(model)
//! └─ Disabled → 不构造(Brain::run_turn 已提前返回)
//! ↓ Model<W>.erase()
//! DynModel<Completion>(wire + transport 擦除,本文件只持有这一个类型)
//! ```
//!
//! 循环侧只要两件事:流式文本增量、以及折好的 [`ChatReply`](文本 + 工具调用)。
//! 所以这里用 `model.stream(request)` 边转发 [`StreamEvent::Text`] 增量给 [`DeltaObserver`],
//! 最后 `Streamed::finish()` 拿到完整 [`CompletionResponse`]——工具调用从响应里取,
//! 不自己拼分片 JSON(拼分片是 rig 的 `ReplyFold` 已经做过的事)。
//!
//! ## 三个已确认的坑
//!
//! 1. **llama.cpp 默认端点就是 `http://localhost:8080/v1`**(`providers/llamacpp/mod.rs`),
//! 与 [`LLAMACPP_DEFAULT_BASE_URL`] 一致。这里统一走 OpenAI 方言 + 显式 base_url:
//! llama.cpp / LM Studio / DeepSeek 都是 OpenAI 兼容面,方言差异主要在默认端点与特有位
//! (`chat_template_kwargs`、`num_ctx`)——真要下发这些,再换成 `providers::llamacpp` 方言或
//! Ollama 的 `native_completion`。
//! 2. **Ollama 原生方言的工具结果用 `tool_name` 配对(没有 id)**,OpenAI 方言用 `tool_call_id`。
//! 这里走 rig 的 `Ollama::completion`(OpenAI 兼容 `/v1/chat/completions`),由 rig 负责回灌形状;
//! 要下发 `num_ctx`(崩溃日志几千行,上下文必须可配)才需要 `native_completion`,留作 P1。
//! 3. **取消**:`Streamed` 是普通 future/stream,丢弃即断开——P0 用「丢弃 future」实现停止生成,
//! P1 接 UI 的「停止」按钮时按 rig 的取消语义(drop stream)对齐即可,不需要 unsafe。
//!
//! ## 逃生口(保留但不是 Plan A)
//!
//! rig 的破坏变更真伤到(或 Rust 1.92 MSRV 追不上 rig 的 1.97.1)时:换 `genai`
//! (provider + function calling,无 agent 循环)或自己写 OpenAI 方言(约 300~500 行)。
//! **代价只是实现一个新的 [`ChatProvider`]**——这正是把它关在一个文件里的意义。
//!
//! [`LLAMACPP_DEFAULT_BASE_URL`]: super::provider::LLAMACPP_DEFAULT_BASE_URL
//! [`StreamEvent::Text`]: rig_core::streaming::StreamEvent::Text
use std::sync::Arc;
use futures_util::StreamExt;
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
use rig_core::error::ProviderError as RigError;
use rig_core::message::{
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
ToolName as RigToolName, ToolResult, ToolResultContent, UserContent,
};
use rig_core::operation::Completion;
use rig_core::providers::anthropic::AnthropicConfig;
use rig_core::providers::ollama::OllamaConfig;
use rig_core::providers::openai::OpenAIConfig;
use rig_core::streaming::{Item, StreamEvent};
use rig_core::DynModel;
use super::chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
ProviderFuture,
};
use super::provider::{ProviderConfig, ProviderError, ProviderKind};
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
pub const RIG_VERSION_PIN: &str = "=0.45.0";
/// 擦掉 wire / transport 之后的补全模型——本文件对外只暴露这一个 rig 类型之外的东西。
type ErasedModel = DynModel<Completion>;
/// rig 实现的 [`ChatProvider`]:四态配置 + 一个擦除后的模型。
pub struct RigProvider {
config: ProviderConfig,
model: ErasedModel,
}
impl RigProvider {
/// 按配置造 provider。`Disabled`、缺模型名、缺 key 都在这里被拒(fail-fast)。
///
/// # Errors
/// AI 被禁用、配置不完整、或 rig 端点构造失败时返回可读错误。
pub fn new(config: ProviderConfig) -> Result<Self, ProviderError> {
config.validate()?;
let model = build_model(&config)?;
Ok(Self { config, model })
}
/// 装配层用的工厂:`ProviderFactory` 直接塞给 `crates/app`。
#[must_use]
pub fn factory() -> ProviderFactory {
Arc::new(|config: &ProviderConfig| {
Ok(Arc::new(Self::new(config.clone())?) as Arc<dyn ChatProvider>)
})
}
}
impl ChatProvider for RigProvider {
fn config(&self) -> &ProviderConfig {
&self.config
}
fn complete<'a>(
&'a self,
request: &'a ChatRequest,
observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a> {
Box::pin(async move {
let rig_request = build_request(request)?;
let mut stream = self.model.stream(rig_request).map_err(map_error)?;
// 先转发增量,再拿折好的完整响应(工具调用只从响应取,不拼分片)。
let mut text = String::new();
while let Some(item) = stream.next().await {
match item.map_err(map_error)? {
Item::Event(StreamEvent::Text { text: fragment, .. }) => {
text.push_str(&fragment);
observer.on_delta(&fragment);
}
Item::Event(_) | Item::Unknown(_) => {}
}
}
let response = stream.finish().await.map_err(map_error)?;
to_reply(&response, text)
})
}
}
/// 四态 → rig 模型。
fn build_model(config: &ProviderConfig) -> Result<ErasedModel, ProviderError> {
let model = config.model.trim().to_string();
let base_url = config.resolved_base_url().to_string();
let api_key = config.api_key.clone().unwrap_or_default();
let erased = match config.kind {
ProviderKind::OpenAiCompatible => OpenAIConfig::new(api_key)
.with_base_url(base_url)
.client()
.chat(model)
.erase(),
ProviderKind::Anthropic => AnthropicConfig::new(api_key)
.with_base_url(base_url)
.client()
.completion(model)
.erase(),
ProviderKind::Ollama => OllamaConfig::new()
.with_base_url(base_url)
.client()
.completion(model)
.erase(),
ProviderKind::Disabled => {
return Err(ProviderError::new(
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
))
}
};
Ok(erased)
}
/// 本方消息 → rig 补全请求。
fn build_request(request: &ChatRequest) -> Result<CompletionRequest, ProviderError> {
let mut history = to_rig_messages(&request.messages)?;
// rig 把历史最后一条当 prompt,其余是上下文;空历史没有可发的 prompt。
let prompt = history
.pop()
.ok_or_else(|| ProviderError::new("对话历史为空:至少需要一条用户消息"))?;
let prompt = Arc::unwrap_or_clone(prompt);
let tools = request
.tools
.iter()
.map(to_rig_tool)
.collect::<Result<Vec<_>, _>>()?;
Ok(CompletionRequest::new(prompt)
.messages(history)
.tools(tools)
.temperature(request.temperature.map(f64::from))
.max_tokens(request.max_tokens.map(u64::from)))
}
/// 本方工具定义 → rig 工具定义(入参都是裸 JSON Schema,这正是双投影的支点)。
fn to_rig_tool(tool: &crate::registry::ToolDefinition) -> Result<RigToolDefinition, ProviderError> {
let name = tool_name(&tool.name)?;
Ok(RigToolDefinition::new(
name,
tool.description.clone(),
tool.input_schema.clone(),
))
}
/// 本方消息 → rig 消息。
fn to_rig_messages(messages: &[ChatMessage]) -> Result<Vec<Arc<RigMessage>>, ProviderError> {
let mut out = Vec::with_capacity(messages.len());
for message in messages {
let rig = match message {
ChatMessage::System { content } => RigMessage::system(content.clone()),
ChatMessage::User { content } => RigMessage::user(content.clone()),
ChatMessage::Assistant { text, tool_calls } => {
let mut parts = Vec::with_capacity(tool_calls.len() + 1);
if !text.trim().is_empty() {
parts.push(RigAssistantContent::Text(RigText::new(text.clone())));
}
for call in tool_calls {
parts.push(RigAssistantContent::ToolCall(RigToolCall::new(
CallId::from_wire(call.id.clone()),
ToolFunction::new(tool_name(&call.name)?, call.arguments.clone()),
)));
}
RigMessage::Assistant(RigAssistantMessage::new(parts))
}
ChatMessage::Tool {
call_id,
name,
content,
} => RigMessage::User {
content: vec![UserContent::ToolResult(ToolResult {
call: CallId::from_wire(call_id.clone()),
name: tool_name(name)?,
content: vec![ToolResultContent::text(content.clone())],
is_error: false,
})],
},
};
out.push(Arc::new(rig));
}
Ok(out)
}
/// rig 响应 → 本方回复(文本 + 工具调用 + 用量)。
fn to_reply(
response: &rig_core::completion::CompletionResponse,
streamed: String,
) -> Result<ChatReply, ProviderError> {
let mut text = streamed;
let mut tool_calls = Vec::new();
for part in &response.choice {
match part {
RigAssistantContent::Text(chunk) => {
if text.is_empty() {
text.push_str(&chunk.text);
}
}
RigAssistantContent::ToolCall(call) => {
if let Some(raw) = call.function.invalid_arguments.as_deref() {
return Err(ProviderError::new(format!(
"模型返回的工具调用参数不是 JSON 对象(工具 `{}`):{raw}",
call.function.name.as_str()
)));
}
tool_calls.push(super::chat::ToolCall {
id: call.id.to_string(),
name: call.function.name.as_str().to_string(),
arguments: serde_json::Value::Object(call.function.arguments.clone()),
});
}
// 推理链/图片/未知块不进对话循环(P0 只消费文本 + 工具调用)。
_ => {}
}
}
Ok(ChatReply {
text,
tool_calls,
usage: Some(super::chat::Usage {
prompt_tokens: response
.usage
.input_tokens
.and_then(|v| u32::try_from(v).ok()),
completion_tokens: response
.usage
.output_tokens
.and_then(|v| u32::try_from(v).ok()),
}),
})
}
/// rig 错误 → 本方错误(文案面向用户)。
fn map_error(error: RigError) -> ProviderError {
ProviderError::new(error.to_string())
}
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
RigToolName::new(name)
.map_err(|_| ProviderError::new("工具名不能为空(ToolSpec 注册阶段应已拦截)"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(RigProvider::new(
ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")
)
.is_ok());
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
}
+139
View File
@@ -0,0 +1,139 @@
//! 时间源:审计日志、事件流、工具耗时都要时间戳,但**测试不能依赖真实时钟**
//! (否则断言不稳定)。所以照既有做法注入 [`Clock`]。
//!
//! 时间戳格式化自己实现([`format_unix_ms`]),不引 `chrono`:agent 的依赖面按
//! `DevDocs/PlanDoc/02-ai-agent-design.md` §0 越窄越好,RFC3339 UTC 只需要一个
//! 民用历换算(Howard Hinnant 的 `civil_from_days`)。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
/// 时间源。生产用 [`SystemClock`],测试用 [`FixedClock`]。
pub trait Clock: Send + Sync {
/// 当前 Unix 毫秒时间戳。
fn now_unix_ms(&self) -> u64;
}
/// 真实系统时钟(单调性不保证,审计只要"大致有序")。
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now_unix_ms(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
}
}
/// 可控时钟:测试里固定起点、必要时手动推进。
#[derive(Debug)]
pub struct FixedClock {
ms: AtomicU64,
step_ms: u64,
}
impl FixedClock {
/// 从固定时间戳开始,每次读走一步 `step_ms`(0 = 不自动推进)。
#[must_use]
pub fn new(start_ms: u64) -> Self {
Self {
ms: AtomicU64::new(start_ms),
step_ms: 0,
}
}
/// 每次读取自动前进 `step_ms`,用来断言工具耗时字段。
#[must_use]
pub fn with_step(start_ms: u64, step_ms: u64) -> Self {
Self {
ms: AtomicU64::new(start_ms),
step_ms,
}
}
/// 手动推进。
pub fn advance(&self, ms: u64) {
self.ms.fetch_add(ms, Ordering::Relaxed);
}
}
impl Clock for FixedClock {
fn now_unix_ms(&self) -> u64 {
if self.step_ms == 0 {
return self.ms.load(Ordering::Relaxed);
}
self.ms.fetch_add(self.step_ms, Ordering::Relaxed) + self.step_ms
}
}
/// `Arc<dyn Clock>` 的简写。
pub type SharedClock = Arc<dyn Clock>;
/// Unix 毫秒 → `YYYY-MM-DDTHH:MM:SS.mmmZ`(UTC)。
#[must_use]
pub fn format_unix_ms(ms: u64) -> String {
let secs = (ms / 1000) as i64;
let millis = ms % 1000;
let days = secs.div_euclid(86_400);
let sod = secs.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
let (h, mi, s) = (sod / 3600, (sod % 3600) / 60, sod % 60);
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z")
}
/// Unix 毫秒 → `YYYY-MM-DD`(UTC),审计落盘按天分文件用。
#[must_use]
pub fn date_of_unix_ms(ms: u64) -> String {
let (y, m, d) = civil_from_days(((ms / 1000) as i64).div_euclid(86_400));
format!("{y:04}-{m:02}-{d:02}")
}
/// 1970-01-01 起的天数 → (年, 月, 日)。
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_epoch_and_known_instants() {
assert_eq!(format_unix_ms(0), "1970-01-01T00:00:00.000Z");
// 2026-10-29T20:12:33.123Z
assert_eq!(
format_unix_ms(1_793_304_753_123),
"2026-10-29T20:12:33.123Z"
);
assert_eq!(date_of_unix_ms(1_793_304_753_123), "2026-10-29");
}
#[test]
fn handles_leap_day() {
// 2024-02-29T00:00:00Z
assert_eq!(
format_unix_ms(1_709_164_800_000),
"2024-02-29T00:00:00.000Z"
);
}
#[test]
fn fixed_clock_can_step() {
let clock = FixedClock::with_step(1_000, 5);
assert_eq!(clock.now_unix_ms(), 1_005);
assert_eq!(clock.now_unix_ms(), 1_010);
clock.advance(10);
assert_eq!(clock.now_unix_ms(), 1_025);
}
}
+235
View File
@@ -0,0 +1,235 @@
//! 分级确认协议(02 文档 §3.1「分级确认」+ §4b「YOLO」)。
//!
//! 分工:
//! - **工具**只声明「这次调用需要确认」并给出 diff([`ConfirmRequest`]),不自己做决定;
//! - **编排层**([`crate::brain::exec`])拿 [`ConfirmPolicy`] 决定是问人、还是走 YOLO;
//! - **UI** 在 P1 通过 [`ConfirmGate`] 接进来(P0 只有事件 + fail-closed 的默认实现)。
//!
//! fail-closed 是刻意的:P0 没有确认弹窗,敏感键的写入**一律拒绝**而不是静默放行
//! (research/02 §7 R9「写操作的破坏半径」)。
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// 敏感等级。**与 M12 `setting::spec::Sensitivity` 逐字一致**
/// (`DevDocs/SubAgentsTalk/setting-接口约定.md` §8):
///
/// | 等级 | 语义 |
/// |------|------|
/// | `Low` | 静默生效 |
/// | `Medium` | 静默生效,审计里能查到(每次调用本来都审计,这里只是标记用途) |
/// | `High` | 必须用户确认(YOLO 可跳过确认,但**仍受白名单约束**) |
///
/// 「AI 完全不能写」的键(`ai.api_key` 等凭据)由 M12 的 `writable=false` 表达,
/// 它们**不进白名单**,agent 侧走「不在白名单」的拒绝分支,不需要第四级。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Sensitivity {
/// 静默生效。
#[default]
Low,
/// 静默生效但审计标记(下载源、语言、日志级别)。
Medium,
/// 必须用户确认(数据目录、Java 路径、代理、AI 端点/YOLO 开关)。
High,
}
/// 一个待确认的操作:给用户看的措辞 + 结构化细节(UI 画 diff 用)。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ConfirmRequest {
/// 请求 id(编排层生成,UI 回填决定时原样带回)。
pub id: String,
/// 工具名。
pub tool: String,
/// 一句话请求(「AI 请求把 `instance.java.path` 改为 `D:\jdk17`」)。
pub summary: String,
/// 结构化细节(`{"path":…, "old":…, "new":…}`)。
pub details: Value,
/// 敏感等级。
pub sensitivity: Sensitivity,
}
/// 用户决定。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfirmDecision {
/// 允许这一次。
Approved,
/// 拒绝(工具调用返回「用户拒绝」给模型)。
Denied,
/// 允许,且本会话后续不再询问(UI 的「全部允许」)。
///
/// 编排层当前**只当普通放行**处理(`brain/exec.rs` 不区分两者);「据此临时开 YOLO」
/// 是 P1 的行为,落地前不要按已实现来看。
ApprovedAll,
}
impl ConfirmDecision {
/// 是否放行(`Approved` 与 `ApprovedAll` 都放行)。
#[must_use]
pub fn is_approved(self) -> bool {
matches!(self, Self::Approved | Self::ApprovedAll)
}
}
/// 确认闸门:P1 由 app 的 Slint 弹窗实现;测试用 [`ScriptedGate`]。
pub trait ConfirmGate: Send + Sync {
/// 发起确认并等待决定(UI 未接时由实现方立即给出 fail-closed 结果)。
fn request(&self, request: ConfirmRequest) -> ConfirmFuture;
}
/// 确认未来的箱子类型。
pub type ConfirmFuture = Pin<Box<dyn Future<Output = ConfirmDecision> + Send + 'static>>;
/// 一律放行(YOLO 单测 / 未来"用户已信任该工具"场景)。
#[derive(Debug, Default, Clone, Copy)]
pub struct AutoApproveGate;
impl ConfirmGate for AutoApproveGate {
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
Box::pin(async { ConfirmDecision::Approved })
}
}
/// 一律拒绝(fail-closed 的极简形态)。
#[derive(Debug, Default, Clone, Copy)]
pub struct AutoDenyGate;
impl ConfirmGate for AutoDenyGate {
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
Box::pin(async { ConfirmDecision::Denied })
}
}
/// P0 默认闸门:**没有确认 UI** → 一律拒绝(fail-closed)。
///
/// 请求本身已经由编排层作为 [`crate::event::AgentEvent::ConfirmRequested`] 发出去了,
/// 所以 P0 的行为是「敏感操作既不静默执行,也不卡住对话」:事件流里留下了
/// 「AI 想改 X」的完整记录,P1 接上弹窗后同一个事件直接变成可点的确认框。
#[derive(Debug, Default, Clone, Copy)]
pub struct NoUiGate;
impl ConfirmGate for NoUiGate {
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
Box::pin(async { ConfirmDecision::Denied })
}
}
/// 脚本化闸门:按顺序返回预设决定,用完之后走 `default`(测试用)。
pub struct ScriptedGate {
decisions: Mutex<VecDeque<ConfirmDecision>>,
default: ConfirmDecision,
}
impl ScriptedGate {
/// 依次给出 `decisions`,耗尽后返回 `default`。
#[must_use]
pub fn new(
decisions: impl IntoIterator<Item = ConfirmDecision>,
default: ConfirmDecision,
) -> Self {
Self {
decisions: Mutex::new(decisions.into_iter().collect()),
default,
}
}
}
impl ConfirmGate for ScriptedGate {
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
let decision = self
.decisions
.lock()
.ok()
.and_then(|mut q| q.pop_front())
.unwrap_or(self.default);
Box::pin(async move { decision })
}
}
/// 确认策略(编排层用)。YOLO 的真实开关在设置里,这里只是当次运行的快照。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ConfirmPolicy {
/// 自动批准全部工具调用(设置 → AI → YOLO,默认关)。
pub yolo: bool,
}
/// 拒绝时的引导口径:**内置对话**有弹窗可点,**外部 MCP** 没有 UI——这条差别只在
/// 「拒绝文案」上,策略本身完全相同(同一条 `ConfirmPolicy`、同一份审计)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DenyGuidance {
/// 内置对话:弹窗拒绝(或 P0 的 fail-closed)。
InApp,
/// 外部 MCP 客户端:引导用户去设置页确认,或打开 YOLO。
External,
}
impl DenyGuidance {
/// 拒绝后回给模型/用户的一句话(区分「谁拒的」与「下一步怎么办」)。
#[must_use]
pub fn message(self) -> &'static str {
match self {
Self::InApp => "用户拒绝了这次操作,未做任何修改",
Self::External => {
"这次操作需要用户确认,但当前是外部 MCP 客户端(没有确认弹窗),已按默认策略拒绝。\
请让用户在启动器设置页手动修改,或在设置 → AI 里打开 YOLO 模式后重试(审计照记)。"
}
}
}
}
impl ConfirmPolicy {
/// 该等级是否需要**询问**用户:只有 `High` 会问;YOLO 下连 `High` 也不问
/// (02 文档 §4b:YOLO 跳过分级确认,但**仍受白名单约束**——能改的键没变少)。
#[must_use]
pub fn requires_confirmation(self, sensitivity: Sensitivity) -> bool {
!self.yolo && sensitivity == Sensitivity::High
}
/// 该等级是否可以直接执行(不需要询问)。
#[must_use]
pub fn auto_approves(self, sensitivity: Sensitivity) -> bool {
!self.requires_confirmation(sensitivity)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn low_and_medium_keys_never_ask() {
let policy = ConfirmPolicy { yolo: false };
assert!(!policy.requires_confirmation(Sensitivity::Low));
assert!(!policy.requires_confirmation(Sensitivity::Medium));
assert!(policy.auto_approves(Sensitivity::Low));
assert!(policy.auto_approves(Sensitivity::Medium));
}
#[test]
fn high_keys_ask_unless_yolo() {
assert!(ConfirmPolicy { yolo: false }.requires_confirmation(Sensitivity::High));
assert!(!ConfirmPolicy { yolo: false }.auto_approves(Sensitivity::High));
assert!(!ConfirmPolicy { yolo: true }.requires_confirmation(Sensitivity::High));
assert!(ConfirmPolicy { yolo: true }.auto_approves(Sensitivity::High));
}
#[test]
fn sensitivity_matches_m12_levels() {
// 序列化名必须与 M12 `setting::spec::Sensitivity` 一致
assert_eq!(serde_json::to_string(&Sensitivity::Low).unwrap(), "\"low\"");
assert_eq!(
serde_json::to_string(&Sensitivity::Medium).unwrap(),
"\"medium\""
);
assert_eq!(
serde_json::to_string(&Sensitivity::High).unwrap(),
"\"high\""
);
}
}
+197
View File
@@ -0,0 +1,197 @@
//! agent 事件流:**UI 与审计之外的观察者**都从这里接(02 文档 §1 的「event channel」)。
//!
//! P0 只做输出([`AgentEventSink`]),P1 的 Slint 聊天面板订阅同一路事件把
//! 「正在读取日志…」「请求修改设置:下载并发 64→16」画出来,确认弹窗直接复用
//! [`AgentEvent::ConfirmRequested`]。
//!
//! 实现方必须**非阻塞**:`emit` 在对话循环的同步路径上被调用。
use std::sync::mpsc::Sender;
use std::sync::Mutex;
use serde::Serialize;
use serde_json::Value;
use crate::confirm::ConfirmRequest;
/// 一条 agent 事件(全部可序列化:P1 走 IPC 给 app 时不写两套类型)。
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
/// 一轮对话开始。
TurnStarted {
/// agent 会话 id。
session_id: String,
/// 用户输入。
user_text: String,
},
/// 流式文本增量(P1 聊天面板逐字渲染)。
TextDelta {
/// 增量文本。
text: String,
},
/// 模型这一轮的非流式完整文本(流式之外的兜底,也是审计用的正文)。
AssistantText {
/// 文本。
text: String,
},
/// 开始执行一个工具调用。
ToolCallStarted {
/// 工具名。
tool: String,
/// 模型给的参数。
args: Value,
/// 第几轮工具循环(从 1 开始)。
step: usize,
},
/// 一个工具调用结束。
ToolCallFinished {
/// 工具名。
tool: String,
/// 是否成功。
ok: bool,
/// 一句话结果摘要(UI 直接显示)。
summary: String,
/// 这次是否触发了确认请求。
requires_confirmation: bool,
},
/// 需要用户确认(P0 只发事件,P1 变成可点的弹窗)。
ConfirmRequested {
/// 待确认请求。
request: Box<ConfirmRequest>,
},
/// 工具调用被拒绝(策略或用户)。
ToolDenied {
/// 工具名。
tool: String,
/// 拒绝原因。
reason: String,
},
/// 一轮对话结束。
TurnFinished {
/// 实际用掉的最大工具轮次。
steps: usize,
/// 最终给用户看的文本。
text: String,
},
/// 循环级错误(provider 挂了、AI 被禁用…)。
Error {
/// 人类可读原因。
message: String,
},
}
/// 事件出口。
pub trait AgentEventSink: Send + Sync {
/// 发一条事件。实现方不得阻塞。
fn emit(&self, event: AgentEvent);
}
/// 丢弃事件。
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopSink;
impl AgentEventSink for NoopSink {
fn emit(&self, _event: AgentEvent) {}
}
/// 内存事件流(测试断言 + P0 的「暂不接 UI」)。
#[derive(Debug, Default)]
pub struct MemoryEventSink {
events: Mutex<Vec<AgentEvent>>,
}
impl MemoryEventSink {
/// 空 sink。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 取快照。
#[must_use]
pub fn events(&self) -> Vec<AgentEvent> {
self.events.lock().map(|e| e.clone()).unwrap_or_default()
}
/// 已发条数。
#[must_use]
pub fn len(&self) -> usize {
self.events.lock().map(|e| e.len()).unwrap_or(0)
}
/// 是否还没发过事件。
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 拼接所有文本增量(测试断言「流式内容拼起来等于完整文本」)。
#[must_use]
pub fn streamed_text(&self) -> String {
self.events()
.into_iter()
.filter_map(|e| match e {
AgentEvent::TextDelta { text } => Some(text),
_ => None,
})
.collect()
}
}
impl AgentEventSink for MemoryEventSink {
fn emit(&self, event: AgentEvent) {
if let Ok(mut events) = self.events.lock() {
events.push(event);
}
}
}
/// 标准库 channel 出口(P1 的 app 用一个线程收、喂给 Slint 的事件循环)。
#[derive(Debug, Clone)]
pub struct MpscSink {
tx: Sender<AgentEvent>,
}
impl MpscSink {
/// 包一个 `std::sync::mpsc::Sender`。
#[must_use]
pub fn new(tx: Sender<AgentEvent>) -> Self {
Self { tx }
}
}
impl AgentEventSink for MpscSink {
fn emit(&self, event: AgentEvent) {
// 接收端已关闭说明 UI 没了,事件直接丢弃(不能拖累对话)。
let _ = self.tx.send(event);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn memory_sink_collects_and_concatenates_deltas() {
let sink = MemoryEventSink::new();
sink.emit(AgentEvent::TextDelta {
text: "正在".into(),
});
sink.emit(AgentEvent::TextDelta {
text: "读取日志".into(),
});
assert_eq!(sink.streamed_text(), "正在读取日志");
assert_eq!(sink.len(), 2);
}
#[test]
fn mpsc_sink_forwards() {
let (tx, rx) = std::sync::mpsc::channel();
let sink = MpscSink::new(tx);
sink.emit(AgentEvent::Error {
message: "boom".into(),
});
assert!(matches!(rx.recv(), Ok(AgentEvent::Error { .. })));
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Unlucky-Launcher AI Agent(`unlucky_agent`)。
//!
//! 形态定案(决策 D4):**内置 Agent 客户端 + 对外的 MCP server 双形态,共享同一个工具内核**。
//! 设计定稿:`DevDocs/PlanDoc/02-ai-agent-design.md`。
//!
//! 三条不能破的边界:
//! 1. 工具只定义一次([`registry`]),内置客户端与 MCP server 都把同一个 `ToolSpec` 投影出去;
//! 2. AI 是 Port 不是 Service——LLM 不可用时规则引擎仍能诊断;
//! 3. AI 永不接触账户凭据;写配置受 schema 白名单约束并留审计。
//!
//! 与 core 的关系:agent 依赖 core 的公共 API,core 不知道 agent 的存在。
//! 落地方式是把需要 core 的能力收成[`port`]里的五个 trait(配置读写 / 会话日志 / 崩溃诊断 /
//! 实例与 Java / 账户),由装配层用 core 的实现填充——这样 M12/M14/M15/M18/M31/M33 未合并时
//! agent 也能独立编译、独立测试。装配处有两条:`crates/app`(GUI)与 `ull agent serve`(headless)。
//!
//! 当前状态:工具内核 + 对话循环 + 审计(P0 定稿)+ **对外 MCP server**(M39)已落地。
//! 两个外部依赖都收口在单文件:[`brain::rig`] 对 rig、[`mcp`] 对 rmcp(3.8.x)——
//! 它们升 breaking 时只有那两个模块编译失败,上层的循环/编排/审计一行不用动。
//!
//! 模块分工:
//! - [`registry`] 工具注册表(唯一真相源 + 双投影载荷);
//! - [`tools`] 工具 v1 实现(配置读写 / 会话日志 / 崩溃诊断);
//! - [`brain`] Provider 抽象 + 对话循环 + 工具编排;
//! - [`mcp`] 对外 MCP server(rmcp 双投影 + stdio / Streamable HTTP 传输);
//! - [`confirm`] 分级确认协议与 YOLO 策略;
//! - [`audit`] 每次工具调用的审计留痕;
//! - [`event`] 面向 UI 的事件流;
//! - [`port`] 与 core 的接触面(依赖倒置);
//! - [`redact`] 密钥/主目录脱敏;
//! - [`clock`] 可注入时间源(审计/事件用);
//! - [`testing`] 五个端口的 mock 实现(供本 crate、app 与 MCP 契约测试复用)。
#![forbid(unsafe_code)]
pub mod audit;
pub mod brain;
pub mod clock;
pub mod confirm;
pub mod event;
pub mod mcp;
pub mod port;
pub mod redact;
pub mod registry;
pub mod testing;
pub mod tools;
+159
View File
@@ -0,0 +1,159 @@
//! 对外 MCP server(02 文档 §1「双形态共享一个工具内核」的第二形态)。
//!
//! 传输定论(§0):**Streamable HTTP on 127.0.0.1 是主力**(外部 agent 起 stdio 会变成
//! 双实例抢数据目录锁),**stdio 形态保留**给 llama.cpp server 的内置 MCP client。
//!
//! 与内置客户端的关系:两侧都只吃 [`crate::registry::ToolRegistry`] 的 `(name, description,
//! input_schema)`,并且**都必须走** [`crate::brain::exec::ToolExecutor`]——schema 校验、
//! 分级确认、YOLO、审计一个字都不少。所以「用户在聊天里让 AI 改配置」与「Claude Code
//! 通过 MCP 改配置」是同一条路径(02 §1 的铁律)。
//!
//! 安全边界(MCP 场景的差别只有一条):
//! - 敏感键(`Sensitivity::High`)默认**拒绝**而不是弹窗——外部客户端没有我们的 UI;
//! 拒绝文案引导用户去设置页确认,或在设置里打开 `ai.yolo`(YOLO 下照记 `yolo=true` 审计);
//! - 写工具默认不暴露(`agent.mcp.allow_write=false`):只读子集注册进注册表,
//! 调写工具会得到「没有这个工具 + 可用工具清单」(并留一条 `unknown_tool` 审计)。
//!
//! 依赖面纪律:整个 crate 只有本模块允许 `use rmcp::`(与 `brain::rig` 对 rig 的规则同形)。
pub mod projection;
pub mod sample;
pub mod serve;
pub mod server;
use std::fmt;
pub use sample::{config_samples, ConfigSamples};
pub use serve::{bind, serve_http, serve_http_on, serve_stdio};
pub use server::RegistryServer;
/// 一次 MCP server 运行的配置(由设置键 `agent.mcp.*` + 命令行覆盖合成)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpConfig {
/// 监听地址。**只允许回环地址**(默认 `127.0.0.1`):MCP 工具能改配置,不能对外开。
pub host: String,
/// Streamable HTTP 端口(默认 47823,设置键 `agent.mcp.port`)。
pub port: u16,
/// 访问令牌(`Authorization: Bearer <token>`);`None` = 不校验(仅限回环监听时允许)。
pub token: Option<String>,
/// 是否只暴露只读工具(默认 **true**;`agent.mcp.allow_write=true` 才放开写侧)。
pub read_only: bool,
}
impl Default for McpConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 47823,
token: None,
read_only: true,
}
}
}
impl McpConfig {
/// `host:port` 形态的监听地址(IPv6 字面量自动加方括号,`::1` 不会拼成 `::1:47823`)。
#[must_use]
pub fn socket_addr(&self) -> String {
if self.host.contains(':') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
/// MCP 端点 URL(客户端配置样例里用的那串)。
#[must_use]
pub fn url(&self) -> String {
format!("http://{}/mcp", self.socket_addr())
}
/// 拒绝非回环监听——这是安全铁律的**代码化**,不是文档里的建议。
///
/// # Errors
/// `host` 不是回环地址时返回 [`McpServeError::NotLoopback`]。
pub fn ensure_loopback(&self) -> Result<(), McpServeError> {
let host = self.host.trim();
if host == "127.0.0.1" || host == "localhost" || host == "::1" {
return Ok(());
}
Err(McpServeError::NotLoopback(host.to_string()))
}
}
/// MCP server 启动/运行期错误(文案面向用户,可直接进 CLI 输出)。
#[derive(Debug)]
pub enum McpServeError {
/// 监听地址不是回环地址。
NotLoopback(String),
/// 端口被占用(最常见:另一个 `ull agent serve` 已经在跑)。
PortInUse {
/// 监听地址(`127.0.0.1:47823`)。
addr: String,
},
/// 其他 IO 失败(权限、端口不足、传输中断)。
Io(String),
}
impl fmt::Display for McpServeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotLoopback(host) => write!(
f,
"拒绝在 `{host}` 上监听:MCP server 只允许回环地址(127.0.0.1 / localhost / ::1)"
),
Self::PortInUse { addr } => write!(
f,
"端口已被占用:{addr}。可能另一个 `ull agent serve` 正在运行;\
用 `--port <端口>` 或设置键 `agent.mcp.port` 换一个"
),
Self::Io(message) => write!(f, "MCP server 传输失败:{message}"),
}
}
}
impl std::error::Error for McpServeError {}
impl From<std::io::Error> for McpServeError {
fn from(error: std::io::Error) -> Self {
Self::Io(error.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_loopback_hosts_are_accepted() {
let mut cfg = McpConfig::default();
assert!(cfg.ensure_loopback().is_ok());
cfg.host = "localhost".to_string();
assert!(cfg.ensure_loopback().is_ok());
cfg.host = "::1".to_string();
assert_eq!(cfg.url(), "http://[::1]:47823/mcp");
assert!(cfg.ensure_loopback().is_ok());
cfg.host = "0.0.0.0".to_string();
let err = cfg.ensure_loopback().unwrap_err();
assert!(matches!(err, McpServeError::NotLoopback(_)));
assert!(err.to_string().contains("回环地址"));
}
#[test]
fn default_is_read_only_on_the_documented_port() {
let cfg = McpConfig::default();
assert!(cfg.read_only);
assert_eq!(cfg.port, 47823);
assert_eq!(cfg.url(), "http://127.0.0.1:47823/mcp");
}
#[test]
fn port_in_use_message_is_actionable() {
let err = McpServeError::PortInUse {
addr: "127.0.0.1:47823".to_string(),
};
let text = err.to_string();
assert!(text.contains("--port"), "{text}");
assert!(text.contains("agent.mcp.port"), "{text}");
}
}
+106
View File
@@ -0,0 +1,106 @@
//! 双投影的 **MCP 侧**:`ToolDefinition` → rmcp `Tool`(`ToolRoute::new_dyn` 的属性入参)。
//!
//! rig 侧在 [`crate::brain::rig`](`ToolDefinition → RigToolDefinition`)。两侧都只吃裸
//! `serde_json::Value` schema,所以「一致性」是可断言的:`tests/mcp_contract.rs` 遍历注册表,
//! 断言 MCP 侧的 `(name, description, input_schema)` 与注册表逐项相等——这就是
//! research/02 §6.2 建议进 CI 的那条契约。
//!
//! 额外一位信息:`annotations.read_only_hint`。MCP 客户端拿它决定「这个工具能不能
//! 静默调用」,我们用它把 [`Access`] 语义透出去(只读提示,不是权限——权限在编排层)。
use std::sync::Arc;
use rmcp::model::{JsonObject, Tool, ToolAnnotations};
use serde_json::Value;
use crate::registry::{Access, ToolDefinition, ToolRegistry};
/// 一个 `ToolDefinition` → rmcp `Tool`。
///
/// 注册表在注册阶段已保证 `input_schema` 根是 `object`,所以这里的兜底分支
/// (非对象 → 空 schema)只是为了不 panic:真走到它说明注册校验被绕过了。
#[must_use]
pub fn tool_attr(definition: &ToolDefinition, access: Access) -> Tool {
let schema: JsonObject = match &definition.input_schema {
Value::Object(map) => map.clone(),
_ => JsonObject::new(),
};
let mut attr = Tool::new(
definition.name.clone(),
definition.description.clone(),
Arc::new(schema),
);
let mut annotations = ToolAnnotations::default();
annotations.read_only_hint = Some(access == Access::ReadOnly);
annotations.destructive_hint = Some(access == Access::Mutating);
attr.annotations = Some(annotations);
attr
}
/// 注册表 → rmcp `Tool` 列表(顺序同注册顺序,便于两侧比对)。
#[must_use]
pub fn tool_attrs(registry: &ToolRegistry) -> Vec<Tool> {
registry
.specs()
.iter()
.map(|spec| tool_attr(&spec.definition(), spec.access))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::mock_ports;
use crate::tools::build_v1_registry;
use serde_json::json;
fn registry() -> ToolRegistry {
let (ports, _settings) = mock_ports();
build_v1_registry(&ports).expect("注册表应装配成功")
}
#[test]
fn projection_keeps_name_description_and_schema() {
let registry = registry();
let attrs = tool_attrs(&registry);
assert_eq!(attrs.len(), registry.len());
for (attr, spec) in attrs.iter().zip(registry.specs()) {
let definition = spec.definition();
assert_eq!(attr.name.as_ref(), definition.name);
assert_eq!(
attr.description.as_deref(),
Some(definition.description.as_str()),
"{} 的说明必须逐字相等",
definition.name
);
let projected = Value::Object((*attr.input_schema).clone());
assert_eq!(
projected, definition.input_schema,
"{} 的 JSON Schema 必须逐字相等",
definition.name
);
}
}
#[test]
fn read_only_hint_matches_access() {
let registry = registry();
for (attr, spec) in tool_attrs(&registry).iter().zip(registry.specs()) {
let hint = attr
.annotations
.as_ref()
.and_then(|a| a.read_only_hint)
.unwrap_or(false);
assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name);
}
}
#[test]
fn schema_root_stays_an_object_on_the_wire() {
for attr in tool_attrs(&registry()) {
let value = Value::Object((*attr.input_schema).clone());
assert_eq!(value["type"], json!("object"), "{}", attr.name);
}
}
}
+80
View File
@@ -0,0 +1,80 @@
//! 对外客户端配置样例(用户把它们粘进 Claude Code / Cursor 就能连上)。
//!
//! 为什么要生成而不是写在文档里:端口与令牌是**用户本机**的值,手抄必错。
//! 两个客户端的形状不同(02 §0):
//! - **Claude Code**:`{"type":"http","url":…}`(`type` 必填,缺了会被当成 stdio)
//! - **Cursor**:`{"url":…}`(`mcp.json` 里只认 url;多写 type 会被忽略但不报错)
//!
//! 令牌只在设置里非空时出现——回环 + 无令牌是允许的(研究里“至少一个 token”是建议不是铁律,
//! 而我们用 `McpConfig::ensure_loopback` 硬性禁掉对外监听)。
use serde_json::{json, Value};
/// 客户端配置样例(三件套:端点 URL + 两份可直接粘的 JSON)。
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigSamples {
/// `http://127.0.0.1:<port>/mcp`。
pub url: String,
/// Claude Code(`claude mcp add` / `~/.claude.json` 的 `mcpServers` 片段)。
pub claude_code: Value,
/// Cursor(`.cursor/mcp.json` 的 `mcpServers` 片段)。
pub cursor: Value,
}
/// 生成样例。`token` 非空时补 `headers.Authorization`。
#[must_use]
pub fn config_samples(url: &str, token: Option<&str>) -> ConfigSamples {
let headers = token
.filter(|t| !t.trim().is_empty())
.map(|t| json!({ "Authorization": format!("Bearer {t}") }));
let mut claude_entry = json!({ "type": "http", "url": url });
let mut cursor_entry = json!({ "url": url });
if let Some(headers) = &headers {
claude_entry["headers"] = headers.clone();
cursor_entry["headers"] = headers.clone();
}
ConfigSamples {
url: url.to_string(),
claude_code: json!({ "mcpServers": { "unlucky-launcher": claude_entry } }),
cursor: json!({ "mcpServers": { "unlucky-launcher": cursor_entry } }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_code_needs_type_http_and_cursor_does_not() {
let samples = config_samples("http://127.0.0.1:47823/mcp", None);
assert_eq!(
samples.claude_code["mcpServers"]["unlucky-launcher"]["type"],
json!("http")
);
assert_eq!(
samples.claude_code["mcpServers"]["unlucky-launcher"]["url"],
json!("http://127.0.0.1:47823/mcp")
);
assert!(samples.cursor["mcpServers"]["unlucky-launcher"]
.get("type")
.is_none());
assert_eq!(
samples.cursor["mcpServers"]["unlucky-launcher"]["url"],
json!("http://127.0.0.1:47823/mcp")
);
}
#[test]
fn token_becomes_bearer_header_and_empty_token_is_skipped() {
let with = config_samples("http://127.0.0.1:1/mcp", Some("s3cret"));
let header = &with.cursor["mcpServers"]["unlucky-launcher"]["headers"]["Authorization"];
assert_eq!(header, &json!("Bearer s3cret"));
let without = config_samples("http://127.0.0.1:1/mcp", Some(" "));
assert!(without.claude_code["mcpServers"]["unlucky-launcher"]
.get("headers")
.is_none());
}
}
+144
View File
@@ -0,0 +1,144 @@
//! 两种传输:stdio(llama.cpp 内置 MCP client 用)与 Streamable HTTP(主力)。
//!
//! 传输定论(02 §0):**外部 MCP 主力是 127.0.0.1 上的 Streamable HTTP**,
//! 因为 stdio 形态要被外部 agent 拉起 = 同一份数据目录出现第二个实例,
//! 迟早撞上单写者锁(M18 的会话 jsonl、M12 的 launcher.json 都是单写者)。
//! stdio 保留给**已经常驻的**本地模型服务(llama.cpp server 的 MCP client 由它自己拉子进程)。
//!
//! 令牌:`Authorization: Bearer <agent.mcp.token>`。设置里为空 = 不校验(只允许回环监听,
//! `McpConfig::ensure_loopback` 已经拦掉对外监听)。research/02 §4.4 的原话是
//! 「至少要有 headers 里的一枚随机 token」——我们做成可配、默认留空但样例里带。
use std::net::SocketAddr;
use axum::extract::Request;
use axum::http::header::AUTHORIZATION;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
use rmcp::ServiceExt;
use tokio::net::TcpListener;
use crate::mcp::server::RegistryServer;
use crate::mcp::{McpConfig, McpServeError};
/// stdio 形态:读 stdin 写 stdout,直到对端关闭。
///
/// 注意:**stdout 只能有 JSON-RPC**。任何日志都要走 stderr(否则客户端解析失败)。
///
/// # Errors
/// 传输中断或初始化失败时返回 [`McpServeError`]。
pub async fn serve_stdio(server: RegistryServer) -> Result<(), McpServeError> {
let service = server
.serve(rmcp::transport::stdio())
.await
.map_err(|error| McpServeError::Io(format!("stdio 初始化失败:{error}")))?;
service
.waiting()
.await
.map_err(|error| McpServeError::Io(format!("stdio 传输中断:{error}")))?;
Ok(())
}
/// 绑定 Streamable HTTP 监听(**只绑回环**),返回实际地址。
///
/// 拆出来是为了让调用方在进入 `axum::serve` 之前就能打印「监听在哪」——
/// 端口被占用时也能拿到一条可操作的错误(tower 的提醒:撞端口要给出换端口的指引)。
///
/// # Errors
/// 非回环地址、端口被占用、或绑定失败时返回 [`McpServeError`]。
pub async fn bind(cfg: &McpConfig) -> Result<(SocketAddr, TcpListener), McpServeError> {
cfg.ensure_loopback()?;
let addr = cfg.socket_addr();
let listener = TcpListener::bind(&addr).await.map_err(|error| {
if error.kind() == std::io::ErrorKind::AddrInUse {
McpServeError::PortInUse { addr: addr.clone() }
} else {
McpServeError::Io(format!("绑定 {addr} 失败:{error}"))
}
})?;
let local = listener
.local_addr()
.map_err(|error| McpServeError::Io(error.to_string()))?;
Ok((local, listener))
}
/// Streamable HTTP 形态:`POST /mcp` 一条 JSON-RPC 请求 / 一条响应。
///
/// `json_response = true`:简单请求直接回 `application/json`(脚本与裸 JSON-RPC
/// 客户端最省事;需要服务端推送时 rmcp 会自动回落到 SSE)。
///
/// # Errors
/// 绑定失败(见 [`bind`])或服务运行期致命错误时返回 [`McpServeError`]。
pub async fn serve_http(server: RegistryServer, cfg: McpConfig) -> Result<(), McpServeError> {
let (local, listener) = bind(&cfg).await?;
serve_http_on(server, cfg, local, listener).await
}
/// 在已绑定的监听上跑(测试用临时端口,避免抢 47823)。
///
/// # Errors
/// 服务运行期致命错误时返回 [`McpServeError`]。
pub async fn serve_http_on(
server: RegistryServer,
cfg: McpConfig,
local: SocketAddr,
listener: TcpListener,
) -> Result<(), McpServeError> {
let http_config = StreamableHttpServerConfig::default()
.with_json_response(true)
.with_legacy_session_mode(false);
let factory_server = server.clone();
let service: StreamableHttpService<RegistryServer, LocalSessionManager> =
StreamableHttpService::new(
move || Ok(factory_server.clone()),
std::sync::Arc::new(LocalSessionManager::default()),
http_config,
);
let token = cfg.token.clone();
let app = axum::Router::new()
.nest_service("/mcp", service)
.layer(axum::middleware::from_fn(
move |request: Request, next: Next| {
let token = token.clone();
async move { authorize(token, request, next).await }
},
));
eprintln!(
"[unlucky-agent] MCP server 监听 http://{local}/mcp({} 个工具;令牌:{})",
server.tool_count(),
if cfg.token.is_some() {
"已启用"
} else {
"未设置(仅回环监听)"
}
);
axum::serve(listener, app)
.await
.map_err(|error| McpServeError::Io(error.to_string()))
}
/// Bearer 令牌闸门:未配置 = 放行;配置了则严格比对。
async fn authorize(token: Option<String>, request: Request, next: Next) -> Response {
let Some(expected) = token else {
return next.run(request).await;
};
let presented = request
.headers()
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if presented == format!("Bearer {expected}") {
return next.run(request).await;
}
(
StatusCode::UNAUTHORIZED,
"缺少或错误的 Authorization: Bearer <agent.mcp.token>",
)
.into_response()
}
+243
View File
@@ -0,0 +1,243 @@
//! rmcp `ServerHandler`:把同一份注册表挂成 MCP 工具路由。
//!
//! 每次 `tools/call` 都走 [`ToolExecutor`]——schema 校验、确认闸门、YOLO、审计、
//! 事件**与内置客户端完全同一条路径**(02 §1 铁律)。工具级失败返回
//! `CallToolResult::error`(客户端能看到文案),协议级错误只留给「路由不到」的情况
//! (research/02 §3.2:`Err(McpError)` 会被大多数客户端吞成 "internal error")。
use std::sync::Arc;
use rmcp::handler::server::tool::{ToolCallContext, ToolRoute, ToolRouter};
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
};
use rmcp::service::RequestContext;
use rmcp::{ErrorData as McpError, RoleServer, ServerHandler};
use serde_json::Value;
use crate::brain::chat::ToolCall;
use crate::brain::exec::ToolExecutor;
use crate::mcp::projection::tool_attr;
use crate::registry::{Access, ToolRegistry};
/// MCP server 的 `ServerHandler` 实现。
///
/// `Clone` 是 Streamable HTTP 要的:每个会话/请求由工厂造一份(共享同一批 `Arc`,
/// 因此审计与策略仍是同一份)。
#[derive(Clone)]
pub struct RegistryServer {
router: ToolRouter<RegistryServer>,
visible: Arc<Vec<Tool>>,
session_id: String,
}
impl std::fmt::Debug for RegistryServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegistryServer")
.field("session_id", &self.session_id)
.field("tools", &self.visible.len())
.finish()
}
}
impl RegistryServer {
/// 用注册表 + 执行器装配。
///
/// **`router_registry` 必须是完整注册表**(含写工具),`executor` 才决定「哪些工具真能跑」:
/// - 只读模式:`-executor` 用 `read_only_subset()` 装配,`tools/list` 只列只读项,
/// 硬调写工具会路由到执行器 → `unknown_tool`(**tool-level 错误 + 审计**),
/// 而不是 rmcp 的协议级 `-32602 tool not found`(那个客户端只会显示 "internal error")。
/// - 写模式:两者都是完整注册表。
///
/// `session_id` 进审计的 `agent_session_id` 字段(`mcp-http` / `mcp-stdio`),
/// 让复盘时能区分「内置对话改的」和「外部 MCP 改的」。
#[must_use]
pub fn new(
router_registry: Arc<ToolRegistry>,
executor: Arc<ToolExecutor>,
session_id: impl Into<String>,
read_only: bool,
) -> Self {
let mut router = ToolRouter::new();
for spec in router_registry.specs() {
let attr = tool_attr(&spec.definition(), spec.access);
router.add_route(route(attr, Arc::clone(&executor)));
}
let visible: Vec<Tool> = router_registry
.specs()
.iter()
.filter(|spec| !read_only || spec.access == Access::ReadOnly)
.map(|spec| tool_attr(&spec.definition(), spec.access))
.collect();
Self {
router,
visible: Arc::new(visible),
session_id: session_id.into(),
}
}
/// 本 server **对外可见**的工具(`tools/list` 的内容)。
#[must_use]
pub fn tools(&self) -> Vec<Tool> {
(*self.visible).clone()
}
/// 审计用的会话标签。
#[must_use]
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 对外可见的工具数(启动日志/断言用)。
#[must_use]
pub fn tool_count(&self) -> usize {
self.visible.len()
}
}
fn route(attr: Tool, executor: Arc<ToolExecutor>) -> ToolRoute<RegistryServer> {
ToolRoute::new_dyn(attr, move |context: ToolCallContext<'_, RegistryServer>| {
let executor = Arc::clone(&executor);
let session_id = context.service.session_id().to_string();
let name = context.name().to_string();
let arguments = context
.arguments
.clone()
.map_or_else(|| Value::Object(serde_json::Map::new()), Value::Object);
Box::pin(async move {
let call = ToolCall {
id: format!("mcp-{name}"),
name,
arguments,
};
let outcome = executor.execute(&session_id, &call, 0).await;
let text = outcome.to_model_content();
let content = vec![ContentBlock::text(text)];
let result = if outcome.ok {
CallToolResult::structured(outcome.content)
} else {
CallToolResult::error(content)
};
Ok(result.into())
})
})
}
impl ServerHandler for RegistryServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new(
"unlucky-launcher",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"Unlucky-Launcher 的工具服务:读写启动器配置、看实例/版本/Java、读游戏日志、跑崩溃诊断。\
账户凭据永不经由本服务出口;写配置受 schema 白名单与敏感键确认约束。",
)
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
Ok(ListToolsResult::with_all_items(self.tools()))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
let call_context = ToolCallContext::new(self, request, context);
self.router.call(call_context).await
}
fn get_tool(&self, name: &str) -> Option<Tool> {
self.router.get(name).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::MemoryAudit;
use crate::clock::{FixedClock, SharedClock};
use crate::confirm::{AutoDenyGate, ConfirmPolicy};
use crate::event::NoopSink;
use crate::testing::mock_ports;
use crate::tools::build_v1_registry;
use serde_json::json;
fn server(registry: Arc<ToolRegistry>, read_only: bool) -> (RegistryServer, Arc<MemoryAudit>) {
let audit = Arc::new(MemoryAudit::new());
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let exposed = if read_only {
Arc::new(registry.read_only_subset())
} else {
Arc::clone(&registry)
};
let executor = Arc::new(ToolExecutor::new(
exposed,
audit.clone(),
Arc::new(AutoDenyGate),
ConfirmPolicy::default(),
clock,
Arc::new(NoopSink),
));
(
RegistryServer::new(registry, executor, "mcp-test", read_only),
audit,
)
}
fn registry() -> Arc<ToolRegistry> {
let (ports, _settings) = mock_ports();
Arc::new(build_v1_registry(&ports).expect("注册表应装配成功"))
}
#[test]
fn lists_every_registered_tool_in_write_mode() {
let registry = registry();
let (server, _audit) = server(Arc::clone(&registry), false);
let tools = server.tools();
assert_eq!(tools.len(), registry.len());
assert_eq!(server.tool_count(), registry.len());
assert_eq!(server.session_id(), "mcp-test");
assert!(tools.iter().any(|tool| tool.name == "set_config"));
}
#[test]
fn read_only_mode_hides_the_writing_tool_but_keeps_it_routable() {
let (server, _audit) = server(registry(), true);
let names: Vec<String> = server.tools().iter().map(|t| t.name.to_string()).collect();
assert!(!names.iter().any(|n| n == "set_config"), "{names:?}");
assert!(names.iter().any(|n| n == "get_config"));
assert_eq!(names.len(), 9);
// 路由仍然存在(否则 rmcp 会回协议级 -32602,客户端看到的是 "internal error"),
// 真正拦下来的是只读子集执行器 → tool-level 错误 + 审计。
assert!(server.router.get("set_config").is_some());
}
#[test]
fn info_advertises_tools_and_a_stable_identity() {
let (server, _audit) = server(registry(), false);
let info = server.get_info();
assert!(info.capabilities.tools.is_some());
assert_eq!(info.server_info.name, "unlucky-launcher");
}
#[test]
fn get_tool_returns_the_projected_attr() {
let (server, _audit) = server(registry(), false);
let attr = server.get_tool("get_config").expect("工具存在");
assert_eq!(attr.name, "get_config");
assert_eq!(
Value::Object((*attr.input_schema).clone())["type"],
json!("object")
);
assert!(server.get_tool("nope").is_none());
}
}
+315
View File
@@ -0,0 +1,315 @@
//! 端口层(port):agent 与 `ull-core` 各模块之间**唯一**的接触面。
//!
//! 为什么不直接 `use ull_core::*`:M12(setting)/ M14(game)/ M15(diagnose)/ M18(launch)
//! 与本 crate 并行开发,直接依赖会把 agent 的编译期绑死在别人的 API 日期上。这里用
//! **依赖倒置**——agent 只声明自己需要的形状(本文件的 trait + 结构体),由 `crates/app`
//! 在装配处用 `ull-core` 的实现去满足(`#![forbid(unsafe_code)]` 的纯适配层)。
//!
//! | 端口 | 提供的工具 | 对方模块 / 数据源 |
//! |------|-----------|------------------|
//! | [`SettingPort`] | `get_config` / `set_config` | M12 · `config/*.json` schema |
//! | [`GameSessionPort`] | `list_game_sessions` / `read_game_log` | M18 · `agent/sessions/game/{index,<id>}.jsonl` |
//! | [`DiagnosePort`] | `diagnose_crash` | M15 · 规则引擎 |
//!
//! 形状依据(以对方文件为准,改字段先改对方文件):
//! - `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5(事件行 + 索引行)
//! - `DevDocs/SubAgentsTalk/agent-ToolSpec约定.md`(本 crate 对外的对齐说明)
//!
//! 错误一律用 `Result<T, String>`:错误文本会**原样进工具结果**给模型与用户看
//! (rmcp 的 `CallToolResult::error` 同理——协议级错误不给模型看,见 research/02 §3.2)。
//! 因此端口实现写中文、面向用户、可执行(「配置键 `x` 不存在」而不是 `ENOENT`)。
use std::collections::BTreeMap;
use std::sync::Arc;
use serde::Serialize;
use serde_json::Value;
use crate::confirm::Sensitivity;
pub mod game;
pub use game::{
AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus,
VersionSummary,
};
/// 端口调用结果的错误文本(面向用户可读,直接进工具结果)。
pub type PortResult<T> = Result<T, String>;
/// agent 需要的全部端口。装配处(`crates/app` / `ull agent serve`)用它一次性注入。
#[derive(Clone)]
pub struct Ports {
/// 配置读写(M12)。
pub setting: Arc<dyn SettingPort>,
/// 游戏会话与日志(M18)。
pub sessions: Arc<dyn GameSessionPort>,
/// 崩溃规则引擎(M15)。
pub diagnose: Arc<dyn DiagnosePort>,
/// 实例 / 版本 / Java(M14 + M33)。
pub game: Arc<dyn GamePort>,
/// 账户元数据(M31,**只有公开字段**,凭据永不出口)。
pub accounts: Arc<dyn AccountPort>,
}
// ───────────────────────────── 配置(M12) ─────────────────────────────
/// AI 可写白名单里的一个键,带**敏感分级**(02 文档 §3.1 的「分级确认」依据)。
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SettingKey {
/// 点分路径,如 `download.concurrency`、`java.selected_path`(键名以 M12 的 schema 为准)。
pub path: String,
/// 敏感等级(M12 `Low/Medium/High`):只有 `High` 需要用户确认。
pub sensitivity: Sensitivity,
/// 值类型(`string` / `integer` / `boolean` / `enum` / `json`),用于错误提示。
pub value_type: String,
/// 一句话说明,同时进 `get_config` 概要与模型上下文。
pub description: String,
}
/// 一次成功的写入,带旧值即「diff」(research/02 §6.4 的 dry-run 落点)。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct SettingChange {
/// 被修改的键路径。
pub path: String,
/// 修改前的值;键此前不存在时为 `None`。
pub old: Option<Value>,
/// 写入后的值。
pub new: Value,
}
/// 配置端口(M12 实现,`ull-core::setting` 的 schema API 直译)。
///
/// agent 侧只做**白名单 + 敏感分级 + 确认编排**,值本身的范围/类型校验归 setting 模块所有
/// (单一真相源:配置 schema 在 M12,agent 不复制一份)。
pub trait SettingPort: Send + Sync {
/// 全量概要(`get_config()` 无参时返回):键 → 当前值的扁平表,敏感键已脱敏。
fn summary(&self) -> Value;
/// 读单个键。键不存在时返回人类可读错误。
///
/// **凭据契约**:`writable=false` 的凭据类键(`ai.api_key` 等)必须**拒绝或掩码**,
/// 不得返回明文——agent 侧还有一道 `redact::is_sensitive_key` 守卫,但端口是数据的
/// 第一道闸(模型上下文与对话历史都会带上这里的返回值)。
fn get(&self, path: &str) -> PortResult<Value>;
/// 写单个键(值校验由实现方负责),返回 diff。
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange>;
/// **AI 可写白名单**(含敏感分级)。不在表里的键 agent 一律拒绝。
fn writable_keys(&self) -> Vec<SettingKey>;
/// 配置 schema 的给 AI 版本(`get_config_schema` 工具):只含 AI 可写键与
/// `x-ai-sensitivity` 标注,凭据类键天然不在其中(M12 `SettingSchema::to_json_schema`)。
fn schema(&self) -> Value;
}
// ──────────────────────── 游戏会话与日志(M18) ────────────────────────
/// `index.jsonl` 一行 → 一个会话摘要(同 id 多行由实现方取最后一行)。
///
/// 字段对齐 `launch-接口约定.md` §5;对方新增字段时这里补 `Option`,不破坏旧实现。
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct GameSessionSummary {
/// 会话 id,格式 `YYYYMMDDTHHMMSS-<6 位 hex>`(UTC)。
pub session_id: String,
/// 实例 id。
pub instance_id: Option<String>,
/// 实例显示名。
pub instance_name: Option<String>,
/// MC 版本。
pub game_version: Option<String>,
/// 启动时间(RFC3339 UTC)。
pub started_at: Option<String>,
/// 结束时间;会话未结束时为 `None`。
pub ended_at: Option<String>,
/// 时长毫秒。
pub duration_ms: u64,
/// 退出码;未正常结束时可能缺失。
pub exit_code: Option<i32>,
/// `exit_type`:`normal|application_error|jvm_error|sigkill|sigterm|killed|interrupted`。
pub exit_type: Option<String>,
/// exit 0 但疑似静默失败(D8 升级 1)。
pub silent_failure: bool,
/// 是否检测到游戏窗口。
pub window_detected: bool,
/// 事件条数。
pub event_count: u64,
/// 是否发现 crash-report / hs_err。
pub crash_found: bool,
/// 规则引擎首选结论 id(`diagnosis` 事件的 top candidate)。
pub diagnosis_top: Option<String>,
}
/// `read_game_log` 的查询条件:**按事件 seq 切片**,不是按行号
/// (stdout/stderr 是一行一事件,但同一条日志行可能被切成多块,见 launch §1)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LogQuery {
/// 起始 `seq`(含)。
pub from_seq: Option<u64>,
/// 结束 `seq`(含)。
pub to_seq: Option<u64>,
/// 只要这些 `kind`(空 = 全部)。未知 kind 由实现方忽略而不是报错(前向兼容)。
pub kinds: Vec<String>,
/// 最多返回多少条事件(工具层已钳到 [`crate::tools::session::MAX_LOG_EVENTS`])。
pub max_events: usize,
}
/// 一条事件行(`{v,seq,ts,t_ms,kind,session_id,payload}`),落盘时已脱敏。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct LogEvent {
/// 会话内单调递增序号。
pub seq: u64,
/// RFC3339 UTC。
pub ts: String,
/// 距会话开始的毫秒数。
pub t_ms: u64,
/// 事件类型(`stdout|stderr|exit|crash_found|diagnosis|…`)。
pub kind: String,
/// 事件负载,原样透传(已脱敏)。
pub payload: Value,
}
/// 一页日志事件 + 全量统计(模型拿到摘要就不会盲目翻页,防 token 爆炸)。
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct LogPage {
/// 会话 id。
pub session_id: String,
/// 文件内事件总数。
pub total_events: u64,
/// 命中过滤条件的事件数。
pub matched_events: u64,
/// 是否因 `max_events` 截断。
pub truncated: bool,
/// 全量 kind 计数(不受过滤影响)。
pub kind_counts: BTreeMap<String, u64>,
/// 首事件时间。
pub first_ts: Option<String>,
/// 末事件时间。
pub last_ts: Option<String>,
/// 本次返回的事件(已按 seq 升序)。
pub events: Vec<LogEvent>,
}
/// 会话端口(M18 实现,`ull-core::launch::session_index` + `session` 的读侧)。
///
/// 读取**必须容忍最后一行不完整**(会话被 kill 时 jsonl 尾部可能截断)——这是实现方义务,
/// 由 `launch-接口约定.md` §1 规定,agent 只消费结果。
pub trait GameSessionPort: Send + Sync {
/// 按 `started_at` 倒序、按 `session_id` 去重后的会话列表;`instance_id` 非空时过滤。
fn list_sessions(
&self,
limit: usize,
instance_id: Option<&str>,
) -> PortResult<Vec<GameSessionSummary>>;
/// 按 [`LogQuery`] 切片读取一个会话的事件流。
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage>;
}
// ───────────────────────── 崩溃诊断(M15) ─────────────────────────
/// 六维修复动作(M15 `diagnose::rules::FixActionKind`,internally-tagged enum,
/// YAML 里写 `kind:`)。序列化名与 M15 逐字相同:`change_java|adjust_memory|
/// change_graphics|remove_mod|reinstall|seek_help`。
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FixActionKind {
/// 换 Java(大版本上下界/指定实现)。
ChangeJava,
/// 调 Xmx/Xms、64 位 Java、系统虚拟内存。
AdjustMemory,
/// 驱动/渲染器/分辨率/覆盖层。
ChangeGraphics,
/// 删/禁/换 mod。
RemoveMod,
/// 重装/修组件(java|game_jar|loader|natives|libraries|assets|mods…)。
Reinstall,
/// 导出诊断包 / 需要更多信息。
SeekHelp,
}
/// 一条修复建议(M15 的 `MaterializedFixAction { kind, tool, args }` + 一句人话)。
///
/// `tool` 是 M15 给的**语义动作 id**(`java.set` / `memory.set` / …)。P0 没有这些细分工具,
/// 所以 agent 侧按 [`FixActionKind`] 桥接到现有工具(见 [`crate::tools::diagnose`]),
/// `tool` 只原样透传给模型与审计,不用于分发。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct FixAction {
/// 六维动作类别。
pub kind: FixActionKind,
/// 语义动作 id(如 `memory.set`);P1 落对应工具后可直接当工具名。
pub tool: String,
/// 一句话建议(可为空,agent 会按 `kind` 补兜底文案)。
pub label: String,
/// 已做模板替换与单位换算的参数(M15 `materialize()` 的产物)。
pub args: std::collections::BTreeMap<String, String>,
}
/// 一条诊断候选(M15 `DiagnosisRecord::candidates[]`)。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct DiagnosisCandidate {
/// 规则 id(如 `jvm.32bit`)。
pub id: String,
/// `critical|error|warn|info`。
pub severity: String,
/// 分组(`jvm|memory|graphics|integrity|mod|…`)。
pub category: String,
/// 置信度 0.0~1.0。
pub confidence: f32,
/// i18n key(如 `diagnose.cause.jvm.32bit`)。
pub message_key: String,
/// **渲染后**的人类可读结论(M15 保证非空;agent 侧只在极旧数据上回落 `message_key`)。
pub message: String,
/// 更细的处理建议(M15 `detail`,可能为空)。
pub detail: String,
/// 证据行(已脱敏)。
pub evidence: Vec<String>,
/// 该候选的修复动作。
pub fix_actions: Vec<FixAction>,
}
/// 退出摘要(M15 `DiagnosisRecord::exit`,与 launch 的 `exit` 事件同源)。
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct ExitSummary {
/// 退出码。
pub code: Option<i32>,
/// `normal|application_error|jvm_error|sigkill|sigterm|killed|interrupted`。
pub exit_type: Option<String>,
/// exit 0 但疑似静默失败。
pub silent_failure: bool,
}
/// 规则引擎结论:**这就是给 LLM 的全部输入**(不是裸日志)。
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct CrashDiagnosis {
/// 会话 id。
pub session_id: String,
/// `rules|ai`(谁产出的)。
pub source: String,
/// 规则拿不准、需要更多信息时置 true(此时才值得让 LLM 深挖)。
pub needs_more_info: bool,
/// 主因候选,`candidates[0]` 即首因(M15 已按 priority→confidence 排序)。
pub candidates: Vec<DiagnosisCandidate>,
/// 被抑制的次要候选。
pub secondary: Vec<DiagnosisCandidate>,
/// 会话级关键片段(`crash_found.excerpt` / `hs_err` 摘要,已脱敏)。
pub evidence: Vec<String>,
/// 退出摘要。
pub exit: ExitSummary,
}
impl CrashDiagnosis {
/// 首选候选(主因)。
#[must_use]
pub fn top(&self) -> Option<&DiagnosisCandidate> {
self.candidates.first()
}
/// 最高置信度(没有候选时为 0)。
#[must_use]
pub fn top_confidence(&self) -> f32 {
self.top().map_or(0.0, |c| c.confidence)
}
}
/// 诊断端口(M15 实现)。agent 只做「调规则引擎 → 把结论 + 片段交给 LLM」。
pub trait DiagnosePort: Send + Sync {
/// 对一个会话跑规则引擎。会话不存在/没有日志时返回人类可读错误。
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis>;
}
+152
View File
@@ -0,0 +1,152 @@
//! 实例 / 版本 / Java / 账户四个读侧端口(M39 的只读工具集用)。
//!
//! 与 [`super`] 的既有三个端口同形:trait 由 agent 声明,实现在装配层用 `unlucky-core` 填充。
//! 拆分出来的原因是 `port.rs` 挨着 400 行硬上限(`guards.yml` 的 file-size 门禁)——
//! 「会话与日志」「崩溃诊断」留在父模块,「游戏与账户」在这里,语义上也是一刀干净的分界。
//!
//! **凭据红线**(D9 / 02 文档 §3.1):账户端口只露 [`AccountSummary`] 的公开字段。
//! `Credentials`(access token / 刷新令牌 / authlib 密码)在 core 的 Secret 体系里,
//! 根本不出现在本文件的类型里,因此不可能被工具结果带出去。
use serde::Serialize;
use super::PortResult;
// ───────────────────────── 实例 / 版本(M14) ─────────────────────────
/// 一个实例(`profiles.json` 一行)的摘要:模型只需要「能定位、能判断载荷在不在」。
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct InstanceSummary {
/// 实例 id。
pub id: String,
/// 显示名。
pub name: String,
/// 版本 id(如 `1.21.1` / `fabric-loader-0.16.9-1.21.1`)。
pub version_id: String,
/// 该版本的载荷是否真的在 `versions/` 下(`false` = 装了档案但载荷缺失)。
pub version_present: bool,
/// 独立游戏目录(未覆盖时为 `None`)。
pub game_dir: Option<String>,
/// 实例级 Java 覆盖路径(未覆盖时为 `None`)。
pub java_path: Option<String>,
/// 上次启动时刻(Unix 毫秒;从未启动为 `None`)。
pub last_played_at: Option<u64>,
}
/// 已安装版本的摘要(`versions/<id>/` 的存在性 + 可读的元信息)。
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct VersionSummary {
/// 版本 id。
pub id: String,
/// 版本 JSON 里声明的 `type`(`release` / `snapshot` / 加载器自报),读不到为 `None`。
pub kind: Option<String>,
/// 被几个实例引用。
pub instance_count: usize,
}
// ──────────────────────────── Java(M33) ────────────────────────────
/// 一个探测到的 Java 运行时。
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct JavaRuntimeSummary {
/// `java` 可执行文件路径。
pub binary: String,
/// 完整版本串(`21.0.2` / `1.8.0_402`)。
pub version: Option<String>,
/// 主版本号(8 / 17 / 21)。
pub major: Option<u32>,
/// 发行方(temurin / oracle / microsoft / zulu …)。
pub vendor: Option<String>,
/// 来源类别(`instance` / `managed` / `env` / `system`)。
pub origin: String,
}
/// Java 现状(`get_java_status`)。
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct JavaStatus {
/// 默认堆内存(MB,`java.default_memory_mb`)。
pub default_memory_mb: Option<u64>,
/// 托管 JDK 的扫描根。
pub managed_roots: Vec<String>,
/// 探测到的候选(已去重,优先级从高到低)。
pub candidates: Vec<JavaRuntimeSummary>,
}
// ──────────────────────────── 账户(M31) ────────────────────────────
/// 账户的**公开字段**摘要。凭据类字段刻意不存在(凭据红线)。
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct AccountSummary {
/// 条目 id。
pub id: String,
/// 轨道:`offline` / `microsoft` / `authlib`。
pub kind: String,
/// Minecraft 档案 UUID(32 位无连字符)。
pub profile_id: String,
/// 玩家名(可能为空串 = 有档案但名字待刷新)。
pub profile_name: String,
/// 登录名(仅 authlib 轨有值)。
pub login_name: Option<String>,
/// 创建时刻(Unix 毫秒)。
pub created_at_ms: u64,
}
/// 游戏资产端口(M14 实例/版本 + M33 Java 探测)。
pub trait GamePort: Send + Sync {
/// 全部实例(按上次启动时间倒序)。
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>>;
/// 已安装版本清单。
fn list_versions(&self) -> PortResult<Vec<VersionSummary>>;
/// Java 现状(候选 + 托管根 + 默认内存)。
fn java_status(&self) -> PortResult<JavaStatus>;
}
/// 账户端口(M31)。实现方只允许读 `AccountStore::accounts()`(`AccountMeta`),
/// **不得**调用 `credentials()`——那是凭据出口,agent 侧没有对应字段可以承接。
pub trait AccountPort: Send + Sync {
/// 账户清单(公开字段)。
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn account_summary_has_no_credential_fields() {
// 把摘要序列化成 JSON:任何 token / password / secret 形态的键都不许出现。
// 这是凭据红线的**可执行断言**——将来有人往结构体里加字段,这条测试先炸。
let summary = AccountSummary {
id: "a1".to_string(),
kind: "offline".to_string(),
profile_id: "0123456789abcdef0123456789abcdef".to_string(),
profile_name: "Steve".to_string(),
login_name: Some("steve@example.com".to_string()),
created_at_ms: 1_793_304_753_123,
};
let json = serde_json::to_string(&summary).expect("可序列化");
for forbidden in ["token", "password", "secret", "credential", "refresh"] {
assert!(
!json.to_lowercase().contains(forbidden),
"账户摘要不得包含 `{forbidden}`:{json}"
);
}
}
#[test]
fn java_status_round_trips() {
let status = JavaStatus {
default_memory_mb: Some(2048),
managed_roots: vec!["/data/java-managed".to_string()],
candidates: vec![JavaRuntimeSummary {
binary: "/jdk/bin/java".to_string(),
version: Some("21.0.2".to_string()),
major: Some(21),
vendor: Some("temurin".to_string()),
origin: "managed".to_string(),
}],
};
assert_eq!(status.candidates.len(), 1);
assert_eq!(status.candidates[0].major, Some(21));
}
}
+217
View File
@@ -0,0 +1,217 @@
//! 脱敏:审计日志的参数、以及日志工具返回文本的**第二道**防线。
//!
//! 第一道在 launch 落盘时(`launch-接口约定.md` §4:`<redacted:token>` / `<home>` /
//! `<redacted:opaque>`),所以这里不重复实现那套;本模块只兜两件事:
//! 1. 审计日志里的参数可能含 API key(`set_config` 能改 AI 端点)→ 按键名掩码;
//! 2. 日志正文万一含用户主目录绝对路径(旧会话、第三方 mod 输出)→ 掩码用户名段。
//!
//! 幂等:对已脱敏文本再跑一遍结果不变(`<home>` / `***` 原样保留)。
use serde_json::{Map, Value};
/// 掩码占位。
pub const MASK: &str = "***";
/// 键名是否敏感(不区分大小写,含子串即算)。
///
/// 覆盖:`api_key` / `apikey` / `accessToken` / `session_token` / `password` /
/// `secret` / `authorization` / `credential`。
#[must_use]
pub fn is_sensitive_key(key: &str) -> bool {
const NEEDLES: [&str; 8] = [
"api_key",
"apikey",
"token",
"password",
"passwd",
"secret",
"authorization",
"credential",
];
let lower = key.to_ascii_lowercase();
NEEDLES.iter().any(|n| lower.contains(n))
}
/// 审计用:递归把敏感键的值换成 [`MASK`](保留结构,读审计的人仍能看出改的是哪个键)。
///
/// 除了「键名本身敏感」之外,还认 `set_config {path, value}` 这种**值随路径敏感**的形状:
/// 当同级的 `path` 是敏感键名(`ai.api_key`)时,`value`/`old`/`new` 一律掩码——
/// 否则审计日志会把 API key 原文写进磁盘。
#[must_use]
pub fn redact_args(value: &Value) -> Value {
match value {
Value::Object(map) => {
let sensitive_path = map
.get("path")
.and_then(Value::as_str)
.is_some_and(is_sensitive_key);
let mut out = Map::with_capacity(map.len());
for (k, v) in map {
let mask = is_sensitive_key(k)
|| (sensitive_path && matches!(k.as_str(), "value" | "old" | "new"));
let masked = if mask {
Value::String(MASK.to_string())
} else {
redact_args(v)
};
out.insert(k.clone(), masked);
}
Value::Object(out)
}
Value::Array(items) => Value::Array(items.iter().map(redact_args).collect()),
other => other.clone(),
}
}
/// 文本脱敏:用户主目录段 + `Bearer` 令牌。已脱敏文本原样返回(幂等)。
#[must_use]
pub fn redact_text(text: &str) -> String {
let masked = mask_user_dirs(text);
mask_bearer(&masked)
}
/// 任意 JSON 的每个字符串都过 [`redact_text`](工具结果出口的第二道防线)。
///
/// 与 [`redact_args`] 的分工:那个按键名掩码(审计参数),这个按**内容**掩码
/// (日志正文、账户展示名之类可能夹带主目录/Bearer 的字符串)。
#[must_use]
pub fn redact_texts(value: &Value) -> Value {
match value {
Value::String(text) => Value::String(redact_text(text)),
Value::Array(items) => Value::Array(items.iter().map(redact_texts).collect()),
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, child)| (key.clone(), redact_texts(child)))
.collect(),
),
other => other.clone(),
}
}
/// `C:\Users\<名>` / `/home/<名>` / `/Users/<名>` → 用户名段替换为 [`MASK`]。
fn mask_user_dirs(text: &str) -> String {
let lower = text.to_ascii_lowercase();
let mut out = String::with_capacity(text.len());
let mut cursor = 0usize;
while cursor < text.len() {
let Some((start, marker_len)) = find_marker(&lower, cursor) else {
out.push_str(&text[cursor..]);
break;
};
out.push_str(&text[cursor..start + marker_len]);
let name_start = start + marker_len;
let name_end = segment_end(text, name_start);
let segment = &text[name_start..name_end];
if segment.is_empty() || segment == MASK || segment.eq_ignore_ascii_case("<home>") {
out.push_str(segment);
} else {
out.push_str(MASK);
}
cursor = name_end;
}
out
}
/// 找最靠前的用户目录标记,返回 `(字节位置, 标记长度)`。标记须与 `lower` 同源。
fn find_marker(lower: &str, from: usize) -> Option<(usize, usize)> {
const MARKERS: [&str; 3] = ["c:\\users\\", "/home/", "/users/"];
MARKERS
.iter()
.filter_map(|m| lower[from..].find(m).map(|p| (from + p, m.len())))
.min_by_key(|(start, _)| *start)
}
/// 从 `from` 起取到下一个路径分隔符/空白/引号为止。
fn segment_end(text: &str, from: usize) -> usize {
const STOPS: [char; 9] = ['\\', '/', '"', '\'', ' ', '\t', '\n', '\r', ','];
text[from..]
.find(STOPS)
.map_or(text.len(), |offset| from + offset)
}
/// `Bearer <token>` → `Bearer ***`。
fn mask_bearer(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut cursor = 0usize;
while let Some(pos) = text[cursor..].find("Bearer ") {
let token_start = cursor + pos + "Bearer ".len();
let token_end = segment_end(text, token_start);
out.push_str(&text[cursor..token_start]);
if token_end > token_start {
out.push_str(MASK);
}
cursor = token_end;
}
out.push_str(&text[cursor..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn masks_sensitive_arg_keys_only() {
let args = json!({
"path": "launcher.theme",
"value": "light",
"nested": { "accessToken": "abc", "keep": 1 },
"list": [{ "password": "p" }]
});
let red = redact_args(&args);
// 非敏感路径:值原样保留(审计要能看出改成了什么)
assert_eq!(red["value"], json!("light"));
assert_eq!(red["path"], json!("launcher.theme"));
// 键名敏感:值掩码
assert_eq!(red["nested"]["accessToken"], json!(MASK));
assert_eq!(red["nested"]["keep"], json!(1));
assert_eq!(red["list"][0]["password"], json!(MASK));
}
#[test]
fn masks_value_when_path_itself_is_sensitive() {
// `set_config {path: "ai.api_key", value: "sk-…"}` 是最容易漏的泄漏点
let args = json!({ "path": "ai.api_key", "value": "sk-super-secret" });
let red = redact_args(&args);
assert_eq!(red["value"], json!(MASK));
let rendered = red.to_string();
assert!(!rendered.contains("sk-super-secret"), "{rendered}");
}
#[test]
fn masks_windows_and_posix_home() {
assert_eq!(
redact_text(r"C:\Users\aska\.minecraft\logs\latest.log"),
r"C:\Users\***\.minecraft\logs\latest.log"
);
assert_eq!(
redact_text("/home/aska/.minecraft/logs"),
"/home/***/.minecraft/logs"
);
}
#[test]
fn keeps_already_redacted_and_is_idempotent() {
let once = redact_text(r"C:\Users\aska\game\log.txt");
assert_eq!(once, r"C:\Users\***\game\log.txt");
assert_eq!(redact_text(&once), once);
assert_eq!(redact_text("<home>/.minecraft"), "<home>/.minecraft");
}
#[test]
fn masks_bearer_token() {
assert_eq!(
redact_text("Authorization: Bearer eyJhbGciOi.payload.sig"),
"Authorization: Bearer ***"
);
}
#[test]
fn detects_sensitive_key_names() {
assert!(is_sensitive_key("ai.api_key"));
assert!(is_sensitive_key("AccessToken"));
assert!(is_sensitive_key("proxy.password"));
assert!(!is_sensitive_key("download.concurrency"));
}
}
+260
View File
@@ -0,0 +1,260 @@
//! 工具注册表:整个 agent 的**唯一真相源**。
//!
//! 一个 `ToolSpec`(名称 + 描述 + serde JSON Schema)投影两份:
//! - 内置客户端的 rig `DynamicTool` 视图;
//! - 对外 MCP server 的 rmcp `ToolRouter` 视图。
//!
//! 两份投影必须一致,用契约测试固定(同 name / 同 description / 同 schema)。
//! 设计依据:`DevDocs/PlanDoc/02-ai-agent-design.md` §1。
//!
//! 工具 v1 清单(M16 起,M39 扩到十个):`get_config` / `set_config` / `get_config_schema` /
//! `list_instances` / `list_versions` / `get_java_status` / `list_accounts` /
//! `list_game_sessions` / `read_game_log` / `diagnose_crash`。
//!
//! 安全策略随工具走:敏感键分级确认、YOLO 模式标记、审计留痕(02 §3.1 / §4b)。
//!
//! ## 落地形态
//!
//! ```text
//! registry(Vec<ToolSpec>)
//! ├─ definitions() → rig `DynamicTool::new` (内置 agent,`brain::rig`)
//! └─ definitions() → rmcp `ToolRoute::new_dyn` (外部 MCP,`mcp::projection`)
//! ```
//!
//! 防漂移的关键一招(research/02 §6.2 建议进 CI):**契约测试**遍历注册表,断言两侧
//! 投影拿到的 `(name, description, schema)` 逐字节相等。因为两侧都只吃
//! [`ToolDefinition`],这条约束在本 crate 里天然成立——测试只需要锁住
//! 「definition 是纯投影」+「schema 根必须是 object」+「名字不重复」。
//!
//! 注册表**不持有任何端口**:工具闭包在构造时就捕获了 `Arc<dyn …Port>`,注册表只是索引。
pub mod schema;
pub mod spec;
pub mod validate;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::Arc;
pub use spec::{
Access, Confirmation, ToolCtx, ToolDefinition, ToolError, ToolFn, ToolOutput, ToolSpec,
};
/// 注册失败原因。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryError {
/// 工具名为空或重复。
DuplicateName(String),
/// schema 不合法(根必须是 `type: object`,否则 MCP 侧直接拒收)。
InvalidSchema {
/// 工具名。
tool: String,
/// 原因。
reason: String,
},
/// 说明为空(模型靠 description 选工具,空说明等于没有这个工具)。
EmptyDescription(String),
}
impl fmt::Display for RegistryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateName(name) => write!(f, "工具名重复或为空:`{name}`"),
Self::InvalidSchema { tool, reason } => {
write!(f, "工具 `{tool}` 的 schema 不合法:{reason}")
}
Self::EmptyDescription(name) => write!(f, "工具 `{name}` 缺少说明"),
}
}
}
impl std::error::Error for RegistryError {}
/// 工具注册表。
#[derive(Debug, Default, Clone)]
pub struct ToolRegistry {
specs: Vec<Arc<ToolSpec>>,
index: BTreeMap<String, usize>,
}
impl ToolRegistry {
/// 空注册表。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 注册一个工具。注册即校验名/schema/说明,**运行时不再做二次判断**。
///
/// # Errors
/// 名字重复或为空、schema 根不是 object、说明为空时返回 [`RegistryError`]。
pub fn register(&mut self, spec: ToolSpec) -> Result<(), RegistryError> {
let name = spec.name.trim();
if name.is_empty() || self.index.contains_key(name) {
return Err(RegistryError::DuplicateName(name.to_string()));
}
if spec.description.trim().is_empty() {
return Err(RegistryError::EmptyDescription(name.to_string()));
}
if spec.input_schema.get("type").and_then(|t| t.as_str()) != Some("object") {
return Err(RegistryError::InvalidSchema {
tool: name.to_string(),
reason: "根类型必须是 object(rig 与 rmcp 的入参都是对象 schema)".to_string(),
});
}
self.index.insert(name.to_string(), self.specs.len());
self.specs.push(Arc::new(spec));
Ok(())
}
/// 按名查找。
#[must_use]
pub fn get(&self, name: &str) -> Option<&Arc<ToolSpec>> {
self.index.get(name).and_then(|i| self.specs.get(*i))
}
/// 全部工具(按注册顺序,模型看到的顺序稳定)。
#[must_use]
pub fn specs(&self) -> &[Arc<ToolSpec>] {
&self.specs
}
/// 工具名列表(错误提示/审计用)。
#[must_use]
pub fn names(&self) -> Vec<&str> {
self.specs.iter().map(|s| s.name).collect()
}
/// 工具数量。
#[must_use]
pub fn len(&self) -> usize {
self.specs.len()
}
/// 是否没有工具。
#[must_use]
pub fn is_empty(&self) -> bool {
self.specs.is_empty()
}
/// **投影 1/2 的公共载荷**:`(name, description, input_schema)`。
#[must_use]
pub fn definitions(&self) -> Vec<ToolDefinition> {
self.specs.iter().map(|s| s.definition()).collect()
}
/// 只读子集(P1 的 `--readonly` 外部 MCP 模式用)。
#[must_use]
pub fn read_only_definitions(&self) -> Vec<ToolDefinition> {
self.specs
.iter()
.filter(|s| s.access == Access::ReadOnly)
.map(|s| s.definition())
.collect()
}
/// **只读模式**的注册表副本(外部 MCP 默认形态)。
///
/// 返回的是「物理上不含写工具」的新注册表:`tools/list` 里看不到 `set_config`,
/// 硬调它也会在编排层得到 `unknown_tool`(并留一条审计),而不是静默执行。
/// 这比「列表过滤 + 调用拦截」两套逻辑更不容易漏——只有一份子集,两侧天然一致。
#[must_use]
pub fn read_only_subset(&self) -> Self {
let mut subset = Self::new();
for spec in self.specs.iter().filter(|s| s.access == Access::ReadOnly) {
let name = spec.name.to_string();
if subset.index.insert(name, subset.specs.len()).is_none() {
subset.specs.push(Arc::clone(spec));
}
}
subset
}
/// 名 → access(装配处检查白名单/只读模式)。
#[must_use]
pub fn access_of(&self, name: &str) -> Option<Access> {
self.get(name).map(|s| s.access)
}
/// 去重后的名字集合(契约测试用)。
#[must_use]
pub fn unique_names(&self) -> BTreeSet<&str> {
self.specs.iter().map(|s| s.name).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::schema::{non_empty_string, object};
use serde_json::{json, Value};
fn echo(name: &'static str) -> ToolSpec {
ToolSpec::read_only(
name,
"回显",
object(&[("text", non_empty_string("文本"))], &["text"]),
Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo"))),
)
}
#[test]
fn register_and_lookup() {
let mut reg = ToolRegistry::new();
reg.register(echo("echo")).unwrap();
assert_eq!(reg.len(), 1);
assert_eq!(reg.names(), vec!["echo"]);
assert!(reg.get("echo").is_some());
assert!(reg.get("nope").is_none());
assert_eq!(reg.access_of("echo"), Some(Access::ReadOnly));
}
#[test]
fn rejects_duplicate_empty_and_non_object_schema() {
let mut reg = ToolRegistry::new();
reg.register(echo("echo")).unwrap();
assert_eq!(
reg.register(echo("echo")),
Err(RegistryError::DuplicateName("echo".into()))
);
let bad = ToolSpec::read_only(
"bad",
"x",
json!({ "type": "array" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))),
);
assert!(matches!(
reg.register(bad),
Err(RegistryError::InvalidSchema { .. })
));
let no_desc = ToolSpec::read_only(
"nodesc",
" ",
json!({ "type": "object" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))),
);
assert!(matches!(
reg.register(no_desc),
Err(RegistryError::EmptyDescription(_))
));
}
#[test]
fn definitions_are_stable_and_filterable() {
let mut reg = ToolRegistry::new();
reg.register(echo("a")).unwrap();
reg.register(ToolSpec::mutating(
"b",
"写",
json!({ "type": "object" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "b"))),
))
.unwrap();
assert_eq!(reg.definitions().len(), 2);
assert_eq!(reg.read_only_definitions().len(), 1);
assert_eq!(reg.definitions()[0].name, "a");
assert_eq!(reg.unique_names().len(), 2);
}
}
+116
View File
@@ -0,0 +1,116 @@
//! JSON Schema 构造helper。
//!
//! 工具参数 schema 一律**手写 JSON**(research/02 §6.2:`rig` 的 `DynamicTool::new` 与
//! rmcp 的 `ToolRoute::new_dyn` 吃的都是裸 `serde_json::Value`),所以不需要 schemars 派生。
//! 手写的好处是同一份 schema 直接喂两方,坏处是啰嗦——这个模块把啰嗦收敛到几个函数。
//!
//! 生成的 schema 一律按 **JSON Schema 2020-12** 的写法(rmcp 支持该版本;
//! `additionalProperties: false` 让模型少给野参数,[`super::validate`] 会执行它)。
use serde_json::{json, Map, Value};
/// 对象 schema。`properties` 按给定顺序写入(JSON 对象保序,UI 展示稳定)。
#[must_use]
pub fn object(properties: &[(&str, Value)], required: &[&str]) -> Value {
let mut props = Map::new();
for (name, schema) in properties {
props.insert((*name).to_string(), schema.clone());
}
json!({
"type": "object",
"properties": Value::Object(props),
"required": required,
"additionalProperties": false,
})
}
/// 任意 JSON 值(不校验类型)。
#[must_use]
pub fn any() -> Value {
json!({})
}
/// 字符串。
#[must_use]
pub fn string(description: &str) -> Value {
json!({ "type": "string", "description": description })
}
/// 非空字符串(`minLength: 1`,省掉一堆「路径不能为空」的运行时判断)。
#[must_use]
pub fn non_empty_string(description: &str) -> Value {
json!({ "type": "string", "description": description, "minLength": 1 })
}
/// 枚举字符串。
#[must_use]
pub fn string_enum(values: &[&str], description: &str) -> Value {
json!({ "type": "string", "description": description, "enum": values })
}
/// 布尔。
#[must_use]
pub fn boolean(description: &str) -> Value {
json!({ "type": "boolean", "description": description })
}
/// 带范围约束的整数。
#[must_use]
pub fn integer(description: &str, minimum: i64, maximum: i64) -> Value {
json!({
"type": "integer",
"description": description,
"minimum": minimum,
"maximum": maximum,
})
}
/// 字符串数组(可选条数上限)。
#[must_use]
pub fn string_array(description: &str, max_items: u64) -> Value {
json!({
"type": "array",
"description": description,
"items": { "type": "string" },
"maxItems": max_items,
})
}
/// 枚举字符串数组(如 `kinds: ["stderr","exit"]`)。
#[must_use]
pub fn enum_string_array(values: &[&str], description: &str, max_items: u64) -> Value {
json!({
"type": "array",
"description": description,
"items": { "type": "string", "enum": values },
"maxItems": max_items,
"uniqueItems": true,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::validate::validate;
#[test]
fn object_records_required_and_forbids_extras() {
let schema = object(
&[("path", non_empty_string("键路径")), ("value", any())],
&["path", "value"],
);
assert_eq!(schema["type"], json!("object"));
assert_eq!(schema["additionalProperties"], json!(false));
assert_eq!(schema["required"], json!(["path", "value"]));
assert!(validate(&schema, &json!({ "path": "a", "value": 1 })).is_ok());
}
#[test]
fn integer_builder_carries_bounds() {
let schema = integer("条数", 1, 500);
assert_eq!(schema["minimum"], json!(1));
assert_eq!(schema["maximum"], json!(500));
assert!(validate(&schema, &json!(0)).is_err());
assert!(validate(&schema, &json!(500)).is_ok());
}
}
+262
View File
@@ -0,0 +1,262 @@
//! 工具的**唯一定义类型**([`ToolSpec`])与它的两份投影载荷([`ToolDefinition`])。
//!
//! 双投影的支点在 research/02 §6.2:`rig::tool::DynamicTool::new(name, description,
//! parameters: serde_json::Value, ..)` 与 `rmcp::ToolRoute::new_dyn(name, description,
//! schema, handler)` 的入参**都是裸 JSON Schema**。所以本 crate 只维护一份
//! [`ToolSpec`],由 [`ToolSpec::definition`] 生成两侧共用的 `(name, description, schema)`。
//!
//! 工具实现是**同步**的:端口调用全是本地文件/内存,没有网络 IO;异步只存在于
//! provider 边界([`crate::brain::provider::ChatProvider`])。P1 若出现网络类工具
//! (mod 搜索/版本安装)再升成 `Fn -> BoxFuture`,不影响调用方语义。
use std::fmt;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::confirm::Sensitivity;
/// 工具的副作用等级(外部 MCP 只读模式、P1 的 `--readonly` 都按它筛)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Access {
/// 只读:可以给外部 agent、可以在无确认的情况下调用。
ReadOnly,
/// 会改用户数据(写配置),受白名单 + 分级确认约束。
Mutating,
}
/// 工具定义的三元组:**双投影就投这个**。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolDefinition {
/// 工具名(模型可见,`snake_case`)。
pub name: String,
/// 一句话说明(模型可见)。
pub description: String,
/// 参数 JSON Schema(2020-12,根类型必须 `object`)。
pub input_schema: Value,
}
/// 工具调用上下文:编排层注入,工具只读。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCtx {
/// agent 对话会话 id(审计用;与游戏 session_id 不是一回事)。
pub agent_session_id: String,
/// 是否已获确认。**只有编排层能置 true**(YOLO 或用户点了允许),
/// 工具据此决定「先报价再执行」还是「直接执行」。
pub confirmed: bool,
}
/// 工具的返回。
#[derive(Debug, Clone, PartialEq)]
pub struct ToolOutput {
/// 给模型/审计的结构化结果(编排层序列化成 JSON 字符串回灌)。
pub content: Value,
/// 一句话摘要(UI 展示 + 审计 summary)。
pub summary: String,
/// 非空表示「这次没执行,先要确认」([`crate::confirm::ConfirmRequest`] 的原料)。
pub confirmation: Option<Confirmation>,
}
/// 待确认的操作详情(dry-run 的产物)。
#[derive(Debug, Clone, PartialEq)]
pub struct Confirmation {
/// 一句话请求(「AI 请求把 X 从 A 改为 B」)。
pub summary: String,
/// 结构化 diff(`{"path":…,"old":…,"new":…}`)。
pub details: Value,
/// 敏感等级(决定 YOLO 能不能跳过)。
pub sensitivity: Sensitivity,
}
impl ToolOutput {
/// 成功返回。
#[must_use]
pub fn ok(content: Value, summary: impl Into<String>) -> Self {
Self {
content,
summary: summary.into(),
confirmation: None,
}
}
/// 需要确认(本次**没有**产生副作用)。
#[must_use]
pub fn needs_confirmation(
summary: impl Into<String>,
details: Value,
sensitivity: Sensitivity,
) -> Self {
let summary = summary.into();
Self {
content: serde_json::json!({
"status": "needs_confirmation",
"summary": summary,
}),
confirmation: Some(Confirmation {
summary: summary.clone(),
details,
sensitivity,
}),
summary,
}
}
}
/// 工具错误。**全部面向用户可读**——它们会作为工具结果回到模型,
/// 由模型决定重试还是换方案(rmcp 的 `Ok(CallToolResult::error(..))` 同义,
/// 见 research/02 §3.2:协议级 `Err` 会被客户端吞掉,不能用来报业务错误)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolError {
/// 参数语义不合法(schema 校验之外的规则,如路径不在白名单)。
NotAllowed(String),
/// 找不到对象(会话/键/文件)。
NotFound(String),
/// 端口(M12/M15/M18)报错,文本原样透传。
Port(String),
/// 工具自身内部错误(不该发生)。
Internal(String),
}
impl ToolError {
/// 一句话文案(写日志/审计用)。
#[must_use]
pub fn message(&self) -> &str {
match self {
Self::NotAllowed(m) | Self::NotFound(m) | Self::Port(m) | Self::Internal(m) => m,
}
}
/// 错误码(审计与 UI 分类用)。
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::NotAllowed(_) => "not_allowed",
Self::NotFound(_) => "not_found",
Self::Port(_) => "port_error",
Self::Internal(_) => "internal",
}
}
}
impl fmt::Display for ToolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for ToolError {}
/// 工具处理函数。
pub type ToolFn = Arc<dyn Fn(&ToolCtx, &Value) -> Result<ToolOutput, ToolError> + Send + Sync>;
/// 一个工具的完整定义 + 实现。
#[derive(Clone)]
pub struct ToolSpec {
/// 工具名(`snake_case`,同时是 MCP 侧的工具名)。
pub name: &'static str,
/// 说明。
pub description: &'static str,
/// 参数 schema(根必须 object)。
pub input_schema: Value,
/// 副作用等级。
pub access: Access,
/// 实现。
pub handler: ToolFn,
}
impl fmt::Debug for ToolSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ToolSpec")
.field("name", &self.name)
.field("access", &self.access)
.finish_non_exhaustive()
}
}
impl ToolSpec {
/// 建一个只读工具。
#[must_use]
pub fn read_only(
name: &'static str,
description: &'static str,
input_schema: Value,
handler: ToolFn,
) -> Self {
Self {
name,
description,
input_schema,
access: Access::ReadOnly,
handler,
}
}
/// 建一个会改数据的工具。
#[must_use]
pub fn mutating(
name: &'static str,
description: &'static str,
input_schema: Value,
handler: ToolFn,
) -> Self {
Self {
name,
description,
input_schema,
access: Access::Mutating,
handler,
}
}
/// 投影成两侧共用的 `(name, description, schema)`。
#[must_use]
pub fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: self.name.to_string(),
description: self.description.to_string(),
input_schema: self.input_schema.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::schema::{non_empty_string, object};
use serde_json::json;
fn echo_handler() -> ToolFn {
Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo")))
}
#[test]
fn definition_is_a_pure_projection() {
let spec = ToolSpec::read_only(
"echo",
"回显",
object(&[("text", non_empty_string("文本"))], &["text"]),
echo_handler(),
);
let def = spec.definition();
assert_eq!(def.name, "echo");
assert_eq!(def.description, "回显");
assert_eq!(def.input_schema, spec.input_schema);
assert_eq!(spec.access, Access::ReadOnly);
}
#[test]
fn confirmation_carries_details() {
let out = ToolOutput::needs_confirmation(
"AI 请求修改 x",
json!({ "path": "x" }),
Sensitivity::High,
);
assert_eq!(out.content["status"], json!("needs_confirmation"));
let confirmation = out.confirmation.expect("必须有待确认详情");
assert_eq!(confirmation.sensitivity, Sensitivity::High);
assert_eq!(confirmation.summary, out.summary);
assert_eq!(confirmation.details, json!({ "path": "x" }));
}
}
+332
View File
@@ -0,0 +1,332 @@
//! 参数校验:JSON Schema 2020-12 的**最小子集**实现。
//!
//! 为什么不引 `jsonschema` crate:agent 的 schema 全是自己手写的([`super::schema`]),
//! 用到的关键字只有 `type/required/properties/additionalProperties/enum/minimum/maximum/
//! minLength/minItems/maxItems/uniqueItems/items`。三百行的校验器换掉一棵依赖树,
//! 对 Win7 单文件形态(D12)值得。
//!
//! 校验失败的文案是给**模型**看的(它会据此改正参数重试),所以要具体:
//! 「参数 `limit` 必须是 1~500 的整数,实际是 2000」,不是 `schema validation failed`。
use serde_json::Value;
/// 校验入口。返回全部错误(一次给全,省模型来回试错)。
///
/// # Errors
/// 参数不符合 schema 时返回人类可读的错误列表。
pub fn validate(schema: &Value, args: &Value) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
validate_at(schema, args, "", &mut errors);
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
/// 只取第一条错误(日志/审计摘要用)。
#[must_use]
pub fn first_error(schema: &Value, args: &Value) -> Option<String> {
validate(schema, args)
.err()
.and_then(|errors| errors.into_iter().next())
}
fn validate_at(schema: &Value, value: &Value, path: &str, errors: &mut Vec<String>) {
let Some(schema) = schema.as_object() else {
return; // `{}` = 任意值
};
if let Some(expected) = schema.get("type").and_then(Value::as_str) {
if !type_matches(expected, value) {
errors.push(format!(
"参数 {} 应为 {},实际是 {}",
label(path),
expected,
type_name(value)
));
return; // 类型都不对,后面的范围检查没有意义
}
}
if let Some(allowed) = schema.get("enum").and_then(Value::as_array) {
if !allowed.iter().any(|v| v == value) {
let list: Vec<String> = allowed.iter().map(render).collect();
errors.push(format!(
"参数 {} 只能取 [{}],实际是 {}",
label(path),
list.join(", "),
render(value)
));
}
}
check_bounds(schema, value, path, errors);
match value {
Value::Object(map) => {
let required = schema.get("required").and_then(Value::as_array);
for name in required.into_iter().flatten().filter_map(Value::as_str) {
if !map.contains_key(name) {
errors.push(format!("缺少必填参数 {}", label(&join(path, name))));
}
}
let properties = schema.get("properties").and_then(Value::as_object);
for (key, entry) in map {
match properties.and_then(|p| p.get(key)) {
Some(sub) => validate_at(sub, entry, &join(path, key), errors),
None => {
if schema.get("additionalProperties").and_then(Value::as_bool)
== Some(false)
{
let known: Vec<&str> = properties
.map(|p| p.keys().map(String::as_str).collect())
.unwrap_or_default();
errors.push(format!(
"参数 {} 不是可接受的参数名(可用:{})",
label(&join(path, key)),
if known.is_empty() {
"无".to_string()
} else {
known.join(", ")
}
));
}
}
}
}
}
Value::Array(items) => {
if let Some(sub) = schema.get("items") {
for (i, item) in items.iter().enumerate() {
validate_at(sub, item, &format!("{path}[{i}]"), errors);
}
}
}
_ => {}
}
}
fn check_bounds(
schema: &serde_json::Map<String, Value>,
value: &Value,
path: &str,
errors: &mut Vec<String>,
) {
let number = value.as_f64();
let minimum = schema.get("minimum").and_then(Value::as_f64);
let maximum = schema.get("maximum").and_then(Value::as_f64);
if let (Some(n), Some(min), Some(max)) = (number, minimum, maximum) {
if n < min || n > max {
errors.push(format!(
"参数 {} 必须在 {}~{} 之间,实际是 {}",
label(path),
render_number(min),
render_number(max),
render_number(n)
));
return;
}
} else if let (Some(n), Some(min)) = (number, minimum) {
if n < min {
errors.push(format!(
"参数 {} 不能小于 {},实际是 {}",
label(path),
render_number(min),
render_number(n)
));
}
} else if let (Some(n), Some(max)) = (number, maximum) {
if n > max {
errors.push(format!(
"参数 {} 不能大于 {},实际是 {}",
label(path),
render_number(max),
render_number(n)
));
}
}
if let Some(text) = value.as_str() {
if let Some(min_len) = schema.get("minLength").and_then(Value::as_u64) {
if (text.chars().count() as u64) < min_len {
errors.push(format!(
"参数 {} 不能为空(最少 {} 个字符)",
label(path),
min_len
));
}
}
}
if let Some(items) = value.as_array() {
if let Some(max_items) = schema.get("maxItems").and_then(Value::as_u64) {
if items.len() as u64 > max_items {
errors.push(format!(
"参数 {} 最多 {} 项,实际 {} 项",
label(path),
max_items,
items.len()
));
}
}
if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64) {
if (items.len() as u64) < min_items {
errors.push(format!(
"参数 {} 至少 {} 项,实际 {} 项",
label(path),
min_items,
items.len()
));
}
}
if schema.get("uniqueItems").and_then(Value::as_bool) == Some(true) {
if let Some(duplicate) = first_duplicate(items) {
errors.push(format!(
"参数 {} 不能有重复项({} 出现了多次)",
label(path),
render(duplicate)
));
}
}
}
}
/// 数组里第一个重复出现的元素(`uniqueItems` 用;`null` 也算一个值)。
fn first_duplicate(items: &[Value]) -> Option<&Value> {
items
.iter()
.enumerate()
.find(|(index, item)| items.iter().take(*index).any(|prev| prev == *item))
.map(|(_, item)| item)
}
fn type_matches(expected: &str, value: &Value) -> bool {
match expected {
"object" => value.is_object(),
"array" => value.is_array(),
"string" => value.is_string(),
"boolean" => value.is_boolean(),
"null" => value.is_null(),
"integer" => value.as_f64().is_some_and(|n| n.fract() == 0.0),
"number" => value.is_number(),
_ => true, // 未知关键字不拦(前向兼容)
}
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(n) if n.as_f64().is_some_and(|f| f.fract() == 0.0) => "integer",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn label(path: &str) -> String {
if path.is_empty() {
"(根)".to_string()
} else {
format!("`{path}`")
}
}
fn join(path: &str, key: &str) -> String {
if path.is_empty() {
key.to_string()
} else {
format!("{path}.{key}")
}
}
fn render(value: &Value) -> String {
match value {
Value::String(s) => format!("\"{s}\""),
other => other.to_string(),
}
}
fn render_number(n: f64) -> String {
if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
n.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::schema::{
enum_string_array, integer, non_empty_string, object, string_array, string_enum,
};
use serde_json::json;
fn errors(schema: &Value, args: &Value) -> Vec<String> {
validate(schema, args).unwrap_err()
}
#[test]
fn accepts_valid_and_reports_missing_required() {
let schema = object(
&[("session_id", non_empty_string("会话 id"))],
&["session_id"],
);
assert!(validate(&schema, &json!({ "session_id": "20261029T124501-9f3c2a" })).is_ok());
let errs = errors(&schema, &json!({}));
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("缺少必填参数 `session_id`"), "{errs:?}");
}
#[test]
fn rejects_empty_string_and_wrong_type() {
let schema = object(
&[("session_id", non_empty_string("会话 id"))],
&["session_id"],
);
assert!(errors(&schema, &json!({ "session_id": "" }))[0].contains("不能为空"));
assert!(errors(&schema, &json!({ "session_id": 1 }))[0].contains("应为 string"));
}
#[test]
fn rejects_out_of_range_and_unknown_keys() {
let schema = object(
&[
("limit", integer("条数", 1, 500)),
("kinds", string_array("类型", 8)),
],
&[],
);
let errs = errors(&schema, &json!({ "limit": 2000 }));
assert!(errs[0].contains("1~500"), "{errs:?}");
let errs = errors(&schema, &json!({ "limit": 1, "evil": true }));
assert!(errs[0].contains("不是可接受的参数名"), "{errs:?}");
}
#[test]
fn checks_enum_and_nested_objects() {
let schema = object(&[("level", string_enum(&["error", "warn"], "级别"))], &[]);
assert!(validate(&schema, &json!({ "level": "error" })).is_ok());
assert!(errors(&schema, &json!({ "level": "fatal" }))[0].contains("只能取"));
}
#[test]
fn rejects_duplicate_items_when_unique() {
let schema = object(
&[("kinds", enum_string_array(&["stderr", "exit"], "类型", 4))],
&[],
);
let errs = errors(&schema, &json!({ "kinds": ["stderr", "exit", "stderr"] }));
assert!(errs[0].contains("不能有重复项"), "{errs:?}");
assert!(validate(&schema, &json!({ "kinds": ["stderr", "exit"] })).is_ok());
}
#[test]
fn integer_type_rejects_fraction() {
assert!(validate(&json!({ "type": "integer" }), &json!(1.5)).is_err());
assert!(validate(&json!({ "type": "integer" }), &json!(2.0)).is_ok());
}
}
+190
View File
@@ -0,0 +1,190 @@
//! 测试替身:五个端口的 **mock 实现**(无盘、无网络、无时间依赖)。
//!
//! 放在库内(而不是只在 `#[cfg(test)]`)是刻意的:`crates/app` 装配时也要能给
//! 自己的单测造一套「无盘」环境,且 P1/M39 的 MCP 契约测试要复用同一批假数据。
//!
//! 按端口拆文件(`port.rs` 挨 400 行上限的同款理由):`settings` 配置、`sessions`
//! 会话日志在下面,`game` 实例/Java/账户与 `diagnose` 诊断各一个子模块。
//! 对外名字不变,`unlucky_agent::testing::MockSettings` 之类照旧可用(走 re-export)。
pub mod diagnose;
pub mod game;
pub mod settings;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Value};
use crate::port::{GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult};
pub use diagnose::MockDiagnose;
pub use game::{MockAccounts, MockGame};
pub use settings::MockSettings;
/// 内存会话端口。
#[derive(Debug, Default)]
pub struct MockSessions {
sessions: Vec<GameSessionSummary>,
events: BTreeMap<String, Vec<LogEvent>>,
}
impl MockSessions {
/// 空数据。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 加一个会话及其事件流。
pub fn add(&mut self, summary: GameSessionSummary, events: Vec<LogEvent>) {
self.events.insert(summary.session_id.clone(), events);
self.sessions.push(summary);
}
/// 便捷:造一个崩溃会话(`exit_code=1` + stderr + crash_found)。
#[must_use]
pub fn crashing_session(session_id: &str) -> (GameSessionSummary, Vec<LogEvent>) {
let summary = GameSessionSummary {
session_id: session_id.to_string(),
instance_id: Some("inst-1".to_string()),
instance_name: Some("1.20.1 Forge".to_string()),
game_version: Some("1.20.1".to_string()),
started_at: Some("2026-10-29T12:45:01.123Z".to_string()),
ended_at: Some("2026-10-29T12:46:31.123Z".to_string()),
duration_ms: 90_000,
exit_code: Some(1),
exit_type: Some("application_error".to_string()),
silent_failure: false,
window_detected: true,
event_count: 5,
crash_found: true,
diagnosis_top: Some("forge_missing_library".to_string()),
};
let events = vec![
event(
0,
"stdout",
json!({ "line": "Starting Minecraft 1.20.1", "level": "info" }),
),
event(
1,
"stderr",
json!({ "line": "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher", "level": "error" }),
),
event(
2,
"crash_found",
json!({ "source": "crash_report", "path": r"C:\Users\aska\.minecraft\crash-reports\crash-1.txt", "excerpt": "Description: Mod loading failure" }),
),
event(
3,
"exit",
json!({ "code": 1, "exit_type": "application_error", "silent_failure": false }),
),
event(
4,
"session_end",
json!({ "reason": "exit", "duration_ms": 90000 }),
),
];
(summary, events)
}
}
fn event(seq: u64, kind: &str, payload: Value) -> LogEvent {
LogEvent {
seq,
ts: format!("2026-10-29T12:45:0{seq}.000Z"),
t_ms: seq * 1_000,
kind: kind.to_string(),
payload,
}
}
impl GameSessionPort for MockSessions {
fn list_sessions(
&self,
limit: usize,
instance_id: Option<&str>,
) -> PortResult<Vec<GameSessionSummary>> {
let mut out: Vec<GameSessionSummary> = self
.sessions
.iter()
.filter(|s| instance_id.is_none_or(|id| s.instance_id.as_deref() == Some(id)))
.cloned()
.collect();
out.sort_by(|a, b| b.started_at.cmp(&a.started_at));
out.truncate(limit);
Ok(out)
}
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage> {
let events = self
.events
.get(session_id)
.ok_or_else(|| format!("会话 `{session_id}` 不存在"))?;
let mut kind_counts = BTreeMap::new();
for event in events {
*kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1;
}
let matched: Vec<&LogEvent> = events
.iter()
.filter(|e| query.from_seq.is_none_or(|from| e.seq >= from))
.filter(|e| query.to_seq.is_none_or(|to| e.seq <= to))
.filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind))
.collect();
let max = if query.max_events == 0 {
matched.len()
} else {
query.max_events
};
let truncated = matched.len() > max;
Ok(LogPage {
session_id: session_id.to_string(),
total_events: events.len() as u64,
matched_events: matched.len() as u64,
truncated,
kind_counts,
first_ts: events.first().map(|e| e.ts.clone()),
last_ts: events.last().map(|e| e.ts.clone()),
events: matched.into_iter().take(max).cloned().collect(),
})
}
}
/// 一次性造好全部端口的 [`crate::port::Ports`](含一个崩溃会话,够跑通对话与日志工具)。
#[must_use]
pub fn mock_ports() -> (crate::port::Ports, std::sync::Arc<MockSettings>) {
use std::sync::Arc;
let settings = Arc::new(MockSettings::new());
let (summary, events) = MockSessions::crashing_session("20261029T124501-9f3c2a");
let mut sessions = MockSessions::new();
sessions.add(summary, events);
let ports = crate::port::Ports {
setting: settings.clone(),
sessions: Arc::new(sessions),
diagnose: Arc::new(MockDiagnose::new()),
game: Arc::new(MockGame::new()),
accounts: Arc::new(MockAccounts::new()),
};
(ports, settings)
}
/// 测试里常用的「全部工具名」集合(契约测试与 MCP 投影比对用)。
#[must_use]
pub fn expected_v1_tool_names() -> BTreeSet<&'static str> {
[
"get_config",
"get_config_schema",
"set_config",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
]
.into_iter()
.collect()
}
+87
View File
@@ -0,0 +1,87 @@
//! 内存诊断端口(`DiagnosePort` 的测试替身)。
use crate::port::{
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
PortResult,
};
/// 内存诊断端口(固定返回一份规则结论)。
#[derive(Debug, Clone)]
pub struct MockDiagnose {
report: Option<CrashDiagnosis>,
}
impl Default for MockDiagnose {
fn default() -> Self {
Self {
report: Some(Self::forge_missing_library()),
}
}
}
impl MockDiagnose {
/// 构造使用默认结论的端口。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 让端口对所有会话返回「会话不存在」。
#[must_use]
pub fn unavailable() -> Self {
Self { report: None }
}
/// 默认结论:Forge 缺库(对应 analyse/02 的高发场景)。
#[must_use]
pub fn forge_missing_library() -> CrashDiagnosis {
CrashDiagnosis {
session_id: String::new(),
source: "rules".to_string(),
needs_more_info: false,
candidates: vec![DiagnosisCandidate {
id: "integrity.forge_missing_library".to_string(),
severity: "error".to_string(),
category: "integrity".to_string(),
confidence: 0.86,
message_key: "diagnose.cause.integrity.forge_missing_library".to_string(),
message: "Forge 启动缺少 bootstraplauncher 库,通常是版本组件未下载完整"
.to_string(),
detail: "在实例上重装 Forge 组件(或先用官方启动器补全一次再回来)".to_string(),
evidence: vec![
"java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher"
.to_string(),
],
fix_actions: vec![FixAction {
kind: FixActionKind::Reinstall,
tool: "component.reinstall".to_string(),
label: "重装该版本的 Forge 组件".to_string(),
args: [("component".to_string(), "forge".to_string())]
.into_iter()
.collect(),
}],
}],
secondary: Vec::new(),
evidence: vec!["Description: Mod loading failure".to_string()],
exit: ExitSummary {
code: Some(1),
exit_type: Some("application_error".to_string()),
silent_failure: false,
},
}
}
}
impl DiagnosePort for MockDiagnose {
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
self.report.clone().map_or_else(
|| Err(format!("会话 `{session_id}` 不存在")),
|report| {
Ok(CrashDiagnosis {
session_id: session_id.to_string(),
..report
})
},
)
}
}
+158
View File
@@ -0,0 +1,158 @@
//! 实例 / 版本 / Java / 账户四个只读端口的测试替身。
use crate::port::game::{
AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus,
VersionSummary,
};
use crate::port::PortResult;
/// 内存游戏端口:一个 1.20.1 Forge 实例 + 两个已安装版本 + 两个 Java 候选。
#[derive(Debug, Clone)]
pub struct MockGame {
instances: Vec<InstanceSummary>,
versions: Vec<VersionSummary>,
java: JavaStatus,
fail: Option<String>,
}
impl Default for MockGame {
fn default() -> Self {
Self::new()
}
}
impl MockGame {
/// 默认假数据(够跑通 `list_instances` / `list_versions` / `get_java_status`)。
#[must_use]
pub fn new() -> Self {
Self {
instances: vec![InstanceSummary {
id: "inst-1".to_string(),
name: "1.20.1 Forge".to_string(),
version_id: "1.20.1-forge-47.2.20".to_string(),
version_present: true,
game_dir: None,
java_path: Some(r"D:\jdk17\bin\java.exe".to_string()),
last_played_at: Some(1_793_304_753_123),
}],
versions: vec![
VersionSummary {
id: "1.20.1-forge-47.2.20".to_string(),
kind: Some("release".to_string()),
instance_count: 1,
},
VersionSummary {
id: "1.21.1".to_string(),
kind: Some("release".to_string()),
instance_count: 0,
},
],
java: JavaStatus {
default_memory_mb: Some(2048),
managed_roots: vec![r"D:\ULL\.ULL\data\java-managed".to_string()],
candidates: vec![
JavaRuntimeSummary {
binary: r"D:\ULL\.ULL\data\java-managed\jdk-17-temurin\bin\java.exe"
.to_string(),
version: Some("17.0.13".to_string()),
major: Some(17),
vendor: Some("temurin".to_string()),
origin: "managed".to_string(),
},
JavaRuntimeSummary {
binary: "/usr/bin/java".to_string(),
version: Some("21.0.2".to_string()),
major: Some(21),
vendor: None,
origin: "PATH".to_string(),
},
],
},
fail: None,
}
}
/// 让三个查询全部返回同一个错误(测端口错误透传)。
#[must_use]
pub fn failing(message: &str) -> Self {
Self {
fail: Some(message.to_string()),
..Self::new()
}
}
fn fail(&self) -> Option<String> {
self.fail.clone()
}
}
impl GamePort for MockGame {
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>> {
self.fail().map_or_else(|| Ok(self.instances.clone()), Err)
}
fn list_versions(&self) -> PortResult<Vec<VersionSummary>> {
self.fail().map_or_else(|| Ok(self.versions.clone()), Err)
}
fn java_status(&self) -> PortResult<JavaStatus> {
self.fail().map_or_else(|| Ok(self.java.clone()), Err)
}
}
/// 内存账户端口:一个离线账户。**只有公开字段**,凭据无处可放。
#[derive(Debug, Clone)]
pub struct MockAccounts {
accounts: Vec<AccountSummary>,
fail: Option<String>,
}
impl Default for MockAccounts {
fn default() -> Self {
Self::new()
}
}
impl MockAccounts {
/// 一个离线账户(Steve)。
#[must_use]
pub fn new() -> Self {
Self {
accounts: vec![AccountSummary {
id: "acc-1".to_string(),
kind: "offline".to_string(),
profile_id: "8667ba71b85a4004af54457a9734eed7".to_string(),
profile_name: "Steve".to_string(),
login_name: None,
created_at_ms: 1_793_304_753_123,
}],
fail: None,
}
}
/// 空账户列表(测「没有账户」的提示文案)。
#[must_use]
pub fn empty() -> Self {
Self {
accounts: Vec::new(),
fail: None,
}
}
/// 让查询失败(测端口错误透传)。
#[must_use]
pub fn failing(message: &str) -> Self {
Self {
fail: Some(message.to_string()),
..Self::new()
}
}
}
impl AccountPort for MockAccounts {
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>> {
self.fail
.clone()
.map_or_else(|| Ok(self.accounts.clone()), Err)
}
}
+201
View File
@@ -0,0 +1,201 @@
//! 内存配置端口(`SettingPort` 的测试替身)。
use std::collections::BTreeMap;
use std::sync::Mutex;
use serde_json::{json, Map, Value};
use crate::confirm::Sensitivity;
use crate::port::{PortResult, SettingChange, SettingKey, SettingPort};
/// 内存配置端口。
#[derive(Debug, Default)]
pub struct MockSettings {
keys: Vec<SettingKey>,
values: Mutex<BTreeMap<String, Value>>,
fail_set: Mutex<Option<String>>,
set_calls: Mutex<usize>,
}
/// 与 `PlanDoc/04` §3 / M12 `setting-接口约定.md` §8 的键表一致的默认白名单
/// (覆盖三档敏感度;`ai.api_key` 这类凭据是 `writable=false`,**故意不在这里**)。
fn default_keys() -> Vec<SettingKey> {
fn key(
path: &str,
sensitivity: Sensitivity,
value_type: &str,
description: &str,
) -> SettingKey {
SettingKey {
path: path.to_string(),
sensitivity,
value_type: value_type.to_string(),
description: description.to_string(),
}
}
vec![
key(
"appearance.theme_mode",
Sensitivity::Low,
"enum",
"界面主题",
),
key(
"download.concurrency",
Sensitivity::Low,
"integer",
"下载并发(1~256)",
),
key(
"java.default_memory_mb",
Sensitivity::Low,
"integer",
"最大内存(512~65536 MB)",
),
key("download.region", Sensitivity::Medium, "enum", "下载源区域"),
key("ai.model", Sensitivity::Medium, "text", "AI 模型名"),
key(
"java.selected_path",
Sensitivity::High,
"path",
"Java 可执行文件路径",
),
key("download.proxy_url", Sensitivity::High, "text", "HTTP 代理"),
key("ai.base_url", Sensitivity::High, "text", "AI 端点"),
key(
"core.data_mode",
Sensitivity::High,
"enum",
"数据目录模式(portable/standard/custom)",
),
]
}
impl MockSettings {
/// 用默认白名单 + 一批初值造一个。
#[must_use]
pub fn new() -> Self {
let mut values = BTreeMap::new();
values.insert("appearance.theme_mode".to_string(), json!("metro_dark"));
values.insert("download.concurrency".to_string(), json!(8));
values.insert("java.default_memory_mb".to_string(), json!(2048));
Self {
keys: default_keys(),
values: Mutex::new(values),
fail_set: Mutex::new(None),
set_calls: Mutex::new(0),
}
}
/// 让接下来的 `set` 全部失败(测错误透传)。
pub fn fail_set_with(&self, message: &str) {
if let Ok(mut slot) = self.fail_set.lock() {
*slot = Some(message.to_string());
}
}
/// 当前值(断言「未确认时没有写进去」)。
#[must_use]
pub fn value_of(&self, path: &str) -> Option<Value> {
self.values.lock().ok().and_then(|v| v.get(path).cloned())
}
/// 直接塞一个值,模拟 M12 真实实现里存在的**任意键**(含凭据键)。
///
/// 白名单以外的键也能塞——测「`get_config` 不把凭据读给模型」需要它
/// (`ai.api_key` 是 `writable=false`,故意不在白名单里)。
pub fn seed_value(&self, path: &str, value: Value) {
if let Ok(mut values) = self.values.lock() {
values.insert(path.to_string(), value);
}
}
/// 成功写入次数。
#[must_use]
pub fn set_calls(&self) -> usize {
self.set_calls.lock().map(|n| *n).unwrap_or(0)
}
}
impl SettingPort for MockSettings {
fn summary(&self) -> Value {
let values = self.values.lock().map(|v| v.clone()).unwrap_or_default();
let mut map = serde_json::Map::new();
for key in &self.keys {
let value = values.get(&key.path).cloned().unwrap_or(Value::Null);
map.insert(key.path.clone(), value);
}
Value::Object(map)
}
fn get(&self, path: &str) -> PortResult<Value> {
self.values
.lock()
.ok()
.and_then(|v| v.get(path).cloned())
.ok_or_else(|| format!("配置键 `{path}` 没有值"))
}
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange> {
if let Ok(slot) = self.fail_set.lock() {
if let Some(message) = slot.as_ref() {
return Err(message.clone());
}
}
let old = self.get(path).ok();
if let Ok(mut values) = self.values.lock() {
values.insert(path.to_string(), value.clone());
}
if let Ok(mut calls) = self.set_calls.lock() {
*calls += 1;
}
Ok(SettingChange {
path: path.to_string(),
old,
new: value.clone(),
})
}
fn writable_keys(&self) -> Vec<SettingKey> {
self.keys.clone()
}
fn schema(&self) -> Value {
// 与 `core::setting::catalog::SettingSchema::to_json_schema` 同形:
// 只含白名单键 + `x-ai-sensitivity`,凭据键天然缺席。
let mut properties = Map::new();
for key in &self.keys {
properties.insert(
key.path.clone(),
json!({
"type": key.value_type,
"description": key.description,
"x-ai-sensitivity": key.sensitivity,
}),
);
}
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "ull.launcher",
"type": "object",
"additionalProperties": false,
"properties": properties,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_excludes_credential_keys() {
let schema = MockSettings::new().schema();
let json = schema.to_string();
assert!(
!json.contains("ai.api_key"),
"schema 不得包含凭据键:{json}"
);
assert!(json.contains("download.concurrency"));
}
}
+110
View File
@@ -0,0 +1,110 @@
//! `list_accounts`:账户清单(**凭据永不出口**)。
//!
//! 这个工具存在的理由很具体:用户常问「我有哪几个号 / 这局用的是哪个号」。
//! 但 D4/D9 的凭据红线是硬的——工具结果会回灌模型、也可能被发往远端 LLM,
//! 所以:
//! 1. 端口只回 [`crate::port::AccountSummary`](`AccountMeta` 的公开字段);
//! 2. 本文件里**没有任何**能表达 token / 密码的类型(连字段名都不出现);
//! 3. 工具结果再过一遍 [`crate::redact`],兜住实现方把凭据塞进 `profile_name` 的意外。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::port::AccountPort;
use crate::registry::schema::object;
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 工具名。
pub const LIST_ACCOUNTS: &str = "list_accounts";
/// 注册 `list_accounts`。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn AccountPort>,
) -> Result<(), RegistryError> {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
let accounts = port.list_accounts().map_err(ToolError::Port)?;
let count = accounts.len();
let display: Vec<String> = accounts
.iter()
.map(|a| {
if a.profile_name.trim().is_empty() {
format!("{}({})", a.id, a.kind)
} else {
format!("{}({})", a.profile_name, a.kind)
}
})
.collect();
let mut content = json!({
"count": count,
"accounts": accounts,
"credential_policy": "账户凭据(access token / 刷新令牌 / authlib 密码)永不经由 AI 工具出口",
});
// 兜底脱敏:端口实现若把敏感串塞进了公开字段,这里也不会带出去。
content = crate::redact::redact_texts(&content);
Ok(ToolOutput::ok(
content,
if count == 0 {
"还没有任何账户".to_string()
} else {
format!("账户 {count} 个:{}", display.join("、"))
},
))
});
registry.register(ToolSpec::read_only(
LIST_ACCOUNTS,
"列出启动器里已登录的账户(展示名 / 轨道 offline|microsoft|authlib / 档案 UUID / 创建时间)。**永不返回任何凭据**:令牌与密码不在工具结果的类型里,需要登录态请让用户在启动器界面查看。",
object(&[], &[]),
handler,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockAccounts;
fn registry(port: Arc<dyn AccountPort>) -> ToolRegistry {
let mut registry = ToolRegistry::new();
register(&mut registry, port).expect("注册应成功");
registry
}
fn call(registry: &ToolRegistry) -> Value {
let spec = registry.get(LIST_ACCOUNTS).expect("工具存在").clone();
let ctx = ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
};
(spec.handler)(&ctx, &json!({})).expect("应成功").content
}
#[test]
fn lists_accounts_without_credentials() {
let content = call(&registry(Arc::new(MockAccounts::new())));
assert_eq!(content["count"], json!(1));
assert_eq!(content["accounts"][0]["profile_name"], json!("Steve"));
// 只扫账户数据本身:`credential_policy` 那句说明文字里本来就写着 "token"。
let fields = content["accounts"].to_string().to_lowercase();
for forbidden in ["token", "password", "refresh", "secret"] {
assert!(
!fields.contains(forbidden),
"账户字段含 `{forbidden}`:{fields}"
);
}
}
#[test]
fn empty_list_is_not_an_error() {
let content = call(&registry(Arc::new(MockAccounts::empty())));
assert_eq!(content["count"], json!(0));
assert_eq!(content["accounts"], json!([]));
}
}
+345
View File
@@ -0,0 +1,345 @@
//! `get_config` / `set_config`(02 文档 §3.1「有权限修改 ULL 各项配置」)。
//!
//! 安全边界都落在这里,但**分级确认的"要不要问"不在这里**:
//! 工具只负责「白名单过滤 → 生成 diff → 需要确认时只报价不执行」,
//! 问不问由编排层按 [`crate::confirm::ConfirmPolicy`] 决定(YOLO 也只改那一处)。
//!
//! 值校验(类型/范围/schema)归 M12 的配置 schema 所有:agent 不复制一份,
//! 否则两处规则迟早漂移(02 文档 §3.1「schema 强校验」)。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::confirm::Sensitivity;
use crate::port::{SettingKey, SettingPort};
use crate::registry::schema::{any, non_empty_string, object, string};
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 读配置。
pub const GET_CONFIG: &str = "get_config";
/// 写配置。
pub const SET_CONFIG: &str = "set_config";
/// 错误提示里最多列多少个可写键(防"把白名单灌给模型")。
pub const MAX_LISTED_KEYS: usize = 16;
/// 摘要里值的最大展示长度。
const BRIEF_CHARS: usize = 60;
/// 注册两个配置工具。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn SettingPort>,
) -> Result<(), RegistryError> {
registry.register(get_config_spec(Arc::clone(&port)))?;
registry.register(set_config_spec(port))
}
fn get_config_spec(port: Arc<dyn SettingPort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let path = args
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|p| !p.is_empty());
match path {
None => {
let summary = port.summary();
let writable = port.writable_keys();
Ok(ToolOutput::ok(
json!({
"scope": "all",
"values": summary,
"writable_keys": writable.iter().map(describe_key).collect::<Vec<_>>(),
}),
format!(
"读取全部配置概要({} 个键,其中 {} 个 AI 可写)",
summary.as_object().map_or(0, serde_json::Map::len),
writable.len()
),
))
}
Some(path) => {
// 凭据类键一律不读:值会进 ToolOutput.content → 回灌模型 → 发往远端 LLM,
// 违反本 crate 的硬边界(AI 永不接触账户凭据,见 `lib.rs`)。
// 这是 agent 侧的第二道防线:第一道是 M12 不把凭据放白名单/概要。
if crate::redact::is_sensitive_key(path) {
return Err(ToolError::NotAllowed(format!(
"`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看。"
)));
}
let value = port.get(path).map_err(ToolError::Port)?;
Ok(ToolOutput::ok(
json!({ "scope": "key", "path": path, "value": value }),
format!("读取配置 `{path}` = {}", brief(&value)),
))
}
}
});
ToolSpec::read_only(
GET_CONFIG,
"读取启动器配置。不带 path 返回全部配置概要(含 AI 可写键及其敏感等级);带 path 返回单个键的值。",
object(
&[(
"path",
string("配置键点分路径,如 `download.concurrency`;省略则返回全部概要"),
)],
&[],
),
handler,
)
}
fn set_config_spec(port: Arc<dyn SettingPort>) -> ToolSpec {
let handler: ToolFn =
Arc::new(move |ctx: &ToolCtx, args: &Value| apply_set_config(port.as_ref(), ctx, args));
ToolSpec::mutating(
SET_CONFIG,
"修改启动器配置(仅限白名单内的键)。普通键立即生效;敏感键(Java 路径/数据目录/代理/AI 端点)会先返回 needs_confirmation,等用户确认后才会真正写入。值必须符合该键的类型与范围。",
object(
&[
("path", non_empty_string("配置键点分路径,必须是 get_config 列出的可写键")),
("value", any()),
],
&["path", "value"],
),
handler,
)
}
/// `set_config` 的实现(独立成函数便于单测直接调用)。
///
/// # Errors
/// 键不在白名单、值缺失、端口写入失败时返回 [`ToolError`]。
pub fn apply_set_config(
port: &dyn SettingPort,
ctx: &ToolCtx,
args: &Value,
) -> Result<ToolOutput, ToolError> {
let path = args
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|p| !p.is_empty())
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `path`".to_string()))?
.to_string();
let value = args
.get("value")
.cloned()
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `value`".to_string()))?;
let keys = port.writable_keys();
let key = keys
.iter()
.find(|k| k.path == path)
.ok_or_else(|| ToolError::NotAllowed(not_writable_message(&keys, &path)))?;
// 旧值读不到不影响写(首次设置),只是 diff 少一半。
let old = port.get(&path).ok();
if !ctx.confirmed && key.sensitivity == Sensitivity::High {
let diff = json!({
"path": path,
"old": old,
"new": value,
"sensitivity": key.sensitivity,
"description": key.description,
});
return Ok(ToolOutput::needs_confirmation(
format!(
"AI 请求把 `{path}` 从 {} 改为 {}({})",
brief_opt(&old),
brief(&value),
sensitivity_label(key.sensitivity)
),
diff,
key.sensitivity,
));
}
let change = port.set(&path, &value).map_err(ToolError::Port)?;
Ok(ToolOutput::ok(
json!({
"status": "written",
"path": change.path,
"old": change.old,
"new": change.new,
"sensitivity": key.sensitivity,
}),
format!(
"已把 `{}` 从 {} 改为 {}",
change.path,
brief_opt(&change.old),
brief(&change.new)
),
))
}
/// 白名单拒绝的文案:说清"不能写"并给出可写键(模型会据此改用正确路径)。
fn not_writable_message(keys: &[SettingKey], path: &str) -> String {
let listed: Vec<&str> = keys
.iter()
.take(MAX_LISTED_KEYS)
.map(|k| k.path.as_str())
.collect();
let rest = keys.len().saturating_sub(listed.len());
let tail = if rest > 0 {
format!(",另有 {rest} 个键见 get_config 概要")
} else {
String::new()
};
format!(
"配置键 `{path}` 不在 AI 可写白名单内。可写键({} 个):{}{tail}",
keys.len(),
listed.join("、")
)
}
fn describe_key(key: &SettingKey) -> Value {
json!({
"path": key.path,
"sensitivity": key.sensitivity,
"value_type": key.value_type,
"description": key.description,
})
}
fn sensitivity_label(sensitivity: Sensitivity) -> &'static str {
match sensitivity {
Sensitivity::Low => "普通",
Sensitivity::Medium => "中等(静默但审计)",
Sensitivity::High => "敏感,需确认",
}
}
fn brief(value: &Value) -> String {
let rendered = match value {
Value::String(s) => s.clone(),
other => other.to_string(),
};
truncate(&rendered)
}
fn brief_opt(value: &Option<Value>) -> String {
value
.as_ref()
.map_or_else(|| "(未设置)".to_string(), brief)
}
fn truncate(text: &str) -> String {
if text.chars().count() <= BRIEF_CHARS {
return text.to_string();
}
let head: String = text.chars().take(BRIEF_CHARS).collect();
format!("{head}…")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockSettings;
fn ctx(confirmed: bool) -> ToolCtx {
ToolCtx {
agent_session_id: "s1".to_string(),
confirmed,
}
}
#[test]
fn rejects_keys_outside_whitelist() {
let port = MockSettings::new();
let err = apply_set_config(
&port,
&ctx(true),
&json!({ "path": "java.random.thing", "value": 1 }),
)
.unwrap_err();
assert!(matches!(err, ToolError::NotAllowed(_)));
assert!(err.message().contains("不在 AI 可写白名单内"));
assert!(err.message().contains("download.concurrency"));
assert_eq!(port.set_calls(), 0);
}
#[test]
fn normal_key_writes_without_confirmation() {
let port = MockSettings::new();
let out = apply_set_config(
&port,
&ctx(false),
&json!({ "path": "download.concurrency", "value": 16 }),
)
.unwrap();
assert!(out.confirmation.is_none());
assert_eq!(out.content["status"], json!("written"));
assert_eq!(port.value_of("download.concurrency"), Some(json!(16)));
}
#[test]
fn medium_key_writes_silently_too() {
// M12:Medium = 静默生效但审计标记,不弹窗
let port = MockSettings::new();
let out = apply_set_config(
&port,
&ctx(false),
&json!({ "path": "download.region", "value": "cn" }),
)
.unwrap();
assert!(out.confirmation.is_none());
assert_eq!(port.set_calls(), 1);
}
#[test]
fn high_key_quotes_first_then_writes_after_confirm() {
let port = MockSettings::new();
let args = json!({ "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" });
let quoted = apply_set_config(&port, &ctx(false), &args).unwrap();
let confirmation = quoted.confirmation.expect("High 键应要求确认");
assert_eq!(confirmation.sensitivity, Sensitivity::High);
assert_eq!(confirmation.details["new"], json!("D:/jdk17/bin/java.exe"));
assert_eq!(port.set_calls(), 0, "未确认前不得写入");
let written = apply_set_config(&port, &ctx(true), &args).unwrap();
assert!(written.confirmation.is_none());
assert_eq!(port.set_calls(), 1);
assert_eq!(
port.value_of("java.selected_path"),
Some(json!("D:/jdk17/bin/java.exe"))
);
}
#[test]
fn locked_credential_key_is_not_writable() {
// M12:ai.api_key 是 writable=false,不在白名单里 → 任何情况下都拒绝
let port = MockSettings::new();
let err = apply_set_config(
&port,
&ctx(true),
&json!({ "path": "ai.api_key", "value": "sk-x" }),
)
.unwrap_err();
assert!(matches!(err, ToolError::NotAllowed(_)));
assert_eq!(port.set_calls(), 0);
}
#[test]
fn port_error_is_passed_through() {
let port = MockSettings::new();
port.fail_set_with("磁盘只读");
let err = apply_set_config(
&port,
&ctx(true),
&json!({ "path": "appearance.theme_mode", "value": "metro_light" }),
)
.unwrap_err();
assert_eq!(err.code(), "port_error");
assert!(err.message().contains("磁盘只读"));
}
}
+335
View File
@@ -0,0 +1,335 @@
//! `diagnose_crash`(02 文档 §3.2 第三条)。
//!
//! 流水线是「**规则在前、模型补位**」(analyse/02 §8.3):本工具只跑 M15 的规则引擎,
//! 把**结构化结论 + 关键片段**交给模型,绝不让模型读裸日志。规则未命中
//! (`candidates` 空 / `needs_more_info`)时,模型再去用 `read_game_log` 自己找证据。
//!
//! P0 与 M15 的桥:M15 的修复动作指向 `java.set` / `memory.set` 这些 **P1 才有的**语义工具 id。
//! P0 只有 `set_config`,所以这里按 [`FixActionKind`] 生成 `p0_next_steps`
//! (`adjust_memory` → `set_config {"path":"java.default_memory_mb"}`,`change_java` →
//! `set_config {"path":"java.selected_path"}`,见 [`MEMORY_KEY`] / [`JAVA_KEY`]),
//! 剩下的动作明确标成「P0 无工具,解释给用户」。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::port::{CrashDiagnosis, DiagnosePort, DiagnosisCandidate, FixAction, FixActionKind};
use crate::redact::redact_text;
use crate::registry::schema::{integer, non_empty_string, object};
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 诊断工具名。
pub const DIAGNOSE_CRASH: &str = "diagnose_crash";
/// 单次返回的证据行上限(防 token 爆炸)。
pub const MAX_EVIDENCE_LINES: usize = 40;
/// 证据行默认条数。
pub const DEFAULT_EVIDENCE_LINES: usize = 20;
/// 单条证据最大字符数。
pub const MAX_EVIDENCE_CHARS: usize = 400;
/// 内存建议的配置键(M12 白名单里的键名,见 `setting-接口约定.md` §8)。
pub const MEMORY_KEY: &str = "java.default_memory_mb";
/// Java 路径的配置键。
pub const JAVA_KEY: &str = "java.selected_path";
/// 注册诊断工具。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn DiagnosePort>,
) -> Result<(), RegistryError> {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let session_id = args
.get("session_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))?;
let max_lines = args
.get("max_evidence_lines")
.and_then(Value::as_u64)
.map_or(DEFAULT_EVIDENCE_LINES, |n| n as usize)
.clamp(1, MAX_EVIDENCE_LINES);
let report = port.diagnose(session_id).map_err(ToolError::Port)?;
Ok(render(&report, max_lines))
});
registry.register(ToolSpec::read_only(
DIAGNOSE_CRASH,
"对某次游戏会话跑规则引擎(59 条崩溃规则)并返回结构化结论:主因、置信度、证据行、修复动作。规则未命中时会提示需要更多信息——此时再用 read_game_log 取 stderr/exit 片段。",
object(
&[
("session_id", non_empty_string("会话 id,来自 list_game_sessions")),
(
"max_evidence_lines",
integer("返回多少条证据行", 1, MAX_EVIDENCE_LINES as i64),
),
],
&["session_id"],
),
handler,
))
}
/// 把规则引擎结论压成工具结果。
fn render(report: &CrashDiagnosis, max_evidence_lines: usize) -> ToolOutput {
let candidates: Vec<Value> = report
.candidates
.iter()
.map(|c| render_candidate(c, max_evidence_lines))
.collect();
let secondary: Vec<Value> = report
.secondary
.iter()
.take(5)
.map(|c| {
json!({
"id": c.id,
"severity": c.severity,
"category": c.category,
"confidence": c.confidence,
"message": message_of(c),
})
})
.collect();
let next_steps: Vec<Value> = report
.candidates
.iter()
.flat_map(|c| c.fix_actions.iter())
.map(bridge_to_p0)
.collect();
let content = json!({
"session_id": report.session_id,
"source": report.source,
"needs_more_info": report.needs_more_info,
"exit": report.exit,
"top_confidence": report.top_confidence(),
"candidates": candidates,
"secondary": secondary,
"evidence": cap_lines(&report.evidence, max_evidence_lines),
"p0_next_steps": next_steps,
});
ToolOutput::ok(content, summarize(report))
}
fn render_candidate(candidate: &DiagnosisCandidate, max_evidence_lines: usize) -> Value {
json!({
"id": candidate.id,
"severity": candidate.severity,
"category": candidate.category,
"confidence": candidate.confidence,
"message": message_of(candidate),
"detail": candidate.detail,
"evidence": cap_lines(&candidate.evidence, max_evidence_lines),
"fix_actions": candidate.fix_actions,
})
}
/// `message` 为空时回落显示 i18n key(装配层没渲染时至少不丢信息)。
fn message_of(candidate: &DiagnosisCandidate) -> String {
let text = candidate.message.trim();
if text.is_empty() {
candidate.message_key.clone()
} else {
text.to_string()
}
}
fn summarize(report: &CrashDiagnosis) -> String {
match report.top() {
Some(top) => {
let label = top
.fix_actions
.first()
.map(action_label)
.unwrap_or_else(|| "没有可执行的修复动作".to_string());
format!(
"规则引擎命中 {} 条候选,主因 `{}`(置信度 {:.2});建议:{label}",
report.candidates.len(),
top.id,
top.confidence
)
}
None if report.needs_more_info => {
"规则引擎未命中,且需要更多信息(用 read_game_log 取 stderr/exit 片段后再判断)"
.to_string()
}
None => "规则引擎未命中(59 条规则都不匹配),需要结合日志片段人工/AI 判断".to_string(),
}
}
/// M15 的语义动作 → P0 现有工具。返回给模型看的"下一步"。
fn bridge_to_p0(action: &FixAction) -> Value {
let label = action_label(action);
match action.kind {
FixActionKind::AdjustMemory => {
let value = pick_memory_mb(action).map_or(Value::Null, |mb| json!(mb));
json!({
"action": action.tool,
"kind": action.kind,
"label": label,
"suggested_tool": "set_config",
"suggested_args": { "path": MEMORY_KEY, "value": value },
})
}
FixActionKind::ChangeJava => {
let value = pick_arg(action, &["path", "java_path", "binary", "value"])
.map_or(Value::Null, |p| json!(p));
json!({
"action": action.tool,
"kind": action.kind,
"label": label,
"suggested_tool": "set_config",
"suggested_args": { "path": JAVA_KEY, "value": value },
})
}
other => json!({
"action": action.tool,
"kind": other,
"label": label,
"suggested_tool": Value::Null,
"unavailable_reason": "P0 没有对应工具(P1 落地),请把这个建议解释给用户",
}),
}
}
/// 动作的人话:优先 M15 给的 `label`,否则按 kind 兜底。
fn action_label(action: &FixAction) -> String {
let label = action.label.trim();
if !label.is_empty() {
return label.to_string();
}
match action.kind {
FixActionKind::ChangeJava => "换一个匹配的 Java 版本".to_string(),
FixActionKind::AdjustMemory => "调整最大内存".to_string(),
FixActionKind::ChangeGraphics => "调整图形设置/驱动".to_string(),
FixActionKind::RemoveMod => "移除或替换冲突的 Mod".to_string(),
FixActionKind::Reinstall => "重装/修复对应组件".to_string(),
FixActionKind::SeekHelp => "导出诊断包并反馈".to_string(),
}
}
/// 从动作参数里挑内存上限:先看常见键,再退化成"第一个带数字的值"。
fn pick_memory_mb(action: &FixAction) -> Option<i64> {
if let Some(value) = pick_arg(
action,
&["value", "suggest", "mb", "max_mb", "xmx", "xmx_mb"],
) {
if let Some(mb) = parse_mb(&value) {
return Some(mb);
}
}
action.args.values().find_map(|v| parse_mb(v))
}
fn pick_arg(action: &FixAction, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| action.args.get(*key).cloned())
.filter(|v| !v.trim().is_empty())
}
/// `"4096"` / `"4G"` / `"6144M"` → MB。
fn parse_mb(raw: &str) -> Option<i64> {
let text = raw.trim().to_ascii_lowercase();
let (number, scale) = if let Some(rest) = text.strip_suffix('g') {
(rest, 1024)
} else if let Some(rest) = text.strip_suffix("gb") {
(rest, 1024)
} else if let Some(rest) = text.strip_suffix('m') {
(rest, 1)
} else if let Some(rest) = text.strip_suffix("mb") {
(rest, 1)
} else {
(text.as_str(), 1)
};
number.trim().parse::<i64>().ok().map(|n| n * scale)
}
fn cap_lines(lines: &[String], max_lines: usize) -> Vec<String> {
lines
.iter()
.take(max_lines)
.map(|line| {
let masked = redact_text(line);
if masked.chars().count() <= MAX_EVIDENCE_CHARS {
masked
} else {
let head: String = masked.chars().take(MAX_EVIDENCE_CHARS).collect();
format!("{head}…")
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockDiagnose;
fn call(port: &MockDiagnose, session_id: &str) -> ToolOutput {
let report = port.diagnose(session_id).expect("mock 应返回结论");
render(&report, DEFAULT_EVIDENCE_LINES)
}
#[test]
fn renders_candidates_with_p0_bridge_for_reinstall() {
let out = call(&MockDiagnose::new(), "s-1");
assert_eq!(out.content["candidates"].as_array().unwrap().len(), 1);
let confidence = out.content["top_confidence"].as_f64().unwrap();
assert!((confidence - 0.86).abs() < 1e-6, "{confidence}");
let steps = out.content["p0_next_steps"].as_array().unwrap();
assert_eq!(steps[0]["kind"], json!("reinstall"));
assert_eq!(steps[0]["suggested_tool"], Value::Null);
assert!(out.summary.contains("建议:重装该版本的 Forge 组件"));
}
#[test]
fn bridges_memory_action_to_set_config() {
let mut report = MockDiagnose::forge_missing_library();
if let Some(candidate) = report.candidates.first_mut() {
candidate.fix_actions = vec![FixAction {
kind: FixActionKind::AdjustMemory,
tool: "memory.set".to_string(),
label: String::new(),
args: [("suggest".to_string(), "4G".to_string())]
.into_iter()
.collect(),
}];
}
let out = render(&report, 5);
let steps = out.content["p0_next_steps"].as_array().unwrap();
assert_eq!(steps[0]["suggested_tool"], json!("set_config"));
assert_eq!(steps[0]["suggested_args"]["path"], json!(MEMORY_KEY));
assert_eq!(steps[0]["suggested_args"]["value"], json!(4096));
assert_eq!(steps[0]["label"], json!("调整最大内存"));
}
#[test]
fn parses_memory_units() {
assert_eq!(parse_mb("4096"), Some(4096));
assert_eq!(parse_mb("4G"), Some(4096));
assert_eq!(parse_mb("6144mb"), Some(6144));
assert_eq!(parse_mb("abc"), None);
}
#[test]
fn empty_candidates_says_need_more_info() {
let report = CrashDiagnosis {
session_id: "s-2".into(),
source: "rules".into(),
needs_more_info: true,
candidates: vec![],
secondary: vec![],
evidence: vec![],
exit: crate::port::ExitSummary::default(),
};
let out = render(&report, 5);
assert!(out.summary.contains("需要更多信息"));
}
}
+199
View File
@@ -0,0 +1,199 @@
//! 实例 / 版本 / Java 三个只读工具(M39 任务 3 的读侧)。
//!
//! 全部只读、全部走 [`crate::port::GamePort`]:agent 不自己去翻 `versions/` 目录,
//! 也不自己扫 JDK——那两件事的真相源在 M14(game)与 M33(java),重复一份必然漂。
//!
//! 输出形状刻意做成「摘要 + 上限」:实例可能几十个、Java 候选可能十几个,
//! 一次全塞给模型等于烧 token(02 §3.2 的「单次返回上限防 token 爆炸」同理)。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::port::GamePort;
use crate::registry::schema::{integer, object};
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 实例列表工具名。
pub const LIST_INSTANCES: &str = "list_instances";
/// 版本列表工具名。
pub const LIST_VERSIONS: &str = "list_versions";
/// Java 现状工具名。
pub const GET_JAVA_STATUS: &str = "get_java_status";
/// 单次最多返回多少个实例/版本。
pub const MAX_ROWS: usize = 50;
/// 注册三个只读工具。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(registry: &mut ToolRegistry, port: Arc<dyn GamePort>) -> Result<(), RegistryError> {
registry.register(list_instances_spec(Arc::clone(&port)))?;
registry.register(list_versions_spec(Arc::clone(&port)))?;
registry.register(java_status_spec(port))
}
fn limit_arg(args: &Value) -> usize {
args.get("limit")
.and_then(Value::as_u64)
.map_or(MAX_ROWS, |n| usize::try_from(n).unwrap_or(MAX_ROWS))
.clamp(1, MAX_ROWS)
}
fn limit_schema() -> Value {
integer(
&format!("最多返回多少条(1~{MAX_ROWS},默认 {MAX_ROWS})"),
1,
MAX_ROWS as i64,
)
}
fn list_instances_spec(port: Arc<dyn GamePort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let limit = limit_arg(args);
let mut instances = port.list_instances().map_err(ToolError::Port)?;
let total = instances.len();
instances.truncate(limit);
Ok(ToolOutput::ok(
json!({
"total": total,
"shown": instances.len(),
"truncated": total > instances.len(),
"instances": instances,
}),
format!("列出 {}/{} 个实例", instances.len(), total),
))
});
ToolSpec::read_only(
LIST_INSTANCES,
"列出启动器里的游戏实例(id / 名称 / 版本 id / 载荷是否已安装 / 独立游戏目录 / 实例级 Java)。要看某个实例的日志先用 list_game_sessions。",
object(&[("limit", limit_schema())], &[]),
handler,
)
}
fn list_versions_spec(port: Arc<dyn GamePort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let limit = limit_arg(args);
let mut versions = port.list_versions().map_err(ToolError::Port)?;
let total = versions.len();
versions.truncate(limit);
Ok(ToolOutput::ok(
json!({
"total": total,
"shown": versions.len(),
"truncated": total > versions.len(),
"versions": versions,
}),
format!("列出 {}/{} 个已安装版本", versions.len(), total),
))
});
ToolSpec::read_only(
LIST_VERSIONS,
"列出已安装的游戏版本(`versions/<id>/` 下真实存在的载荷),带类型与被几个实例引用。判断「实例的载荷缺没缺」时配合 list_instances 的 version_present 一起看。",
object(&[("limit", limit_schema())], &[]),
handler,
)
}
fn java_status_spec(port: Arc<dyn GamePort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
let status = port.java_status().map_err(ToolError::Port)?;
let majors: Vec<String> = status
.candidates
.iter()
.filter_map(|c| c.major.map(|m| m.to_string()))
.collect();
Ok(ToolOutput::ok(
json!({
"default_memory_mb": status.default_memory_mb,
"managed_roots": status.managed_roots,
"candidate_count": status.candidates.len(),
"majors": majors,
"candidates": status.candidates,
}),
format!(
"Java 候选 {} 个(主版本 {})",
status.candidates.len(),
if majors.is_empty() {
"无".to_string()
} else {
majors.join("/")
}
),
))
});
ToolSpec::read_only(
GET_JAVA_STATUS,
"读取 Java 现状:托管扫描根、默认堆内存、探测到的全部运行时(路径 / 版本 / 主版本 / 发行方 / 来源)。启动报「不支持的 class 版本」「找不到 Java」时先看这里。",
object(&[], &[]),
handler,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockGame;
fn registry() -> ToolRegistry {
let mut registry = ToolRegistry::new();
register(&mut registry, Arc::new(MockGame::new())).expect("注册应成功");
registry
}
fn call(registry: &ToolRegistry, name: &str, args: Value) -> Value {
let spec = registry.get(name).expect("工具存在").clone();
let ctx = ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
};
(spec.handler)(&ctx, &args).expect("应成功").content
}
#[test]
fn lists_instances_with_limit() {
let content = call(&registry(), LIST_INSTANCES, json!({ "limit": 1 }));
assert_eq!(content["total"], json!(1));
assert_eq!(content["instances"][0]["id"], json!("inst-1"));
assert_eq!(content["truncated"], json!(false));
}
#[test]
fn lists_versions() {
let content = call(&registry(), LIST_VERSIONS, json!({}));
assert_eq!(content["total"], json!(2));
assert_eq!(content["versions"][0]["id"], json!("1.20.1-forge-47.2.20"));
}
#[test]
fn java_status_summarises_majors() {
let content = call(&registry(), GET_JAVA_STATUS, json!({}));
assert_eq!(content["candidate_count"], json!(2));
assert_eq!(content["majors"], json!(["17", "21"]));
assert_eq!(content["default_memory_mb"], json!(2048));
}
#[test]
fn port_errors_are_passed_through() {
let mut registry = ToolRegistry::new();
register(
&mut registry,
Arc::new(MockGame::failing("versions 目录不可读")),
)
.expect("注册应成功");
let spec = registry.get(LIST_VERSIONS).expect("工具存在").clone();
let ctx = ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
};
let err = (spec.handler)(&ctx, &json!({})).unwrap_err();
assert_eq!(err.code(), "port_error");
assert!(err.message().contains("versions 目录不可读"));
}
}
+39
View File
@@ -0,0 +1,39 @@
//! 工具集 v1(02 文档 §3):配置读写 / 会话与日志 / 崩溃诊断 / 实例与 Java / 账户。
//!
//! 分工边界(重要,评审时按这条看):
//! - **工具**:参数语义 → 端口调用 → 结构化结果;需要确认时**只报价不执行**。
//! - **编排层**([`crate::brain::exec`]):schema 校验、确认闸门、YOLO 判定、审计、事件。
//! - **端口**([`crate::port`]):真正的 IO 与领域校验(M12/M14/M15/M18/M31/M33 拥有)。
//!
//! 这样「YOLO」只在一个地方(编排层),工具的测试不需要关心 policy。
//!
//! 双投影:这里的注册表是**唯一真相源**,内置客户端投成 rig `DynamicTool`,
//! 对外 MCP server 投成 rmcp `ToolRoute`([`crate::mcp`])。工具数量与顺序对外一致。
pub mod accounts;
pub mod config;
pub mod diagnose;
pub mod game;
pub mod schema_tool;
pub mod session;
use crate::port::Ports;
use crate::registry::{RegistryError, ToolRegistry};
/// 组装 v1 的全部工具。注册顺序 = 模型看到的顺序(只读在前,写在后)。
///
/// 顺序不是随便排的:模型在长工具表里更容易选中靠前的项,所以「先看再改」
/// (`get_config*` 在 `set_config` 之前)是刻意的提示。
///
/// # Errors
/// 工具名重复 / schema 非法时返回 [`RegistryError`](属于编码错误,装配期就该炸)。
pub fn build_v1_registry(ports: &Ports) -> Result<ToolRegistry, RegistryError> {
let mut registry = ToolRegistry::new();
config::register(&mut registry, ports.setting.clone())?;
schema_tool::register(&mut registry, ports.setting.clone())?;
game::register(&mut registry, ports.game.clone())?;
accounts::register(&mut registry, ports.accounts.clone())?;
session::register(&mut registry, ports.sessions.clone())?;
diagnose::register(&mut registry, ports.diagnose.clone())?;
Ok(registry)
}
+84
View File
@@ -0,0 +1,84 @@
//! `get_config_schema`:把 M12 的配置 schema(**只含 AI 可写键**)投给模型。
//!
//! 为什么单独一个工具而不是塞进 `get_config`:模型要能「先看有哪些键、类型是什么、
//! 哪些是敏感键」,再决定要不要写——这是一次性的静态信息,和读当前值不是同一件事。
//! 拆开还能让 token 预算更可控(schema 比概要大一个量级)。
//!
//! 凭据边界:schema 由 M12 的 `SettingSchema::to_json_schema()` 生成,只遍历
//! `ai.writable && !deprecated` 的键,`ai.api_key`(`locked`)天然不在其中。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::port::SettingPort;
use crate::registry::schema::object;
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 工具名。
pub const GET_CONFIG_SCHEMA: &str = "get_config_schema";
/// 注册 `get_config_schema`。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn SettingPort>,
) -> Result<(), RegistryError> {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
let schema = port.schema();
if schema.get("properties").is_none() {
return Err(ToolError::Port(
"配置 schema 不可用:装配层没有提供 SettingPort::schema()".to_string(),
));
}
let writable = schema
.get("properties")
.and_then(Value::as_object)
.map_or(0, serde_json::Map::len);
Ok(ToolOutput::ok(
json!({
"scope": "ai_writable_schema",
"writable_key_count": writable,
"schema": schema,
}),
format!("读取配置 schema({writable} 个 AI 可写键)"),
))
});
registry.register(ToolSpec::read_only(
GET_CONFIG_SCHEMA,
"读取启动器配置的 JSON Schema(只含 AI 可写键)。每个键带类型、取值范围、说明与 `x-ai-sensitivity` 敏感等级;凭据类键不会出现。写配置前先看它。",
object(&[], &[]),
handler,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockSettings;
fn ctx() -> ToolCtx {
ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
}
}
#[test]
fn returns_schema_without_credentials() {
let port: Arc<dyn SettingPort> = Arc::new(MockSettings::new());
let mut registry = ToolRegistry::new();
register(&mut registry, port).expect("注册应成功");
let spec = registry.get(GET_CONFIG_SCHEMA).expect("工具存在").clone();
let out = (spec.handler)(&ctx(), &json!({})).expect("应返回 schema");
let text = out.content.to_string();
assert!(out.content["writable_key_count"].as_u64().unwrap_or(0) > 0);
assert!(text.contains("x-ai-sensitivity"));
assert!(!text.contains("ai.api_key"), "凭据键不得进 schema:{text}");
}
}
+388
View File
@@ -0,0 +1,388 @@
//! `list_game_sessions` / `read_game_log`(02 文档 §3.2「能读取某次游戏崩溃日志」)。
//!
//! 数据源是 M18 的会话事件流(`DevDocs/SubAgentsTalk/launch-接口约定.md`):
//! - 列表读 `index.jsonl`(每会话一行摘要,同 id 取最后一行);
//! - 日志读 `<session-id>.jsonl`,**按 `seq` 切片 + 按 `kind` 过滤**,不是按行号。
//!
//! 两条防 token 爆炸的硬约束:
//! 1. 单次最多 [`MAX_LOG_EVENTS`] 条事件(默认 [`DEFAULT_LOG_EVENTS`]);
//! 2. 单条 payload 里的字符串超过 [`MAX_PAYLOAD_CHARS`] 截断。
//!
//! 脱敏:launch 落盘前已经做过一遍(`<home>` / `<redacted:token>`),本工具再跑一遍
//! [`crate::redact::redact_text`] 作为第二道防线(幂等,旧会话/第三方 mod 输出可能漏网)。
use std::sync::Arc;
use serde_json::{json, Map, Value};
use crate::port::{GameSessionPort, LogEvent, LogPage, LogQuery};
use crate::redact::redact_text;
use crate::registry::schema::{enum_string_array, integer, non_empty_string, object, string};
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 列出游戏会话。
pub const LIST_GAME_SESSIONS: &str = "list_game_sessions";
/// 读一个会话的日志。
pub const READ_GAME_LOG: &str = "read_game_log";
/// 单次返回的事件上限(防 token 爆炸的硬闸)。
pub const MAX_LOG_EVENTS: usize = 500;
/// 默认返回条数。
pub const DEFAULT_LOG_EVENTS: usize = 100;
/// 单条 payload 字符串的展示上限(字符)。
pub const MAX_PAYLOAD_CHARS: usize = 800;
/// 列表默认/最大条数。
pub const DEFAULT_SESSION_LIMIT: usize = 10;
/// 列表最大条数。
pub const MAX_SESSION_LIMIT: usize = 50;
/// `launch-接口约定.md` §3 的事件类型(schema 里给模型做枚举;未知 kind 仍可透传)。
pub const KNOWN_KINDS: [&str; 13] = [
"session_start",
"java_selected",
"cmd_built",
"natives_extracted",
"proc_start",
"stdout",
"stderr",
"window_detected",
"exit",
"crash_found",
"diagnosis",
"killed",
"session_end",
];
/// 注册两个日志工具。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn GameSessionPort>,
) -> Result<(), RegistryError> {
registry.register(list_sessions_spec(Arc::clone(&port)))?;
registry.register(read_log_spec(port))
}
fn list_sessions_spec(port: Arc<dyn GameSessionPort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let limit = args
.get("limit")
.and_then(Value::as_u64)
.map_or(DEFAULT_SESSION_LIMIT, |n| n as usize)
.clamp(1, MAX_SESSION_LIMIT);
let instance_id = args.get("instance_id").and_then(Value::as_str);
let sessions = port
.list_sessions(limit, instance_id)
.map_err(ToolError::Port)?;
if sessions.is_empty() {
return Ok(ToolOutput::ok(
json!({ "sessions": [], "count": 0 }),
"没有游戏会话记录(还没启动过游戏,或数据目录不对)".to_string(),
));
}
let crashed = sessions
.iter()
.filter(|s| s.crash_found || s.silent_failure || s.exit_code.is_some_and(|c| c != 0))
.count();
let summary = format!(
"最近 {} 个游戏会话,其中 {crashed} 个崩溃/异常退出;最近一次 {}({})",
sessions.len(),
sessions[0].session_id,
sessions[0].started_at.as_deref().unwrap_or("启动时间未知")
);
Ok(ToolOutput::ok(
json!({ "sessions": sessions, "count": sessions.len(), "problem_count": crashed }),
summary,
))
});
ToolSpec::read_only(
LIST_GAME_SESSIONS,
"列出最近的游戏会话(启动时间、退出码、是否崩溃、规则引擎首选结论)。用于定位要读哪一次会话的日志。",
object(
&[
("limit", integer("返回条数", 1, MAX_SESSION_LIMIT as i64)),
("instance_id", string("只看某个实例的会话;省略则全部实例")),
],
&[],
),
handler,
)
}
fn read_log_spec(port: Arc<dyn GameSessionPort>) -> ToolSpec {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let session_id = args
.get("session_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))?
.to_string();
let kinds: Vec<String> = args
.get("kinds")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let max_events = args
.get("max_events")
.and_then(Value::as_u64)
.map_or(DEFAULT_LOG_EVENTS, |n| n as usize)
.clamp(1, MAX_LOG_EVENTS);
let query = LogQuery {
from_seq: args.get("from_seq").and_then(Value::as_u64),
to_seq: args.get("to_seq").and_then(Value::as_u64),
kinds,
max_events,
};
let page = port
.read_log(&session_id, &query)
.map_err(ToolError::Port)?;
Ok(render_page(&page))
});
ToolSpec::read_only(
READ_GAME_LOG,
"读取某次游戏会话的事件流(stdout/stderr 按行、退出事件、crash-report 摘录)。可按 seq 区间和事件类型过滤;单次最多 500 条、单条最长 800 字符,优先用 kinds 过滤 stderr/exit/crash_found 而不是拉全量。",
object(
&[
("session_id", non_empty_string("会话 id,来自 list_game_sessions")),
("from_seq", integer("起始 seq(含)", 0, 9_007_199_254_740_991)),
("to_seq", integer("结束 seq(含)", 0, 9_007_199_254_740_991)),
(
"kinds",
enum_string_array(&KNOWN_KINDS, "只要这些事件类型;省略则全部", 13),
),
("max_events", integer("最多返回多少条", 1, MAX_LOG_EVENTS as i64)),
],
&["session_id"],
),
handler,
)
}
/// 把端口返回的一页日志压成工具结果(含摘要,模型据此决定要不要继续翻页)。
fn render_page(page: &LogPage) -> ToolOutput {
let events: Vec<Value> = page.events.iter().map(render_event).collect();
let kind_summary = page
.kind_counts
.iter()
.map(|(kind, count)| format!("{kind}×{count}"))
.collect::<Vec<_>>()
.join(", ");
let (content, summary) = if events.is_empty() {
(
json!({
"session_id": page.session_id,
"total_events": page.total_events,
"matched_events": 0,
"truncated": false,
"kind_counts": page.kind_counts,
"first_ts": page.first_ts,
"last_ts": page.last_ts,
"events": [],
"hint": "没有符合过滤条件的事件;把 kinds/from_seq/to_seq 放宽再试",
}),
format!(
"会话 {} 没有匹配的事件(共 {} 条)",
page.session_id, page.total_events
),
)
} else {
let truncated_note = if page.truncated {
"(已截断)"
} else {
""
};
(
json!({
"session_id": page.session_id,
"total_events": page.total_events,
"matched_events": page.matched_events,
"truncated": page.truncated,
"kind_counts": page.kind_counts,
"first_ts": page.first_ts,
"last_ts": page.last_ts,
"events": events,
}),
format!(
"会话 {}:共 {} 条事件,返回 {} 条{truncated_note};类型分布 {kind_summary}",
page.session_id,
page.total_events,
events.len(),
),
)
};
ToolOutput::ok(content, summary)
}
fn render_event(event: &LogEvent) -> Value {
json!({
"seq": event.seq,
"ts": event.ts,
"t_ms": event.t_ms,
"kind": event.kind,
"payload": shrink(&event.payload),
})
}
/// 递归脱敏 + 截断(第二道脱敏防线)。
fn shrink(value: &Value) -> Value {
match value {
Value::String(text) => Value::String(cap(&redact_text(text))),
Value::Array(items) => Value::Array(items.iter().map(shrink).collect()),
Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
for (key, item) in map {
out.insert(key.clone(), shrink(item));
}
Value::Object(out)
}
other => other.clone(),
}
}
fn cap(text: &str) -> String {
let length = text.chars().count();
if length <= MAX_PAYLOAD_CHARS {
return text.to_string();
}
let head: String = text.chars().take(MAX_PAYLOAD_CHARS).collect();
format!("{head}…(已截断 {} 字符)", length - MAX_PAYLOAD_CHARS)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockSessions;
use serde_json::json;
const SESSION: &str = "20261029T124501-9f3c2a";
fn mock() -> MockSessions {
let (summary, events) = MockSessions::crashing_session(SESSION);
let (older, older_events) = MockSessions::crashing_session("20261028T100000-aaaaaa");
let mut sessions = MockSessions::new();
sessions.add(summary, events);
sessions.add(older, older_events);
sessions
}
fn registry() -> ToolRegistry {
let mut registry = ToolRegistry::new();
register(&mut registry, Arc::new(mock())).expect("注册应成功");
registry
}
/// 走**真实注册表 → 真实 handler**,不复制实现(否则测的是复制品)。
fn call(tool: &str, args: Value) -> ToolOutput {
let registry = registry();
let spec = registry.get(tool).expect("工具应存在").clone();
(spec.handler)(
&ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
},
&args,
)
.expect("工具应成功")
}
#[test]
fn lists_sessions_newest_first() {
let out = call(LIST_GAME_SESSIONS, json!({ "limit": 5 }));
assert_eq!(out.content["count"], json!(2));
assert_eq!(out.content["sessions"][0]["session_id"], json!(SESSION));
assert_eq!(out.content["problem_count"], json!(2));
assert!(out.summary.contains("2 个崩溃/异常退出"));
}
#[test]
fn list_respects_limit() {
let out = call(LIST_GAME_SESSIONS, json!({ "limit": 1 }));
assert_eq!(out.content["count"], json!(1));
}
#[test]
fn filters_by_kind_and_keeps_summary() {
let out = call(
READ_GAME_LOG,
json!({ "session_id": SESSION, "kinds": ["stderr"] }),
);
assert_eq!(out.content["events"].as_array().unwrap().len(), 1);
assert_eq!(out.content["kind_counts"]["stdout"], json!(1));
assert!(out.summary.contains("共 5 条事件"));
}
#[test]
fn truncates_when_more_events_than_cap() {
let out = call(
READ_GAME_LOG,
json!({ "session_id": SESSION, "max_events": 2 }),
);
assert_eq!(out.content["events"].as_array().unwrap().len(), 2);
assert_eq!(out.content["truncated"], json!(true));
assert_eq!(out.content["matched_events"], json!(5));
}
#[test]
fn masks_user_dir_in_payload() {
let out = call(
READ_GAME_LOG,
json!({ "session_id": SESSION, "kinds": ["crash_found"] }),
);
let payload = &out.content["events"][0]["payload"];
assert_eq!(
payload["path"],
json!(r"C:\Users\***\.minecraft\crash-reports\crash-1.txt")
);
}
#[test]
fn reports_empty_result_with_hint() {
let out = call(
READ_GAME_LOG,
json!({ "session_id": SESSION, "kinds": ["window_detected"] }),
);
assert_eq!(out.content["events"], json!([]));
assert!(out.content["hint"].is_string());
}
#[test]
fn caps_long_payload_text() {
let long = "x".repeat(MAX_PAYLOAD_CHARS + 10);
assert!(cap(&long).contains("已截断 10 字符"));
}
#[test]
fn unknown_session_is_a_port_error() {
let registry = registry();
let spec = registry.get(READ_GAME_LOG).expect("工具应存在").clone();
let err = (spec.handler)(
&ToolCtx {
agent_session_id: "s1".to_string(),
confirmed: false,
},
&json!({ "session_id": "nope" }),
)
.unwrap_err();
assert_eq!(err.code(), "port_error");
assert!(err.message().contains("不存在"));
}
}
+248
View File
@@ -0,0 +1,248 @@
//! 对话循环集成测试:mock provider 驱动**完整链路**(provider → 工具编排 → 审计 → 事件)。
//!
//! 这里测"循环的骨架":多轮工具调用、步数上限、流式增量、禁用态、provider 失败。
//! 安全边界(白名单/确认/YOLO/审计标记)在 `tools_security.rs`。
mod support;
use std::sync::Arc;
use serde_json::json;
use support::{
block_on, list_args, tool_call_reply, Harness, MockProvider, AGENT_SESSION, SESSION,
};
use unlucky_agent::audit::AuditOutcome;
use unlucky_agent::brain::{BrainError, ChatMessage, ChatReply, StopReason, ToolCall};
use unlucky_agent::event::AgentEvent;
#[test]
fn single_turn_without_tools_returns_text() {
let harness = Harness::new(vec![ChatReply::text("你好,需要我看看最近的崩溃吗?")]);
let mut history = Vec::new();
let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap();
assert_eq!(outcome.text, "你好,需要我看看最近的崩溃吗?");
assert_eq!(outcome.steps, 1);
assert_eq!(outcome.tool_calls, 0);
assert_eq!(outcome.stopped_reason, StopReason::Completed);
assert_eq!(history.len(), 2, "应只有 user + assistant");
assert!(harness.audit.is_empty(), "没调工具就不该有审计记录");
}
#[test]
fn system_prompt_leads_every_request_and_tools_are_projected() {
let harness = Harness::new(vec![ChatReply::text("好的")]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "看看配置"),
)
.unwrap();
let requests = harness.provider.requests();
assert_eq!(requests.len(), 1);
let request = &requests[0];
assert!(
matches!(&request.messages[0], ChatMessage::System { content } if content.contains("Unlucky-Launcher")),
"system prompt 必须在首位"
);
assert_eq!(request.model, "mock-model");
let names: Vec<&str> = request.tools.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
vec![
"get_config",
"set_config",
"get_config_schema",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
],
"注册表的双投影载荷必须原样交给 provider"
);
// 双投影契约:provider 收到的 schema 与注册表定义逐字节相等。
assert_eq!(request.tools, harness.registry.definitions());
}
#[test]
fn tool_call_round_trip_feeds_result_back_and_finishes() {
let harness = Harness::new(vec![
tool_call_reply("call_1", "list_game_sessions", list_args()),
ChatReply::text("最近一次会话崩溃了,要不要跑诊断?"),
]);
let mut history = Vec::new();
let outcome = block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "最近崩了吗"),
)
.unwrap();
assert_eq!(outcome.tool_calls, 1);
assert_eq!(outcome.steps, 2);
assert_eq!(outcome.stopped_reason, StopReason::Completed);
let requests = harness.provider.requests();
assert_eq!(requests.len(), 2);
let tool_message = requests[1]
.messages
.iter()
.find_map(|m| match m {
ChatMessage::Tool {
call_id,
name,
content,
} => Some((call_id.clone(), name.clone(), content.clone())),
_ => None,
})
.expect("必须有工具结果消息");
assert_eq!(tool_message.0, "call_1");
assert_eq!(tool_message.1, "list_game_sessions");
assert!(
tool_message.2.contains(SESSION),
"工具结果应包含会话 id:{}",
tool_message.2
);
let audits = harness.audit.records();
assert_eq!(audits.len(), 1);
assert_eq!(audits[0].tool, "list_game_sessions");
assert_eq!(audits[0].outcome, AuditOutcome::Ok);
assert!(!audits[0].yolo);
assert!(!audits[0].confirmed);
}
#[test]
fn multiple_tool_calls_in_one_step_all_execute() {
let reply = ChatReply::with_tool_calls(vec![
ToolCall {
id: "c1".to_string(),
name: "list_game_sessions".to_string(),
arguments: list_args(),
},
ToolCall {
id: "c2".to_string(),
name: "diagnose_crash".to_string(),
arguments: json!({ "session_id": SESSION }),
},
]);
let harness = Harness::new(vec![reply, ChatReply::text("诊断完成")]);
let mut history = Vec::new();
let outcome = block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "查一下"),
)
.unwrap();
assert_eq!(outcome.tool_calls, 2);
assert_eq!(harness.audit.len(), 2);
assert_eq!(
harness
.audit
.records()
.iter()
.map(|r| r.tool.as_str())
.collect::<Vec<_>>(),
vec!["list_game_sessions", "diagnose_crash"]
);
}
#[test]
fn step_limit_stops_runaway_loop() {
// 每一步都要求调工具,永远不会给最终回答。
let script: Vec<ChatReply> = (0..16)
.map(|i| tool_call_reply(&format!("c{i}"), "get_config", json!({})))
.collect();
let harness = Harness::new(script);
let mut history = Vec::new();
let outcome = block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "死循环"),
)
.unwrap();
assert_eq!(outcome.stopped_reason, StopReason::StepLimit);
assert_eq!(outcome.steps, unlucky_agent::brain::DEFAULT_MAX_STEPS);
assert_eq!(outcome.tool_calls, unlucky_agent::brain::DEFAULT_MAX_STEPS);
}
#[test]
fn streaming_deltas_are_forwarded_as_events() {
let harness = Harness::new(vec![ChatReply::text("日志里是 NoClassDefFoundError")]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "什么错误"),
)
.unwrap();
assert_eq!(
harness.events.streamed_text(),
"日志里是 NoClassDefFoundError"
);
let events = harness.events.events();
assert!(events
.iter()
.any(|e| matches!(e, AgentEvent::TurnStarted { .. })));
assert!(events
.iter()
.any(|e| matches!(e, AgentEvent::AssistantText { .. })));
assert!(events
.iter()
.any(|e| matches!(e, AgentEvent::TurnFinished { .. })));
}
#[test]
fn disabled_provider_fails_fast() {
let harness = Harness::from_provider(Arc::new(MockProvider::disabled()));
let mut history = Vec::new();
let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err();
assert_eq!(err, BrainError::Disabled);
assert!(err.to_string().contains("规则引擎"), "要提示降级能力");
assert!(
harness.provider.requests().is_empty(),
"禁用态不该发起任何请求"
);
assert!(history.is_empty(), "禁用态不该改动历史");
}
#[test]
fn provider_error_surfaces_as_brain_error_and_event() {
let harness = Harness::from_provider(Arc::new(MockProvider::failing(
"连接 http://127.0.0.1:8080 失败",
)));
let mut history = Vec::new();
let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err();
assert_eq!(
err,
BrainError::Provider(unlucky_agent::brain::ProviderError::new(
"连接 http://127.0.0.1:8080 失败"
))
);
assert!(harness
.events
.events()
.iter()
.any(|e| matches!(e, AgentEvent::Error { message } if message.contains("8080"))));
}
#[test]
fn empty_script_yields_empty_completion_turn() {
let harness = Harness::new(vec![]);
let mut history = Vec::new();
let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap();
assert_eq!(outcome.text, "");
assert_eq!(outcome.stopped_reason, StopReason::Completed);
}
+132
View File
@@ -0,0 +1,132 @@
//! **双投影契约测试(MCP 侧)**:注册表 → rmcp `Tool` 必须与 rig 侧逐项相等。
//!
//! research/02 §6.2 建议进 CI 的那条断言,M16 落的是「注册表自身是纯投影」+「rig 适配层
//! 只做直投」,M39 补上 rmcp 侧:
//!
//! 1. `mcp::projection::tool_attrs(&registry)` 的 `(name, description, input_schema)`
//! 与 `registry.definitions()` **逐项相等**(JSON 层面,不是"看起来差不多");
//! 2. `tools/list` 的顺序与注册顺序一致,数量等于注册表长度;
//! 3. 每个 schema 根是 `object`(rig 与 rmcp 的共同前提);
//! 4. `read_only_hint` 与 `Access` 一致(外部客户端据此决定能不能静默调用);
//! 5. 只读子集不会漏出写工具(`agent.mcp.allow_write=false` 的默认形态)。
use serde_json::{json, Value};
use unlucky_agent::mcp::projection::{tool_attr, tool_attrs};
use unlucky_agent::port::Ports;
use unlucky_agent::registry::{Access, ToolDefinition, ToolRegistry};
use unlucky_agent::testing::mock_ports;
use unlucky_agent::tools::build_v1_registry;
fn registry() -> ToolRegistry {
let (ports, _settings) = mock_ports();
build_v1_registry(&ports).expect("注册表应装配成功")
}
fn ports() -> Ports {
mock_ports().0
}
#[test]
fn mcp_projection_equals_registry_definition_item_by_item() {
let registry = registry();
let definitions = registry.definitions();
let attrs = tool_attrs(&registry);
assert_eq!(attrs.len(), definitions.len());
for (attr, definition) in attrs.iter().zip(definitions.iter()) {
assert_eq!(attr.name.as_ref(), definition.name, "工具名必须相等");
assert_eq!(
attr.description.as_deref(),
Some(definition.description.as_str()),
"{} 的 description 必须逐字相等",
definition.name
);
assert_eq!(
Value::Object((*attr.input_schema).clone()),
definition.input_schema,
"{} 的 JSON Schema 必须逐项相等",
definition.name
);
}
}
#[test]
fn projection_order_matches_registration_order() {
let registry = registry();
let projected: Vec<String> = tool_attrs(&registry)
.iter()
.map(|attr| attr.name.to_string())
.collect();
assert_eq!(projected, registry.names(), "两侧顺序必须一致");
}
#[test]
fn every_projected_schema_is_an_object_and_round_trips() {
for attr in tool_attrs(&registry()) {
let schema = Value::Object((*attr.input_schema).clone());
assert_eq!(
schema["type"],
json!("object"),
"{} 的 schema 根必须是 object",
attr.name
);
// rmcp 会把 Tool 序列化进 tools/list 的响应体:必须能反序列化回来。
let json = serde_json::to_string(&attr).expect("Tool 可序列化");
let back: rmcp::model::Tool = serde_json::from_str(&json).expect("Tool 可反序列化");
assert_eq!(back, attr);
}
}
#[test]
fn read_only_hint_tracks_access() {
let registry = registry();
for (attr, spec) in tool_attrs(&registry).iter().zip(registry.specs()) {
let hint = attr
.annotations
.as_ref()
.and_then(|annotations| annotations.read_only_hint)
.expect("每个工具都应有 read_only_hint");
assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name);
}
}
#[test]
fn read_only_subset_projects_without_the_writing_tool() {
let subset = registry().read_only_subset();
assert_eq!(subset.len(), 9);
let names: Vec<String> = tool_attrs(&subset)
.iter()
.map(|attr| attr.name.to_string())
.collect();
assert!(!names.iter().any(|name| name == "set_config"), "{names:?}");
assert!(subset.access_of("set_config").is_none());
}
#[test]
fn projected_attrs_are_pure_functions_of_the_definition() {
let definition = ToolDefinition {
name: "demo".to_string(),
description: "演示".to_string(),
input_schema: json!({ "type": "object", "properties": {}, "required": [] }),
};
let first = tool_attr(&definition, Access::ReadOnly);
let second = tool_attr(&definition, Access::ReadOnly);
assert_eq!(first, second);
// 副作用等级换一下,只有 annotations 变,name/description/schema 不变。
let mutating = tool_attr(&definition, Access::Mutating);
assert_eq!(first.name, mutating.name);
assert_eq!(first.input_schema, mutating.input_schema);
assert_ne!(first.annotations, mutating.annotations);
}
#[test]
fn mock_ports_cover_every_port_the_registry_needs() {
// 装配完整性:五个端口都在,注册表才有这十个工具(少一个端口 = 少工具)。
let ports = ports();
let registry = build_v1_registry(&ports).expect("注册表应装配成功");
assert_eq!(registry.len(), 10);
assert!(unlucky_agent::testing::expected_v1_tool_names()
.iter()
.all(|name| registry.get(name).is_some()));
}
+336
View File
@@ -0,0 +1,336 @@
//! MCP over Streamable HTTP 的端到端测试:真起监听、打裸 JSON-RPC,断言工具返回与审计。
//!
//! 为什么不用 MCP 客户端 crate:客户端依赖会再拖一棵 reqwest 树(且 3.8 的客户端 feature
//! 与我们的服务端 feature 不是一组)。裸 HTTP 反而更贴近「用户用 Claude Code 连上来」
//! 的实际形状——握手、会话头、`tools/list`、`tools/call` 一个都不省。
//!
//! 覆盖的安全语义(02 §3.1 / §4b):
//! - 只读模式下 `set_config` 根本不在工具表里,硬调 → tool-level 错误 + `unknown_tool` 审计;
//! - 普通键(`download.concurrency`,Low)直接写入,审计 `outcome=ok`;
//! - 敏感键(`java.selected_path`,High)在**没有弹窗**的外部场景被拒,文案引导去设置页/YOLO;
//! - 打开 YOLO 后同一个写入成功,审计 `yolo=true`。
use std::net::SocketAddr;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use unlucky_agent::audit::{AuditOutcome, AuditRecord, JsonlAudit};
use unlucky_agent::brain::exec::ToolExecutor;
use unlucky_agent::clock::{FixedClock, SharedClock};
use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate};
use unlucky_agent::event::NoopSink;
use unlucky_agent::mcp::{bind, serve_http_on, McpConfig, RegistryServer};
use unlucky_agent::testing::mock_ports;
use unlucky_agent::tools::build_v1_registry;
const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ull-test","version":"0.1"}}}"#;
struct Harness {
addr: SocketAddr,
audit_dir: std::path::PathBuf,
}
fn audit_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ull-mcp-http-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn server(read_only: bool, yolo: bool, audit: Arc<JsonlAudit>) -> RegistryServer {
let (ports, _settings) = mock_ports();
let registry = Arc::new(build_v1_registry(&ports).expect("注册表应装配成功"));
let exposed = if read_only {
Arc::new(registry.read_only_subset())
} else {
Arc::clone(&registry)
};
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let executor = Arc::new(
ToolExecutor::new(
exposed,
audit,
Arc::new(NoUiGate),
ConfirmPolicy { yolo },
clock,
Arc::new(NoopSink),
)
.with_guidance(DenyGuidance::External),
);
RegistryServer::new(registry, executor, "mcp-http", read_only)
}
async fn spawn(tag: &str, read_only: bool, yolo: bool) -> Harness {
let dir = audit_dir(tag);
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let audit = Arc::new(JsonlAudit::new(&dir, clock));
let server = server(read_only, yolo, audit);
let cfg = McpConfig {
port: 0,
..McpConfig::default()
};
let (addr, listener) = bind(&cfg).await.expect("应能绑定临时端口");
tokio::spawn(async move {
let _ = serve_http_on(server, cfg, addr, listener).await;
});
Harness {
addr,
audit_dir: dir,
}
}
/// 发一条 HTTP POST(`Connection: close` 让读取变成 read_to_end)。
async fn post(
addr: SocketAddr,
body: &str,
session: Option<&str>,
token: Option<&str>,
) -> (u16, Option<String>, String) {
let mut stream = TcpStream::connect(addr).await.expect("连接失败");
let mut head = format!(
"POST /mcp HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\
Accept: application/json, text/event-stream\r\nConnection: close\r\n\
Content-Length: {}\r\n",
body.len()
);
if let Some(session) = session {
head.push_str(&format!("Mcp-Session-Id: {session}\r\n"));
}
if let Some(token) = token {
head.push_str(&format!("Authorization: Bearer {token}\r\n"));
}
head.push_str("\r\n");
stream
.write_all(format!("{head}{body}").as_bytes())
.await
.expect("写请求失败");
stream.flush().await.expect("刷新失败");
let mut raw = Vec::new();
stream.read_to_end(&mut raw).await.expect("读响应失败");
let text = String::from_utf8_lossy(&raw).to_string();
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((text.as_str(), ""));
let status = head
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|code| code.parse::<u16>().ok())
.unwrap_or(0);
let session = head.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("mcp-session-id")
.then(|| value.trim().to_string())
});
(status, session, body.to_string())
}
/// 响应体可能是裸 JSON,也可能是 SSE(`data: {...}`);两种都解出第一条 JSON-RPC 响应。
fn parse_rpc(body: &str) -> Value {
if let Ok(value) = serde_json::from_str::<Value>(body.trim()) {
return value;
}
for line in body.lines() {
let Some(data) = line.strip_prefix("data: ") else {
continue;
};
if let Ok(value) = serde_json::from_str::<Value>(data.trim()) {
return value;
}
}
panic!("响应体既不是 JSON 也不是 SSE:{body}");
}
async fn initialize(addr: SocketAddr) -> Option<String> {
let (status, session, body) = post(addr, INITIALIZE, None, None).await;
assert_eq!(status, 200, "initialize 应成功:{body}");
let response = parse_rpc(&body);
assert_eq!(
response["result"]["serverInfo"]["name"],
json!("unlucky-launcher")
);
// 按 MCP 规范补一条 initialized 通知(无 id、无响应体)。
let _ = post(
addr,
r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
session.as_deref(),
None,
)
.await;
session
}
async fn rpc(
addr: SocketAddr,
session: Option<&str>,
id: u32,
method: &str,
params: Value,
) -> Value {
let body =
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
let (status, _session, text) = post(addr, &body, session, None).await;
assert_eq!(status, 200, "{method} 应成功:{text}");
parse_rpc(&text)
}
fn audit_records(dir: &std::path::Path) -> Vec<AuditRecord> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let text = std::fs::read_to_string(entry.path()).unwrap_or_default();
for line in text.lines() {
if let Ok(record) = serde_json::from_str::<AuditRecord>(line) {
out.push(record);
}
}
}
}
out
}
#[tokio::test(flavor = "multi_thread")]
async fn readonly_server_lists_and_serves_only_read_tools() {
let harness = spawn("readonly", true, false).await;
let session = initialize(harness.addr).await;
let list = rpc(harness.addr, session.as_deref(), 2, "tools/list", json!({})).await;
let tools = list["result"]["tools"].as_array().expect("应有 tools 数组");
let names: Vec<&str> = tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
assert_eq!(names.len(), 9, "{names:?}");
assert!(!names.contains(&"set_config"), "只读模式不得暴露写工具");
assert!(names.contains(&"get_config"));
// 真调一个只读工具:返回真实配置概要(mock 端口)。
let call = rpc(
harness.addr,
session.as_deref(),
3,
"tools/call",
json!({ "name": "get_config", "arguments": {} }),
)
.await;
assert_ne!(call["result"]["isError"], json!(true), "{call}");
let text = call["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("download.concurrency"), "{text}");
// 硬调写工具:tool-level 错误(客户端能看见文案)+ unknown_tool 审计。
let denied = rpc(
harness.addr,
session.as_deref(),
4,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
)
.await;
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
let records = audit_records(&harness.audit_dir);
assert!(records.iter().any(|r| r.tool == "get_config"
&& r.outcome == AuditOutcome::Ok
&& r.agent_session_id == "mcp-http"));
assert!(records
.iter()
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::UnknownTool));
let _ = std::fs::remove_dir_all(&harness.audit_dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn write_server_enforces_sensitive_key_confirmation() {
let harness = spawn("write", false, false).await;
let session = initialize(harness.addr).await;
// 普通键:直接写。
let ok = rpc(
harness.addr,
session.as_deref(),
2,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
)
.await;
assert_ne!(ok["result"]["isError"], json!(true), "{ok}");
// 敏感键:外部客户端没有弹窗 → 拒绝,文案要能指导用户下一步。
let denied = rpc(
harness.addr,
session.as_deref(),
3,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
)
.await;
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
let text = denied["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(
text.contains("YOLO"),
"拒绝文案应引导到 YOLO/设置页:{text}"
);
assert!(text.contains("设置页"), "{text}");
let records = audit_records(&harness.audit_dir);
assert!(records
.iter()
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::AwaitingConfirmation));
assert!(records
.iter()
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Denied && !r.yolo));
let _ = std::fs::remove_dir_all(&harness.audit_dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn yolo_lets_the_same_write_through_and_audits_the_flag() {
let harness = spawn("yolo", false, true).await;
let session = initialize(harness.addr).await;
let written = rpc(
harness.addr,
session.as_deref(),
2,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
)
.await;
assert_ne!(written["result"]["isError"], json!(true), "{written}");
let records = audit_records(&harness.audit_dir);
let yolo_ok = records
.iter()
.find(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Ok);
let record = yolo_ok.expect("应有一条 ok 审计");
assert!(record.yolo, "YOLO 放行必须标记 yolo=true");
assert!(!record.confirmed, "YOLO 自动放行不算用户确认");
let _ = std::fs::remove_dir_all(&harness.audit_dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn bearer_token_is_enforced_when_configured() {
let dir = audit_dir("token");
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
let audit = Arc::new(JsonlAudit::new(&dir, clock));
let server = server(true, false, audit);
let cfg = McpConfig {
port: 0,
token: Some("s3cret".to_string()),
..McpConfig::default()
};
let (addr, listener) = bind(&cfg).await.expect("应能绑定");
tokio::spawn(async move {
let _ = serve_http_on(server, cfg, addr, listener).await;
});
let (status, _, _) = post(addr, INITIALIZE, None, None).await;
assert_eq!(status, 401, "没带令牌必须 401");
let (status, _, body) = post(addr, INITIALIZE, None, Some("s3cret")).await;
assert_eq!(status, 200, "带对令牌应通过:{body}");
let _ = std::fs::remove_dir_all(&dir);
}
+163
View File
@@ -0,0 +1,163 @@
//! **双投影契约测试**(research/02 §6.2 建议进 CI 的那一条)。
//!
//! rig 侧(`DynamicTool::new`)与 rmcp 侧(`ToolRoute::new_dyn`)吃的都是
//! `(name, description, serde_json::Value schema)`。本 crate 只维护一份 `ToolSpec`,
//! 所以「两侧相等」在这里等价于三条可断言的性质:
//! 1. [`unlucky_agent::registry::ToolRegistry::definitions`] 是纯投影(多次调用逐字节相等);
//! 2. 每个 schema 根是 `type: object`(两侧都要求对象入参)且拒绝多余参数;
//! 3. 名字唯一,`required` 真的被执行(缺参能被校验器抓到)。
//!
//! 本文件只锁**投影载荷本身**;两侧的一致性各有专测:
//! - rig 侧:`tests/brain_loop.rs` 断言 `provider` 收到的 `request.tools` 与
//! `registry.definitions()` 逐字节相等(`brain/rig.rs` 只做直投);
//! - MCP 侧:`tests/mcp_contract.rs` 断言 rmcp `Tool` 的 `(name, description, schema)` 与
//! 注册表逐项相等(M39 补的那条)。
use serde_json::json;
use unlucky_agent::registry::validate::validate;
use unlucky_agent::registry::{Access, ToolDefinition};
use unlucky_agent::testing::mock_ports;
fn registry() -> unlucky_agent::registry::ToolRegistry {
let (ports, _settings) = mock_ports();
unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功")
}
#[test]
fn ships_exactly_the_v1_tools() {
let registry = registry();
assert_eq!(
registry.names(),
vec![
"get_config",
"set_config",
"get_config_schema",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
]
);
assert_eq!(registry.len(), 10);
}
#[test]
fn definitions_are_pure_projections() {
let registry = registry();
let first = registry.definitions();
let second = registry.definitions();
assert_eq!(first, second, "投影必须是纯函数");
for definition in &first {
// 序列化后再反序列化必须完全一样(rig 与 rmcp 都走 JSON)。
let json = serde_json::to_string(definition).expect("可序列化");
let round_trip: ToolDefinition = serde_json::from_str(&json).expect("可反序列化");
assert_eq!(&round_trip, definition);
assert!(!definition.description.trim().is_empty());
assert_eq!(
definition.input_schema["type"],
json!("object"),
"{} 的 schema 根必须是 object",
definition.name
);
}
}
#[test]
fn only_set_config_is_mutating() {
let registry = registry();
for name in registry.names() {
let expected = if name == "set_config" {
Access::Mutating
} else {
Access::ReadOnly
};
assert_eq!(registry.access_of(name), Some(expected), "{name}");
}
let read_only: Vec<String> = registry
.read_only_definitions()
.into_iter()
.map(|d| d.name)
.collect();
assert_eq!(
read_only,
vec![
"get_config",
"get_config_schema",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
],
"只读 MCP 模式只暴露这九个(写侧只有 set_config)"
);
}
#[test]
fn every_schema_rejects_unknown_arguments() {
for definition in registry().definitions() {
let err = validate(&definition.input_schema, &json!({ "__unexpected": 1 }))
.expect_err(&format!("{} 应拒绝野参数", definition.name));
assert!(
err.iter().any(|e| e.contains("不是可接受的参数名")),
"{}: {err:?}",
definition.name
);
}
}
#[test]
fn required_fields_are_actually_enforced() {
for definition in registry().definitions() {
let required = definition
.input_schema
.get("required")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
if required.is_empty() {
continue;
}
let err = validate(&definition.input_schema, &json!({}))
.expect_err(&format!("{} 应有必填参数", definition.name));
for field in required {
let name = field.as_str().unwrap_or_default();
assert!(
err.iter().any(|e| e.contains(name)),
"{} 的必填 `{name}` 没被校验:{err:?}",
definition.name
);
}
}
}
#[test]
fn integer_bounds_are_declared_and_enforced() {
let registry = registry();
let read_log = registry.get("read_game_log").expect("工具存在");
let max_events = &read_log.input_schema["properties"]["max_events"];
assert_eq!(max_events["minimum"], json!(1));
assert_eq!(max_events["maximum"], json!(500));
assert!(validate(
&read_log.input_schema,
&json!({ "session_id": "s", "max_events": 0 })
)
.is_err());
assert!(validate(
&read_log.input_schema,
&json!({ "session_id": "s", "max_events": 501 })
)
.is_err());
assert!(validate(
&read_log.input_schema,
&json!({ "session_id": "s", "max_events": 500 })
)
.is_ok());
}
+225
View File
@@ -0,0 +1,225 @@
//! 集成测试的公共脚手架:**mock provider** + 迷你 `block_on` + 装配好的 harness。
//!
//! `block_on` 是手写的 20 行:mock provider 的 future 永远立刻 Ready,所以不需要运行时。
//! 这样 agent 的循环测试**零 tokio 依赖**,阶段 B 换成 rig 后才需要真运行时。
#![allow(dead_code)]
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use serde_json::{json, Value};
use unlucky_agent::audit::{AuditSink, MemoryAudit};
use unlucky_agent::brain::{Brain, BrainConfig, BrainDeps};
use unlucky_agent::brain::{
ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderConfig, ProviderError,
ProviderFuture, ToolCall,
};
use unlucky_agent::clock::{FixedClock, SharedClock};
use unlucky_agent::confirm::{AutoApproveGate, ConfirmGate, ConfirmPolicy, NoUiGate};
use unlucky_agent::event::{AgentEventSink, MemoryEventSink};
use unlucky_agent::registry::ToolRegistry;
use unlucky_agent::testing::{mock_ports, MockSettings};
/// 立刻完成的 future 直接 poll;挂起就是测试写错了。
pub fn block_on<F: Future>(future: F) -> F::Output {
let mut future: Pin<Box<F>> = Box::pin(future);
let waker = Waker::noop();
let mut context = Context::from_waker(waker);
match future.as_mut().poll(&mut context) {
Poll::Ready(value) => value,
Poll::Pending => panic!("mock provider 不应挂起:测试里没有执行器"),
}
}
/// 脚本化 provider:按顺序回放 [`ChatReply`],并记录收到的请求。
pub struct MockProvider {
config: ProviderConfig,
script: Mutex<VecDeque<ChatReply>>,
requests: Mutex<Vec<ChatRequest>>,
fail_with: Option<String>,
}
impl MockProvider {
/// 用默认模型名(本地 llama.cpp 形态)回放脚本。
#[must_use]
pub fn new(script: Vec<ChatReply>) -> Self {
Self {
config: ProviderConfig::llamacpp_local("mock-model"),
script: Mutex::new(script.into()),
requests: Mutex::new(Vec::new()),
fail_with: None,
}
}
/// 禁用态(测 `BrainError::Disabled`)。
#[must_use]
pub fn disabled() -> Self {
Self {
config: ProviderConfig::disabled(),
script: Mutex::new(VecDeque::new()),
requests: Mutex::new(Vec::new()),
fail_with: None,
}
}
/// 每次补全都失败(测 provider 错误透传)。
#[must_use]
pub fn failing(message: &str) -> Self {
Self {
fail_with: Some(message.to_string()),
..Self::new(Vec::new())
}
}
/// 收到的请求(断言 system prompt / 工具定义 / 回灌的工具消息)。
#[must_use]
pub fn requests(&self) -> Vec<ChatRequest> {
self.requests.lock().map(|r| r.clone()).unwrap_or_default()
}
}
impl ChatProvider for MockProvider {
fn config(&self) -> &ProviderConfig {
&self.config
}
fn complete<'a>(
&'a self,
request: &'a ChatRequest,
observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a> {
Box::pin(async move {
if let Ok(mut requests) = self.requests.lock() {
requests.push(request.clone());
}
if let Some(message) = &self.fail_with {
return Err(ProviderError::new(message.clone()));
}
let reply = self
.script
.lock()
.ok()
.and_then(|mut script| script.pop_front())
.unwrap_or_default();
// 按 4 字符切块吐增量,模拟流式。
if !reply.text.is_empty() {
for chunk in reply.text.chars().collect::<Vec<_>>().chunks(4) {
observer.on_delta(&chunk.iter().collect::<String>());
}
}
Ok(reply)
})
}
}
/// 装配好的测试环境。
pub struct Harness {
/// 对话循环。
pub brain: Brain,
/// 审计出口(内存)。
pub audit: Arc<MemoryAudit>,
/// 事件出口(内存)。
pub events: Arc<MemoryEventSink>,
/// mock provider(查请求)。
pub provider: Arc<MockProvider>,
/// mock 配置端口(查是否真的写入)。
pub settings: Arc<MockSettings>,
/// 工具注册表。
pub registry: Arc<ToolRegistry>,
}
impl Harness {
/// P0 默认形态:无确认 UI(`NoUiGate`,敏感操作 fail-closed)+ 非 YOLO。
/// 确认请求本身仍会作为 [`unlucky_agent::event::AgentEvent::ConfirmRequested`] 发出。
#[must_use]
pub fn new(script: Vec<ChatReply>) -> Self {
Self::with_gate(script, ConfirmPolicy::default(), |_sink| Arc::new(NoUiGate))
}
/// YOLO:自动放行全部(除破坏性)。
#[must_use]
pub fn yolo(script: Vec<ChatReply>) -> Self {
Self::with_gate(script, ConfirmPolicy { yolo: true }, |_sink| {
Arc::new(AutoApproveGate)
})
}
/// 用指定的(可能失效/禁用的)provider 装配,其余同 [`Harness::new`]。
#[must_use]
pub fn from_provider(provider: Arc<MockProvider>) -> Self {
Self::build(provider, ConfirmPolicy::default(), |_sink| {
Arc::new(NoUiGate)
})
}
/// 自定义闸门 + 策略。
#[must_use]
pub fn with_gate(
script: Vec<ChatReply>,
policy: ConfirmPolicy,
make_gate: impl FnOnce(Arc<dyn AgentEventSink>) -> Arc<dyn ConfirmGate>,
) -> Self {
Self::build(Arc::new(MockProvider::new(script)), policy, make_gate)
}
fn build(
provider: Arc<MockProvider>,
policy: ConfirmPolicy,
make_gate: impl FnOnce(Arc<dyn AgentEventSink>) -> Arc<dyn ConfirmGate>,
) -> Self {
let (ports, settings) = mock_ports();
let registry =
Arc::new(unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功"));
let audit = Arc::new(MemoryAudit::new());
let events = Arc::new(MemoryEventSink::new());
let clock: SharedClock = Arc::new(FixedClock::with_step(1_793_314_353_123, 3));
let gate = make_gate(events.clone());
let deps = BrainDeps::new(
audit.clone() as Arc<dyn AuditSink>,
gate,
policy,
clock,
events.clone() as Arc<dyn AgentEventSink>,
);
let brain = Brain::new(
provider.clone(),
Arc::clone(&registry),
deps,
BrainConfig::default(),
);
Self {
brain,
audit,
events,
provider,
settings,
registry,
}
}
}
/// 构造一条工具调用回复。
#[must_use]
pub fn tool_call_reply(id: &str, name: &str, arguments: Value) -> ChatReply {
ChatReply::with_tool_calls(vec![ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments,
}])
}
/// 便捷:游戏会话 id(与 `MockSessions::crashing_session` 一致)。
pub const SESSION: &str = "20261029T124501-9f3c2a";
/// 便捷:agent 对话会话 id。
pub const AGENT_SESSION: &str = "chat-1";
/// 便捷:`list_game_sessions` 的最小参数。
#[must_use]
pub fn list_args() -> Value {
json!({ "limit": 5 })
}
+365
View File
@@ -0,0 +1,365 @@
//! 安全边界集成测试:白名单拦截、分级确认、YOLO、审计标记。
//!
//! 这些是 02 文档 §3.1「安全边界」+ §4b「YOLO」与 M12 `setting-接口约定.md` §8
//! (`Low/Medium/High` 三档 + `writable=false` 凭据)的可执行版本;
//! 判断口径对照 research/02 §7 R9(写操作的破坏半径)。
mod support;
use std::sync::Arc;
use serde_json::{json, Value};
use support::{block_on, tool_call_reply, Harness, AGENT_SESSION};
use unlucky_agent::audit::AuditOutcome;
use unlucky_agent::brain::ChatReply;
use unlucky_agent::confirm::{ConfirmDecision, ConfirmPolicy, ScriptedGate};
use unlucky_agent::event::AgentEvent;
fn set_config_reply(path: &str, value: Value) -> ChatReply {
tool_call_reply("c1", "set_config", json!({ "path": path, "value": value }))
}
fn tool_results(harness: &Harness) -> Vec<String> {
harness
.provider
.requests()
.last()
.map(|request| {
request
.messages
.iter()
.filter_map(|m| match m {
unlucky_agent::brain::ChatMessage::Tool { content, .. } => {
Some(content.clone())
}
_ => None,
})
.collect()
})
.unwrap_or_default()
}
fn confirm_events(harness: &Harness) -> usize {
harness
.events
.events()
.iter()
.filter(|e| matches!(e, AgentEvent::ConfirmRequested { .. }))
.count()
}
#[test]
fn low_key_writes_silently_without_confirmation() {
let harness = Harness::new(vec![
set_config_reply("appearance.theme_mode", json!("metro_light")),
ChatReply::text("已切换主题"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换成浅色"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(
harness.settings.value_of("appearance.theme_mode"),
Some(json!("metro_light"))
);
assert_eq!(confirm_events(&harness), 0, "Low 键不该打扰用户");
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1);
assert_eq!(records[0].outcome, AuditOutcome::Ok);
assert!(!records[0].confirmed, "静默生效不是「用户确认」");
assert!(!records[0].yolo);
}
#[test]
fn medium_key_also_writes_silently() {
// M12:Medium = 静默生效但审计标记(不弹窗)
let harness = Harness::new(vec![
set_config_reply("download.region", json!("cn")),
ChatReply::text("已切到国内源"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换下载源"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(confirm_events(&harness), 0);
assert_eq!(
harness.audit.records_for("set_config")[0].outcome,
AuditOutcome::Ok
);
}
#[test]
fn high_key_is_fail_closed_without_ui() {
let harness = Harness::new(vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("需要你确认"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(
harness.settings.set_calls(),
0,
"P0 无 UI 时必须 fail-closed"
);
assert_eq!(harness.settings.value_of("java.selected_path"), None);
assert_eq!(confirm_events(&harness), 1, "确认请求必须作为事件发出");
let outcomes: Vec<AuditOutcome> = harness
.audit
.records_for("set_config")
.iter()
.map(|r| r.outcome)
.collect();
assert_eq!(
outcomes,
vec![AuditOutcome::AwaitingConfirmation, AuditOutcome::Denied],
"先记「AI 想改」,再记「被拒绝」"
);
assert!(tool_results(&harness)
.iter()
.any(|content| content.contains("denied")));
assert!(harness
.events
.events()
.iter()
.any(|e| matches!(e, AgentEvent::ToolDenied { .. })));
}
#[test]
fn user_approval_writes_and_marks_confirmed() {
let harness = Harness::with_gate(
vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("已更新"),
],
ConfirmPolicy::default(),
|_sink| {
Arc::new(ScriptedGate::new(
[ConfirmDecision::Approved],
ConfirmDecision::Denied,
))
},
);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1, "确认后只写一次");
let records = harness.audit.records_for("set_config");
let last = records.last().expect("必须有审计记录");
assert_eq!(last.outcome, AuditOutcome::Ok);
assert!(last.confirmed, "用户点了允许");
assert!(!last.yolo);
}
#[test]
fn yolo_skips_confirm_but_audits_flag() {
let harness = Harness::yolo(vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("已更新"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(confirm_events(&harness), 0, "YOLO 不打扰用户");
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1, "YOLO 只留结局记录");
assert!(records[0].yolo, "审计必须标记 yolo");
assert!(!records[0].confirmed, "自动放行不是用户确认");
assert_eq!(records[0].outcome, AuditOutcome::Ok);
}
#[test]
fn whitelist_violation_is_rejected_and_audited() {
let harness = Harness::new(vec![
set_config_reply("java.home.mystery", json!("x")),
ChatReply::text("换个方案"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "改个不存在的键"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1);
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("白名单")),
"模型需要知道为什么被拒:{results:?}"
);
}
#[test]
fn invalid_args_are_rejected_before_any_side_effect() {
// 缺 value(schema required)
let harness = Harness::new(vec![
tool_call_reply(
"c1",
"set_config",
json!({ "path": "appearance.theme_mode" }),
),
ChatReply::text("参数补上"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "改主题"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
assert!(tool_results(&harness)
.iter()
.any(|c| c.contains("缺少必填参数")));
// 多余参数(additionalProperties: false)
let harness = Harness::new(vec![
tool_call_reply(
"c2",
"read_game_log",
json!({ "session_id": "x", "path": "evil" }),
),
ChatReply::text("好"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "读日志"),
)
.unwrap();
let records = harness.audit.records_for("read_game_log");
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
}
#[test]
fn unknown_tool_lists_available_names() {
let harness = Harness::new(vec![
tool_call_reply("c1", "delete_everything", json!({})),
ChatReply::text("抱歉"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "删库跑路"),
)
.unwrap();
let records = harness.audit.records_for("delete_everything");
assert_eq!(records[0].outcome, AuditOutcome::UnknownTool);
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("get_config")),
"{results:?}"
);
}
#[test]
fn locked_credential_key_is_refused_even_under_yolo() {
// M12:ai.api_key 是 writable=false(不在白名单)→ YOLO 也不能放宽可写范围
let harness = Harness::yolo(vec![
set_config_reply("ai.api_key", json!("sk-super-secret-value")),
ChatReply::text("换不了"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 key"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
let args = serde_json::to_string(&records[0].args).unwrap();
assert!(
!args.contains("sk-super-secret-value"),
"审计绝不能落明文密钥:{args}"
);
assert!(args.contains("***"), "{args}");
}
#[test]
fn get_config_refuses_credential_keys() {
// F1:模型可自由给 path,凭据键必须读不到 —— 否则值会进模型上下文发往远端 LLM。
let harness = Harness::new(vec![
tool_call_reply("c1", "get_config", json!({ "path": "ai.api_key" })),
ChatReply::text("读不到"),
]);
// 模拟 M12 真实实现:凭据键在文件里确实有值(writable=false,不在白名单也要能防住读)
harness
.settings
.seed_value("ai.api_key", json!("sk-live-credential-value"));
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "看看我的 key"),
)
.unwrap();
// 1) 工具报错并被审计
let records = harness.audit.records_for("get_config");
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
// 2) 模型拿到的是拒绝文案,不是值
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("凭据")),
"应给出可读拒绝:{results:?}"
);
assert!(
!results
.iter()
.any(|c| c.contains("sk-live-credential-value")),
"工具结果绝不能带凭据:{results:?}"
);
// 3) 事件流与对话历史里也不能出现(P1 事件会落盘 / 历史会持久化)
let leaked_in_events = harness
.events
.events()
.iter()
.any(|e| format!("{e:?}").contains("sk-live-credential-value"));
assert!(!leaked_in_events, "事件流不能带凭据明文");
let leaked_in_history = history
.iter()
.any(|m| format!("{m:?}").contains("sk-live-credential-value"));
assert!(!leaked_in_history, "对话历史不能带凭据明文");
}
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "unlucky-app"
description = "Unlucky-Launcher 桌面壳:Slint + Metro 视觉(唯一允许依赖 UI 框架的 crate)"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
[[bin]]
name = "unlucky-launcher"
path = "src/main.rs"
[dependencies]
slint.workspace = true
# 跨界 DTO 必须可序列化(AGENTS §6 / 01c §5):事件与快照是 IPC 契约,
# 序列化能力由 derive 钉住,测试里做 round-trip 断言。
serde.workspace = true
# host 层要读 core 设置/事件里的 JSON 值(serde_json 是 workspace 既有依赖)
serde_json.workspace = true
# 边界(01c §5 / D3):app 只经 DTO 消费 core/agent,**不 import 内部类型**。
# P0 的 host 是 src/mock.rs 的假后端;这两个依赖在这里登记,是为了后续把真实 host
# (core 的快照/事件流翻译成 src/dto 的类型)落位时不必再动 workspace 契约。
unlucky-core = { path = "../core", version = "0.1" }
unlucky-agent = { path = "../agent", version = "0.1" }
[build-dependencies]
slint-build.workspace = true
[dev-dependencies]
[lints]
workspace = true
+7
View File
@@ -0,0 +1,7 @@
//! Slint 编译入口:把 ui/main.slint 编译成 Rust 代码,供 src/main.rs 的 `slint::include_modules!()` 引入。
//! 保持极简——UI 结构演进(多文件拆分、主题文件)在 M17 阶段 B 里扩展本脚本。
/// 编译 `ui/main.slint`;失败时让构建直接报错(UI 语法错误不该被静默忽略)。
fn main() {
slint_build::compile("ui/main.slint").expect("Slint UI 编译失败(ui/main.slint)");
}
+124
View File
@@ -0,0 +1,124 @@
//! 「外观(高级)」四件的回调接线(从 `callbacks.rs` 拆出,守住单文件 ≤400 行)。
//!
//! 与 `wire_settings` 的分工:这个文件只负责 09 §101-104 的四个高级特性
//! (背景图 / 面板半透明度 / 玻璃拟态 / 强调色)+ 主题三态。
//! 每个回调都是同一套动作:改 `state.prefs` → 下发到 Slint(`apply_*`)→ 落盘(`persist_settings`)→
//! `sync()`。出厂全部关闭,所以「不碰设置页」时这里一次都不会触发。
use std::rc::Weak;
use crate::conv;
use crate::ui_bind::Inner;
/// 接主题三态与外观四件的回调。
pub(crate) fn wire(ui: &crate::MainWindow, weak: &Weak<Inner>) {
{
let weak = weak.clone();
ui.on_theme_changed(move |mode| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().prefs.theme = conv::theme_mode_from(mode);
this.apply_theme();
this.persist_settings();
this.sync();
});
}
// ── 09 §101-104「外观(高级)」四件(出厂全关,改动即刻落盘)────────
{
let weak = weak.clone();
ui.on_bg_image_enabled_changed(move |enabled| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().prefs.appearance.bg_image_enabled = enabled;
this.apply_appearance();
this.persist_settings();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_bg_image_changed(move |path| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().prefs.appearance.bg_image = path.to_string();
this.apply_appearance();
this.persist_settings();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_bg_fill_changed(move |fill| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().prefs.appearance.bg_fill = conv::bg_fill_from(fill);
this.apply_appearance();
this.persist_settings();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_bg_dim_changed(move |value| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().prefs.appearance.bg_dim = value;
this.apply_appearance();
this.persist_settings();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_panel_alpha_changed(move |value| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().prefs.appearance.panel_alpha = value;
this.apply_appearance();
this.persist_settings();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_glass_changed(move |enabled| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().prefs.appearance.glass = enabled;
this.apply_appearance();
this.persist_settings();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_accent_color_changed(move |text| {
let Some(this) = weak.upgrade() else { return };
let accepted = this.state.borrow_mut().prefs.appearance.set_accent(&text);
// 非法输入:只亮设置页的 warn 行,不改现状、不落盘
this.ui.set_accent_invalid(!accepted);
if accepted {
this.apply_appearance();
this.persist_settings();
}
this.sync();
});
}
{
let weak = weak.clone();
ui.on_accent_swatch_picked(move |index| {
let Some(this) = weak.upgrade() else { return };
let Ok(index) = usize::try_from(index) else {
return;
};
let accepted = this
.state
.borrow_mut()
.prefs
.appearance
.set_accent_preset(index);
if !accepted {
return;
}
let hex = this.state.borrow().prefs.appearance.accent_color.clone();
this.ui.set_accent_color(conv::accent_color(&hex));
this.ui.set_accent_invalid(false);
this.apply_appearance();
this.persist_settings();
this.sync();
});
}
}
+36
View File
@@ -0,0 +1,36 @@
//! 后端选择与启动选项:真实 core host 优先,解析不出数据根时退回 mock。
use std::path::{Path, PathBuf};
use crate::dto::Backend;
use crate::host::CoreHost;
use crate::host::HostEnv;
use crate::mock::MockBackend;
/// 启动选项(截图/演示通道用;正常启动全用默认值)。
#[derive(Debug, Clone, Default)]
pub struct AppOptions {
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析
pub data_root: Option<PathBuf>,
/// 强制 mock 后端(UI 回归/无数据根演示)
pub mock: bool,
}
/// `<root>/.ULL/config/launcher.json`(与 `SettingsBridge::open` 同落点)。
pub(crate) fn config_path(root: &Path) -> PathBuf {
root.join(".ULL").join("config").join("launcher.json")
}
/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。
pub(crate) fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
if options.mock {
return Box::new(MockBackend::new());
}
match &options.data_root {
Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))),
None => match CoreHost::open() {
Some(host) => Box::new(host),
None => Box::new(MockBackend::new()),
},
}
}
+370
View File
@@ -0,0 +1,370 @@
//! 背景图与玻璃拟态模糊层的准备(09 §101/§103 的「预生成」手法,纯 Rust、无新依赖)。
//!
//! 为什么要自己算:Slint 没有 `backdrop-filter`,也无法在渲染管线里做高斯模糊。可用的只有
//! `Image::load_from_path` + 像素缓冲(`to_rgba8` / `from_rgba8`)。于是:
//!
//! - **模糊层** = 把原图按面积平均降到极小的尺寸(默认宽 48px),渲染时被平滑放大 → 天然模糊
//! (面积平均本身就是低通滤波,比在满分辨率上跑 box blur 快几个数量级,且一次只在设置变更时算);
//! - **tile 模式** = Slint 的 `image-fit` 没有 `repeat`,所以在 Rust 侧按窗口尺寸平铺成一张画布,
//! 交给 `image-fit: cover`(画布与窗口同尺寸,等价于 1:1 铺设)。
//!
//! 解码格式:Slint 默认只编译 PNG 解码器(`image-default-formats` 特性未开),因此这里只保证 PNG;
//! 其它格式 `load_from_path` 直接失败,UI 侧给出提示而不是静默无效果。
use slint::{Image, Rgba8Pixel, SharedPixelBuffer};
use crate::prefs::BgFill;
/// 模糊层目标宽度(像素)。越小越糊;96 在 1180px 宽窗口上 ≈ 24px 半径高斯的效果。
const BLUR_WIDTH: u32 = 96;
/// 小图上再跑几轮 box blur(3×3):把降采样的块状边缘抹平。
const BLUR_PASSES: usize = 3;
/// tile 画布上限(防超大窗口吃爆内存:4096×2560×4 ≈ 40MB)。
const TILE_MAX_WIDTH: u32 = 4096;
const TILE_MAX_HEIGHT: u32 = 2560;
/// 一次准备出来的两张图:原图(按填充模式处理)+ 模糊层。
#[derive(Debug, Clone, Default)]
pub struct BackgroundLayers {
/// 铺在窗口底的图(tile 模式下已平铺成整窗画布)
pub image: Image,
/// 玻璃拟态用的模糊层
pub blur: Image,
}
/// 载入并加工背景图。
///
/// `viewport` 是窗口的**物理像素**尺寸(tile 模式平铺用;拿不到时传 `(0, 0)` 退回只载入)。
/// 返回 `None` 表示路径无效 / 解码失败 / 读了像素但为空——调用方据此提示用户。
#[must_use]
pub fn prepare(path: &str, fill: BgFill, viewport: (u32, u32)) -> Option<BackgroundLayers> {
let path = path.trim();
if path.is_empty() {
return None;
}
let loaded = Image::load_from_path(std::path::Path::new(path)).ok()?;
let source = loaded.to_rgba8()?;
if source.width() == 0 || source.height() == 0 {
return None;
}
let image = match fill {
BgFill::Tile => {
let (width, height) = tile_canvas_size(source.width(), source.height(), viewport);
Image::from_rgba8(tile(&source, width, height))
}
// cover / contain 交给 Slint 的 image-fit,这里保留原图
BgFill::Cover | BgFill::Contain => loaded,
};
let blurred = box_blur(&downscale(&source, BLUR_WIDTH), BLUR_PASSES);
// 放大回窗口物理尺寸再做 1:1 贴图:软件渲染器对图片缩放不做平滑插值(最近邻),
// 直接把 96px 的小图丢给它会出现明显方块;这里自己双线性放大,观感才是「磨砂」。
let blur = match viewport {
(0, 0) => Image::from_rgba8(blurred),
(width, height) => Image::from_rgba8(upscale(&blurred, width, height)),
};
Some(BackgroundLayers { image, blur })
}
/// 3×3 均值模糊,`passes` 轮(小图上跑,代价可忽略)。
fn box_blur(
source: &SharedPixelBuffer<Rgba8Pixel>,
passes: usize,
) -> SharedPixelBuffer<Rgba8Pixel> {
let width = source.width() as usize;
let height = source.height() as usize;
if width < 3 || height < 3 || passes == 0 {
return source.clone();
}
let mut current: Vec<Rgba8Pixel> = source.as_slice().to_vec();
let mut next = current.clone();
for _ in 0..passes {
for y in 0..height {
for x in 0..width {
let mut sum = [0u32; 4];
let mut count = 0u32;
for dy in -1i32..=1 {
for dx in -1i32..=1 {
let sy = (y as i32 + dy).clamp(0, height as i32 - 1) as usize;
let sx = (x as i32 + dx).clamp(0, width as i32 - 1) as usize;
let pixel = current[sy * width + sx];
sum[0] += u32::from(pixel.r);
sum[1] += u32::from(pixel.g);
sum[2] += u32::from(pixel.b);
sum[3] += u32::from(pixel.a);
count += 1;
}
}
next[y * width + x] = Rgba8Pixel {
r: (sum[0] / count) as u8,
g: (sum[1] / count) as u8,
b: (sum[2] / count) as u8,
a: (sum[3] / count) as u8,
};
}
}
std::mem::swap(&mut current, &mut next);
}
let mut out = SharedPixelBuffer::<Rgba8Pixel>::new(source.width(), source.height());
out.make_mut_slice().copy_from_slice(&current);
out
}
/// 双线性放大到目标尺寸。
fn upscale(
source: &SharedPixelBuffer<Rgba8Pixel>,
width: u32,
height: u32,
) -> SharedPixelBuffer<Rgba8Pixel> {
let src_w = source.width();
let src_h = source.height();
if src_w == 0 || src_h == 0 || width == 0 || height == 0 {
return SharedPixelBuffer::new(width.max(1), height.max(1));
}
let pixels = source.as_slice();
let mut out = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
let dst = out.make_mut_slice();
let sample = |fx: f32, fy: f32| -> Rgba8Pixel {
let x = (fx * (src_w - 1) as f32).clamp(0.0, (src_w - 1) as f32);
let y = (fy * (src_h - 1) as f32).clamp(0.0, (src_h - 1) as f32);
let x0 = x.floor() as usize;
let y0 = y.floor() as usize;
let x1 = (x0 + 1).min(src_w as usize - 1);
let y1 = (y0 + 1).min(src_h as usize - 1);
let tx = x - x0 as f32;
let ty = y - y0 as f32;
let at = |px: usize, py: usize| pixels[py * src_w as usize + px];
let blend = |a: u8, b: u8, c: u8, d: u8| -> u8 {
let top = f32::from(a) * (1.0 - tx) + f32::from(b) * tx;
let bottom = f32::from(c) * (1.0 - tx) + f32::from(d) * tx;
(top * (1.0 - ty) + bottom * ty).round().clamp(0.0, 255.0) as u8
};
let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1));
Rgba8Pixel {
r: blend(p00.r, p10.r, p01.r, p11.r),
g: blend(p00.g, p10.g, p01.g, p11.g),
b: blend(p00.b, p10.b, p01.b, p11.b),
a: blend(p00.a, p10.a, p01.a, p11.a),
}
};
for y in 0..height as usize {
let fy = y as f32 / height as f32;
for x in 0..width as usize {
dst[y * width as usize + x] = sample(x as f32 / width as f32, fy);
}
}
out
}
/// 平铺画布尺寸:覆盖整个视口、且是原图尺寸的整数倍(超上限就截断)。
fn tile_canvas_size(source_width: u32, source_height: u32, viewport: (u32, u32)) -> (u32, u32) {
if viewport.0 == 0 || viewport.1 == 0 {
return (source_width, source_height);
}
let repeat_x = viewport.0.div_ceil(source_width).max(1);
let repeat_y = viewport.1.div_ceil(source_height).max(1);
let width = (repeat_x * source_width).min(TILE_MAX_WIDTH);
let height = (repeat_y * source_height).min(TILE_MAX_HEIGHT);
(width.max(source_width), height.max(source_height))
}
/// 按整数格平铺(源图超出画布的部分裁掉)。
fn tile(
source: &SharedPixelBuffer<Rgba8Pixel>,
width: u32,
height: u32,
) -> SharedPixelBuffer<Rgba8Pixel> {
let mut canvas = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
let src_w = source.width() as usize;
let src_h = source.height() as usize;
let src = source.as_slice();
let dst = canvas.make_mut_slice();
for y in 0..height as usize {
let src_row = (y % src_h) * src_w;
let dst_row = y * width as usize;
for x in 0..width as usize {
dst[dst_row + x] = src[src_row + (x % src_w)];
}
}
canvas
}
/// 面积平均降采样(保留宽高比,最窄 1px)。
fn downscale(
source: &SharedPixelBuffer<Rgba8Pixel>,
target_width: u32,
) -> SharedPixelBuffer<Rgba8Pixel> {
let src_w = source.width();
let src_h = source.height();
let target_width = target_width.clamp(1, src_w);
let target_height = ((src_h as u64 * target_width as u64) / src_w as u64).max(1) as u32;
let mut out = SharedPixelBuffer::<Rgba8Pixel>::new(target_width, target_height);
let src = source.as_slice();
let dst = out.make_mut_slice();
for ty in 0..target_height as usize {
// 每个目标像素覆盖的源矩形(按比例分块,块内取平均)
let y0 = ty * src_h as usize / target_height as usize;
let y1 = ((ty + 1) * src_h as usize / target_height as usize).max(y0 + 1);
for tx in 0..target_width as usize {
let x0 = tx * src_w as usize / target_width as usize;
let x1 = ((tx + 1) * src_w as usize / target_width as usize).max(x0 + 1);
let mut sum = [0u32; 4];
let mut count = 0u32;
for y in y0..y1.min(src_h as usize) {
for x in x0..x1.min(src_w as usize) {
let pixel = src[y * src_w as usize + x];
sum[0] += u32::from(pixel.r);
sum[1] += u32::from(pixel.g);
sum[2] += u32::from(pixel.b);
sum[3] += u32::from(pixel.a);
count += 1;
}
}
let count = count.max(1);
dst[ty * target_width as usize + tx] = Rgba8Pixel {
r: (sum[0] / count) as u8,
g: (sum[1] / count) as u8,
b: (sum[2] / count) as u8,
a: (sum[3] / count) as u8,
};
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(width: u32, height: u32, pixel: Rgba8Pixel) -> SharedPixelBuffer<Rgba8Pixel> {
let mut buffer = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
buffer.make_mut_slice().fill(pixel);
buffer
}
#[test]
fn downscale_keeps_aspect_and_averages_area() {
let mut source = SharedPixelBuffer::<Rgba8Pixel>::new(96, 48);
// 左半黑、右半白:降采样后左右两侧应各自保持纯色
for y in 0..48 {
for x in 0..96 {
let value = if x < 48 { 0 } else { 255 };
source.make_mut_slice()[y * 96 + x] = Rgba8Pixel {
r: value,
g: value,
b: value,
a: 255,
};
}
}
let small = downscale(&source, 24);
assert_eq!(small.width(), 24);
assert_eq!(small.height(), 12, "宽高比保持 2:1");
let pixels = small.as_slice();
assert_eq!(pixels[0].r, 0, "左侧仍是黑");
assert_eq!(pixels[23].r, 255, "右侧仍是白");
}
#[test]
fn downscale_never_produces_zero_height() {
let source = solid(
400,
3,
Rgba8Pixel {
r: 1,
g: 2,
b: 3,
a: 255,
},
);
let small = downscale(&source, 2);
assert_eq!(small.width(), 2);
assert!(small.height() >= 1);
}
#[test]
fn tile_canvas_covers_viewport_in_whole_repeats() {
assert_eq!(tile_canvas_size(120, 100, (1180, 760)), (1200, 800));
// 视口未知(取不到窗口尺寸)时退回单块
assert_eq!(tile_canvas_size(120, 100, (0, 0)), (120, 100));
// 超大视口被上限截断
let (width, height) = tile_canvas_size(64, 64, (9000, 9000));
assert!(width <= TILE_MAX_WIDTH && height <= TILE_MAX_HEIGHT);
}
#[test]
fn tile_repeats_source_pixels() {
let source = solid(
2,
2,
Rgba8Pixel {
r: 9,
g: 9,
b: 9,
a: 255,
},
);
let canvas = tile(&source, 5, 3);
assert_eq!(canvas.width(), 5);
assert_eq!(canvas.height(), 3);
assert!(canvas.as_slice().iter().all(|pixel| pixel.r == 9));
}
#[test]
fn prepare_rejects_bad_input() {
assert!(prepare("", BgFill::Cover, (100, 100)).is_none());
assert!(prepare("D:/definitely/not/here.png", BgFill::Cover, (100, 100)).is_none());
}
#[test]
fn box_blur_smooths_a_hard_edge() {
// 左黑右白:模糊后交界处应出现中间灰(硬边被抹平)
let mut source = SharedPixelBuffer::<Rgba8Pixel>::new(9, 3);
for y in 0..3 {
for x in 0..9 {
let value = if x < 4 { 0 } else { 255 };
source.make_mut_slice()[y * 9 + x] = Rgba8Pixel {
r: value,
g: value,
b: value,
a: 255,
};
}
}
let blurred = box_blur(&source, 2);
let middle = blurred.as_slice()[9 + 4]; // 第 1 行(共 9 列)的第 4 个像素
assert!(
middle.r > 20 && middle.r < 235,
"交界处应被抹成中间灰,实际 {}",
middle.r
);
}
#[test]
fn upscale_matches_requested_size_and_interpolates() {
let mut source = SharedPixelBuffer::<Rgba8Pixel>::new(2, 1);
let pixels = source.make_mut_slice();
pixels[0] = Rgba8Pixel {
r: 0,
g: 0,
b: 0,
a: 255,
};
pixels[1] = Rgba8Pixel {
r: 255,
g: 255,
b: 255,
a: 255,
};
let big = upscale(&source, 8, 2);
assert_eq!(big.width(), 8);
assert_eq!(big.height(), 2);
let row = big.as_slice();
// 双线性:中间像素应是灰阶过渡而不是 0/255 二值
assert!(row[3].r > 60 && row[3].r < 200, "实际 {}", row[3].r);
}
}
+352
View File
@@ -0,0 +1,352 @@
//! 回调接线:把 `ui/main.slint` 暴露的每个回调翻译成「状态变更 + AppCommand + 同步」。
//!
//! 逐条列出而不做通用转发,是为了让「用户能做的每一件事」在这里一目了然;
//! 新增交互时只改这一处 + `.slint` 的 callback 声明。
use std::rc::Weak;
use slint::ComponentHandle;
use crate::conv;
use crate::dto::AppCommand;
use crate::router::Route;
use crate::toast::ToastKind;
use crate::ui_bind::Inner;
/// 接全部回调;闭包持弱引用,避免 `MainWindow` 与闭包互相持有导致窗口永不释放。
pub(crate) fn wire(weak: Weak<Inner>) {
let Some(inner) = weak.upgrade() else { return };
// 只在注册期间持有强引用;闭包内一律持弱引用(否则窗口与闭包互相持有、永不释放)。
let ui = &inner.ui;
wire_window(ui, &weak);
wire_navigation(ui, &weak);
wire_instances(ui, &weak);
wire_download(ui, &weak);
wire_logs(ui, &weak);
wire_settings(ui, &weak);
wire_overlays(ui, &weak);
}
fn wire_window(ui: &crate::MainWindow, weak: &Weak<Inner>) {
{
let weak = weak.clone();
ui.on_toggle_maximize(move || {
let Some(this) = weak.upgrade() else { return };
let window = this.ui.window();
window.set_maximized(!window.is_maximized());
});
}
{
let weak = weak.clone();
ui.on_minimize(move || {
if let Some(this) = weak.upgrade() {
this.ui.window().set_minimized(true);
}
});
}
{
let weak = weak.clone();
ui.on_request_close(move || {
if let Some(this) = weak.upgrade() {
let _ = this.ui.hide();
}
let _ = slint::quit_event_loop();
});
}
}
fn wire_navigation(ui: &crate::MainWindow, weak: &Weak<Inner>) {
{
let weak = weak.clone();
ui.on_navigate(move |route| {
let Some(this) = weak.upgrade() else { return };
if let Some(route) = conv::route_from(route) {
this.state.borrow_mut().navigate(route);
this.sync();
}
});
}
{
let weak = weak.clone();
ui.on_toggle_nav(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().toggle_nav();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_toggle_ai(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().toggle_ai();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_expand_ai(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().navigate(Route::Agent);
this.sync();
});
}
{
let weak = weak.clone();
ui.on_dock_ai(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().back();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_toggle_yolo(move || {
let Some(this) = weak.upgrade() else { return };
let enabled = {
let mut state = this.state.borrow_mut();
state.ai.yolo = !state.ai.yolo;
state.ai.yolo
};
this.dispatch(AppCommand::SetYolo(enabled));
this.sync();
});
}
{
let weak = weak.clone();
ui.on_ai_send(move |text| {
let Some(this) = weak.upgrade() else { return };
let text = text.to_string();
if text.trim().is_empty() {
return;
}
// 不做本地回显:user 消息由 host 的 MessageAppended 事件带回(契约 §3)
this.state.borrow_mut().ai.draft.clear();
this.ui.set_ai_draft("".into());
this.ask_agent(&text);
this.sync();
});
}
{
let weak = weak.clone();
ui.on_ai_stop(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().ai.finish_stream();
this.dispatch(AppCommand::StopAgent);
this.sync();
});
}
}
fn wire_instances(ui: &crate::MainWindow, weak: &Weak<Inner>) {
{
let weak = weak.clone();
ui.on_instance_launch(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::LaunchInstance(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
ui.on_instance_configure(move |id| {
let Some(this) = weak.upgrade() else { return };
// 齿轮键进实例详情(G4 占位页),实例级设置后续挂在该页 tab 上
this.state
.borrow_mut()
.navigate(Route::Detail(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
ui.on_instance_stop(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::StopInstance(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
ui.on_instance_open(move |id| {
let Some(this) = weak.upgrade() else { return };
this.state
.borrow_mut()
.navigate(Route::Detail(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
ui.on_new_instance(move || {
let Some(this) = weak.upgrade() else { return };
// G5:打开向导(版本 → 加载器 → 名字 → 确认 → 安装进度)
this.state.borrow_mut().wizard = crate::wizard::WizardState::open();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_instance_view_changed(move |view| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().prefs.instance_view = conv::instance_view_from(view);
this.sync();
});
}
{
let weak = weak.clone();
ui.on_tile_size_changed(move |size| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().prefs.tile_size = conv::tile_size_from(size);
this.sync();
});
}
{
let weak = weak.clone();
ui.on_instance_query_changed(move |query| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().instance_query = query.to_string();
this.sync();
});
}
}
fn wire_download(ui: &crate::MainWindow, weak: &Weak<Inner>) {
{
let weak = weak.clone();
ui.on_download_category_changed(move |category| {
let Some(this) = weak.upgrade() else { return };
let category = conv::version_category(category);
this.state.borrow_mut().prefs.download_category = category;
this.dispatch(AppCommand::ListVersions(category));
this.sync();
});
}
{
let weak = weak.clone();
ui.on_download_install(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::InstallVersion(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
ui.on_installed_only_changed(move |only| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().installed_only = only;
this.sync();
});
}
{
let weak = weak.clone();
ui.on_download_query_changed(move |query| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().download_query = query.to_string();
this.sync();
});
}
{
let weak = weak.clone();
ui.on_ancient_help(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().toasts.push(
ToastKind::Info,
"远古版(classic/alpha/beta)需旧版 Java 与旧启动参数;完整说明页 P1 接入",
);
this.sync();
});
}
}
fn wire_logs(ui: &crate::MainWindow, weak: &Weak<Inner>) {
{
let weak = weak.clone();
ui.on_log_select(move |id| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().selected_session = Some(id.to_string());
this.dispatch(AppCommand::OpenLogSession(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
ui.on_log_ask_ai(move |id| {
let Some(this) = weak.upgrade() else { return };
let question = format!("帮我看下会话 {id} 为什么崩溃");
this.state.borrow_mut().navigate(Route::Agent);
this.ask_agent(&question);
this.sync();
});
}
{
let weak = weak.clone();
ui.on_log_apply_fix(move || {
let Some(this) = weak.upgrade() else { return };
this.state
.borrow_mut()
.toasts
.push(ToastKind::Warn, "一键修复在 P1 接 diagnose 的 fix_action");
this.sync();
});
}
}
fn wire_settings(ui: &crate::MainWindow, weak: &Weak<Inner>) {
// 主题三态 + 外观四件在 appearance_bind.rs(拆文件守 400 行)
crate::appearance_bind::wire(ui, weak);
{
let weak = weak.clone();
ui.on_yolo_changed(move |enabled| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().ai.yolo = enabled;
this.dispatch(AppCommand::SetYolo(enabled));
this.sync();
});
}
{
let weak = weak.clone();
ui.on_mcp_changed(move |_enabled| {
let Some(this) = weak.upgrade() else { return };
this.state
.borrow_mut()
.toasts
.push(ToastKind::Info, "MCP server 开关在 P1 接 agent");
this.sync();
});
}
// P0 只保留入口:设置分组与 AI 端点暂不落状态(P1 接 setting schema)。
ui.on_settings_section_changed(|_section| {});
ui.on_ai_endpoint_changed(|_index| {});
}
fn wire_overlays(ui: &crate::MainWindow, weak: &Weak<Inner>) {
{
let weak = weak.clone();
ui.on_dialog_primary(move || close_current_dialog(&weak));
}
{
let weak = weak.clone();
ui.on_dialog_secondary(move || close_current_dialog(&weak));
}
{
let weak = weak.clone();
ui.on_dialog_dismissed(move || close_current_dialog(&weak));
}
{
let weak = weak.clone();
ui.on_toast_dismissed(move |id| {
let Some(this) = weak.upgrade() else { return };
if let Ok(id) = id.parse::<u64>() {
this.state.borrow_mut().toasts.dismiss(id);
this.sync();
}
});
}
}
fn close_current_dialog(weak: &Weak<Inner>) {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().dialogs.close_current();
this.sync();
}
+264
View File
@@ -0,0 +1,264 @@
//! `PageState` global 的回调接线:账户 / Java / 设置 / 通知 / 向导 / 崩溃导入 / 日志刷新。
//!
//! 与 `callbacks.rs` 同一纪律:每个回调 = 「改状态 + 发 AppCommand + 同步 UI」,一处看全。
//! 用 global 而不是逐层转发属性(见 `ui/page_state.slint` 顶部的取舍说明)。
use std::rc::Weak;
use slint::ComponentHandle;
use crate::conv;
use crate::dto::{AppCommand, CreateRequest, LoaderKind};
use crate::ui_bind::Inner;
/// 接全部页面状态回调。
pub(crate) fn wire_pages(weak: Weak<Inner>) {
let Some(inner) = weak.upgrade() else { return };
let page = inner.ui.global::<crate::PageState<'_>>();
// ── 账户 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_account_add_offline(move |name| {
let name = name.to_string();
if name.trim().is_empty() {
return;
}
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::AddOfflineAccount(name));
this.ui
.global::<crate::PageState<'_>>()
.set_offline_name("".into());
this.sync();
});
}
{
let weak = weak.clone();
page.on_account_remove(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::RemoveAccount(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
page.on_account_switch(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::SwitchAccount(id.to_string()));
this.sync();
});
}
{
let weak = weak.clone();
page.on_msa_begin(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::StartDeviceLogin);
this.sync();
});
}
{
let weak = weak.clone();
page.on_msa_browser_begin(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::StartBrowserLogin);
this.sync();
});
}
{
let weak = weak.clone();
page.on_msa_cancel(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::CancelDeviceLogin);
this.sync();
});
}
// ── Java ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_java_install(move |vendor| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::InstallJava(vendor.to_string()));
this.sync();
});
}
// ── 设置 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_setting_changed(move |key, value| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::SetSetting {
key: key.to_string(),
value: value.to_string(),
});
this.sync();
});
}
{
let weak = weak.clone();
page.on_setting_action(move |action| {
let Some(this) = weak.upgrade() else { return };
let action = action.to_string();
match action.as_str() {
// 清 24h 地区缓存 → 下次下载重新判定
"detect_region" => this.dispatch(AppCommand::SetSetting {
key: "download.action.detect_region".to_string(),
value: String::new(),
}),
// Java 分组的「安装/管理」→ 按回退链装一个托管 JDK
"install_java" => this.dispatch(AppCommand::InstallJava(String::new())),
_ => {}
}
this.sync();
});
}
{
let weak = weak.clone();
page.on_settings_tab_changed(move |tab| {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().settings_tab = tab.to_string();
this.sync();
});
}
// ── 通知 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_notice_dismiss(move |id| {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::DismissNotice(id.to_string()));
this.sync();
});
}
// ── 向导 ──────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_wizard_open(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard = crate::wizard::WizardState::open();
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_close(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard.open = false;
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_select_version(move |version| {
let Some(this) = weak.upgrade() else { return };
this.state
.borrow_mut()
.wizard
.select_version(version.as_ref());
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_select_loader(move |index| {
let Some(this) = weak.upgrade() else { return };
let loader = match index {
1 => LoaderKind::Fabric,
2 => LoaderKind::Quilt,
3 => LoaderKind::Forge,
4 => LoaderKind::NeoForge,
_ => LoaderKind::Vanilla,
};
this.state.borrow_mut().wizard.select_loader(loader);
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_name_changed(move |name| {
let Some(this) = weak.upgrade() else { return };
let mut state = this.state.borrow_mut();
state.wizard.name = name.to_string();
state.wizard.error.clear();
drop(state);
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_next(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard.advance();
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_back(move || {
let Some(this) = weak.upgrade() else { return };
this.state.borrow_mut().wizard.step_back();
this.sync();
});
}
{
let weak = weak.clone();
page.on_wizard_confirm(move || {
let Some(this) = weak.upgrade() else { return };
let request = {
let state = this.state.borrow();
let wizard = &state.wizard;
wizard.ready_to_submit().map(|()| CreateRequest {
version: wizard.version.trim().to_string(),
loader: wizard.loader,
loader_version: wizard.loader_version.trim().to_string(),
name: wizard.name.trim().to_string(),
})
};
match request {
Ok(request) => {
this.dispatch(AppCommand::CreateInstance(request));
this.state.borrow_mut().wizard.submitted();
}
Err(error) => {
this.state.borrow_mut().wizard.error = error;
}
}
this.sync();
});
}
{
let weak = weak.clone();
page.on_install_cancel(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::CancelInstall);
this.sync();
});
}
// ── 日志页 ────────────────────────────────────────────────
{
let weak = weak.clone();
page.on_crash_import(move |path| {
let Some(this) = weak.upgrade() else { return };
let path = path.to_string();
if path.trim().is_empty() {
return;
}
this.dispatch(AppCommand::ImportCrashLog(path));
this.sync();
});
}
{
let weak = weak.clone();
page.on_logs_refresh(move || {
let Some(this) = weak.upgrade() else { return };
this.dispatch(AppCommand::RefreshLogSessions);
this.sync();
});
}
// 设置页分组切换同时刷新一遍分组快照(外部改配置后手点分组即最新)
let _ = conv::setting_groups;
}
+103
View File
@@ -0,0 +1,103 @@
//! AI 页与覆盖层:会话消息、对话框(一次一条)、Toast。
use crate::dialogs as dlg;
use crate::dto::ChatRole as DtoChatRole;
use crate::state::AppState;
use crate::toast as toast_mod;
use crate::{ChatRole, DialogKind, UiChatMessage, UiDialog, UiToast};
/// AI 会话消息。
pub fn chat(state: &AppState) -> Vec<UiChatMessage> {
state
.ai
.messages
.iter()
.map(|message| UiChatMessage {
role: match message.role {
DtoChatRole::User => ChatRole::User,
DtoChatRole::Assistant => ChatRole::Assistant,
DtoChatRole::Tool => ChatRole::Tool,
DtoChatRole::System => ChatRole::System,
},
text: message.text.clone().into(),
tool_name: message
.tool
.as_ref()
.map(|tool| tool.name.clone())
.unwrap_or_default()
.into(),
tool_ok: message.tool.as_ref().is_none_or(|tool| tool.ok),
streaming: message.streaming,
})
.collect()
}
/// 当前对话框(一次一条)+ 是否显示 + 队列剩余条数。
pub fn dialog(state: &AppState) -> (UiDialog, bool, i32) {
let queued = i32::try_from(state.dialogs.pending_len()).unwrap_or(0);
match state.dialogs.current() {
None => (UiDialog::default(), false, queued),
Some(request) => {
let (kind, primary, secondary) = match &request.kind {
dlg::DialogKind::Confirm { primary, secondary } => {
(DialogKind::Confirm, primary.clone(), secondary.clone())
}
dlg::DialogKind::Input { primary, .. } => {
(DialogKind::Input, primary.clone(), String::new())
}
dlg::DialogKind::Progress { .. } => {
(DialogKind::Progress, "取消".to_string(), String::new())
}
dlg::DialogKind::Error { primary, .. } => {
(DialogKind::Error, primary.clone(), "忽略".to_string())
}
};
let progress = match &request.kind {
dlg::DialogKind::Progress { progress, .. } => *progress,
_ => 0.0,
};
let input_label = match &request.kind {
dlg::DialogKind::Input { label, .. } => label.clone(),
_ => String::new(),
};
let input_value = match &request.kind {
dlg::DialogKind::Input { value, .. } => value.clone(),
_ => String::new(),
};
(
UiDialog {
id: request.id.to_string().into(),
kind,
title: request.title.clone().into(),
body: request.body.clone().into(),
primary: primary.into(),
secondary: secondary.into(),
progress,
input_label: input_label.into(),
input_value: input_value.into(),
},
true,
queued,
)
}
}
}
/// Toast 列表。
pub fn toasts(state: &AppState) -> Vec<UiToast> {
state
.toasts
.items()
.iter()
.map(|toast| UiToast {
id: toast.id.to_string().into(),
kind: match toast.kind {
toast_mod::ToastKind::Info => crate::ToastKind::Info,
toast_mod::ToastKind::Success => crate::ToastKind::Success,
toast_mod::ToastKind::Warn => crate::ToastKind::Warn,
toast_mod::ToastKind::Error => crate::ToastKind::Error,
},
text: toast.text.clone().into(),
})
.collect()
}
+163
View File
@@ -0,0 +1,163 @@
//! 实例相关:磁贴外观(相位/进度/徽标/占位色)、运行中列表。
use slint::Color;
use crate::dto::{self, InstanceRuntime};
use crate::state::{AppState, TileCell};
use crate::{LoaderKind, RuntimePhase, UiTile};
/// 头像/图标占位的底色池(Metro 强调色系,按 id 哈希稳定取色)。
const HUES: [u32; 8] = [
0x0078D4, 0x8764B0, 0xD13438, 0xCA5010, 0x498205, 0x038387, 0x5C2D91, 0x0050EF,
];
/// 加载器枚举。
pub fn loader(kind: dto::LoaderKind) -> LoaderKind {
match kind {
dto::LoaderKind::Vanilla => LoaderKind::Vanilla,
dto::LoaderKind::Fabric => LoaderKind::Fabric,
dto::LoaderKind::Quilt => LoaderKind::Quilt,
dto::LoaderKind::Forge => LoaderKind::Forge,
dto::LoaderKind::NeoForge => LoaderKind::Neoforge,
dto::LoaderKind::OptiFine => LoaderKind::Optifine,
dto::LoaderKind::LiteLoader => LoaderKind::Liteloader,
}
}
/// 运行时状态 → (磁贴外观相位, 进度 0~1, 阶段文案)。
pub fn phase(runtime: &InstanceRuntime) -> (RuntimePhase, f32, String) {
match runtime {
InstanceRuntime::Idle => (RuntimePhase::Idle, 0.0, String::new()),
InstanceRuntime::Busy { stage, progress } => {
let phase = match stage {
dto::LaunchStage::Download => RuntimePhase::Downloading,
dto::LaunchStage::Verify => RuntimePhase::Verifying,
dto::LaunchStage::Launch => RuntimePhase::Launching,
dto::LaunchStage::Window => RuntimePhase::Running,
};
(phase, progress.ratio(), stage.label().to_string())
}
InstanceRuntime::Running { .. } => (RuntimePhase::Running, 1.0, "运行中".to_string()),
InstanceRuntime::Failed { reason } => (RuntimePhase::Failed, 0.0, reason.clone()),
}
}
/// 名字首字(无图标时的占位字母)。
pub fn initial_of(name: &str) -> String {
name.chars()
.next()
.map(|c| c.to_string())
.unwrap_or_else(|| "?".to_string())
}
/// id → 稳定底色(同一实例每次启动同色)。
pub fn hue_of(id: &str) -> Color {
let hash = id.bytes().fold(0u32, |acc, byte| {
acc.wrapping_mul(31).wrapping_add(u32::from(byte))
});
Color::from_argb_encoded(0xFF00_0000 | HUES[(hash as usize) % HUES.len()])
}
/// ISO-8601 UTC → 逗号少的本地展示串(`2026-10-29 19:22`);None/空 → 从未启动。
pub fn last_played(raw: &Option<String>) -> String {
match raw {
None => "从未启动".to_string(),
Some(value) if value.is_empty() => "从未启动".to_string(),
Some(value) => {
let trimmed = value.trim_end_matches('Z');
let mut out = String::with_capacity(trimmed.len());
let mut seen_time = false;
for ch in trimmed.chars() {
match ch {
'T' => {
seen_time = true;
out.push(' ');
}
':' if seen_time && out.ends_with(|c: char| c.is_ascii_digit()) => {
// 只保留到分钟:秒(第三段冒号之后)直接截断
if out.matches(':').count() >= 1 {
break;
}
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
}
}
/// 单个磁贴。
pub fn tile(cell: &TileCell<'_>) -> UiTile {
let instance = cell.instance;
let (phase, progress, label) = phase(&instance.runtime);
UiTile {
id: instance.id.clone().into(),
name: instance.name.clone().into(),
mc_version: instance.mc_version.clone().into(),
loader: loader(instance.loader.kind),
loader_version: instance.loader.version.clone().into(),
loader_label: instance.loader.badge().into(),
last_played: last_played(&instance.last_played).into(),
phase,
phase_label: label.into(),
progress,
icon: slint::Image::default(),
hue: hue_of(&instance.id),
initial: initial_of(&instance.name).into(),
row: i32::try_from(cell.row).unwrap_or(0),
col: i32::try_from(cell.col).unwrap_or(0),
}
}
/// 磁贴网格(已按当前尺寸算好 row/col)。
pub fn tiles(state: &AppState) -> Vec<UiTile> {
state.tiles().iter().map(tile).collect()
}
/// 运行中实例(顶部迷你状态条)。
pub fn running_tiles(state: &AppState) -> Vec<UiTile> {
state
.running()
.iter()
.enumerate()
.map(|(index, instance)| {
let cell = TileCell {
instance,
row: 0,
col: index,
};
tile(&cell)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{hue_of, initial_of, last_played};
#[test]
fn initial_is_first_grapheme_or_question_mark() {
assert_eq!(initial_of("生存整合包"), "生");
assert_eq!(initial_of("Vanilla"), "V");
assert_eq!(initial_of(""), "?");
}
#[test]
fn hue_is_stable_per_id() {
assert_eq!(hue_of("survival"), hue_of("survival"));
// 不同 id 大体不同色(哈希池 8 色,这里只验证确定性 + 不 panic)
let _ = hue_of("another-instance");
}
#[test]
fn last_played_formats_iso_to_minutes() {
assert_eq!(
last_played(&Some("2026-10-29T19:22:45Z".to_string())),
"2026-10-29 19:22"
);
assert_eq!(last_played(&None), "从未启动");
assert_eq!(last_played(&Some(String::new())), "从未启动");
}
}
+84
View File
@@ -0,0 +1,84 @@
//! 日志页:会话列表、日志行、D8 诊断卡片。
use crate::dto::{LogLevel as DtoLogLevel, LogOutcome as DtoLogOutcome};
use crate::state::AppState;
use crate::{LogLevel, LogOutcome, UiDiagnosis, UiLogLine, UiLogSession};
/// 日志会话列表。
pub fn log_sessions(state: &AppState) -> Vec<UiLogSession> {
state
.log_sessions
.iter()
.map(|session| UiLogSession {
id: session.id.clone().into(),
when: session.when.clone().into(),
instance: session.instance.clone().into(),
outcome: match session.outcome {
DtoLogOutcome::Ok => LogOutcome::Ok,
DtoLogOutcome::Crashed => LogOutcome::Crashed,
DtoLogOutcome::SilentFail => LogOutcome::SilentFail,
},
duration: duration(session.duration_secs).into(),
})
.collect()
}
/// 秒 → `1h03m` / `62s`。
pub fn duration(secs: u64) -> String {
if secs >= 3_600 {
format!("{}h{:02}m", secs / 3_600, (secs % 3_600) / 60)
} else if secs >= 60 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{secs}s")
}
}
/// 当前会话日志行。
pub fn log_lines(state: &AppState) -> Vec<UiLogLine> {
state
.log_lines
.iter()
.map(|line| UiLogLine {
text: line.text.clone().into(),
level: match line.level {
DtoLogLevel::Debug => LogLevel::Debug,
DtoLogLevel::Info => LogLevel::Info,
DtoLogLevel::Warn => LogLevel::Warn,
DtoLogLevel::Error => LogLevel::Error,
},
})
.collect()
}
/// 诊断卡片内容(无结论时返回默认值 + has=false)。
pub fn diagnosis(state: &AppState) -> (UiDiagnosis, bool) {
match &state.diagnosis {
None => (UiDiagnosis::default(), false),
Some(report) => {
let diagnosis = &report.diagnosis;
(
UiDiagnosis {
rule: diagnosis.rule.clone().into(),
title: diagnosis.title.clone().into(),
confidence: i32::from(diagnosis.confidence),
hint: diagnosis.hint.clone().into(),
has_fix: diagnosis.fix.is_some(),
},
true,
)
}
}
}
#[cfg(test)]
mod tests {
use super::duration;
#[test]
fn duration_is_human_readable() {
assert_eq!(duration(8), "8s");
assert_eq!(duration(62), "1m02s");
assert_eq!(duration(3_780), "1h03m");
}
}
+26
View File
@@ -0,0 +1,26 @@
//! 状态层 → Slint 视图模型的单向转换(DTO 值 → `models.slint` 结构体)。
//!
//! 只做「领域值 → 展示值」的搬运与格式化,不含业务判断;
//! 这样 `ui_bind.rs` 里的同步逻辑就是纯粹的「状态灌进属性」。
//! 按领域拆子模块:instances / versions / logs / agent / ui,全量 `pub use`,
//! 调用方统一写 `crate::conv::tiles(...)` 这类路径。
mod agent;
mod instances;
mod logs;
mod pages;
mod ui;
mod versions;
pub use agent::{chat, dialog, toasts};
pub use instances::{running_tiles, tiles};
pub use logs::{diagnosis, log_lines, log_sessions};
pub use pages::{
accounts, active_account_name, device, install, java_chain, java_runtimes, java_vendors,
notices, setting_groups, wizard, wizard_versions,
};
pub use ui::{
accent_color, bg_fill, bg_fill_from, category_from, instance_view, instance_view_from, route,
route_from, theme_mode, theme_mode_from, tile_size, tile_size_from, version_category,
};
pub use versions::versions;
+300
View File
@@ -0,0 +1,300 @@
//! 状态 → `page_state.slint` 的视图模型(账户 / Java / 安装 / 设置分组 / 通知 / 向导)。
//!
//! 与 `conv/{instances,versions,logs,agent}.rs` 同一套路:只做「领域值 → 展示值」的搬运,
//! 不含业务判断。`ui_bind::sync` 把这些灌进 `PageState` global。
use slint::{ModelRc, SharedString, VecModel};
use crate::dto::{
AccountKind as DtoAccountKind, DeviceLoginPhase, InstallStage as DtoInstallStage,
JavaChainOutcome, NoticeLevel, SettingControl, VersionKind as DtoVersionKind,
};
use crate::state::AppState;
use crate::wizard::WizardStep;
use crate::{
AccountKind, InstallStage, JavaStepOutcome, NoticeKind, SettingControl as UiControl, UiAccount,
UiDeviceCode, UiInstallProgress, UiJavaRuntime, UiJavaStep, UiJavaVendor, UiNotice,
UiSettingGroup, UiSettingRow, UiVersion, UiWizard,
};
fn strings(items: &[String]) -> ModelRc<SharedString> {
ModelRc::new(VecModel::from(
items
.iter()
.map(|item| SharedString::from(item.as_str()))
.collect::<Vec<_>>(),
))
}
/// 账户行。
pub fn accounts(state: &AppState) -> Vec<UiAccount> {
state
.accounts
.iter()
.map(|account| UiAccount {
id: account.id.as_str().into(),
name: account.name.as_str().into(),
kind: match account.kind {
DtoAccountKind::Offline => AccountKind::Offline,
DtoAccountKind::Microsoft => AccountKind::Microsoft,
DtoAccountKind::Authlib => AccountKind::Authlib,
},
kind_label: account.kind.label().into(),
profile_id: account.profile_id.as_str().into(),
login_name: account.login_name.as_str().into(),
created: account.created.as_str().into(),
active: account.active,
})
.collect()
}
/// 当前启动账户的展示名(导航底部头像)。
pub fn active_account_name(state: &AppState) -> String {
state
.accounts
.iter()
.find(|account| account.active)
.map(|account| account.name.clone())
.unwrap_or_else(|| "未登录".to_string())
}
/// 微软登录面板。
pub fn device(state: &AppState) -> UiDeviceCode {
let view = &state.device_login;
UiDeviceCode {
visible: view.phase.visible(),
busy: view.phase.busy(),
user_code: view.user_code.as_str().into(),
verification_uri: view.verification_uri.as_str().into(),
qr_link: view.qr_link.as_str().into(),
expires: i32::try_from(view.expires_in).unwrap_or(i32::MAX),
message: view.message.as_str().into(),
phase_label: match view.phase {
DeviceLoginPhase::Idle => "空闲",
DeviceLoginPhase::Requesting => "申请中",
DeviceLoginPhase::Waiting => "等待确认",
DeviceLoginPhase::Succeeded => "成功",
DeviceLoginPhase::Failed => "失败",
}
.into(),
}
}
/// Java 发行版行。
pub fn java_vendors(state: &AppState) -> Vec<UiJavaVendor> {
state
.java
.vendors
.iter()
.map(|vendor| UiJavaVendor {
id: vendor.id.as_str().into(),
display: vendor.display.as_str().into(),
rank: i32::try_from(vendor.rank).unwrap_or(0),
majors: vendor.majors.as_str().into(),
sources: vendor.sources.join(" · ").into(),
installed: vendor.installed,
})
.collect()
}
/// Java 回退链。
pub fn java_chain(state: &AppState) -> Vec<UiJavaStep> {
state
.java
.chain
.iter()
.map(|step| UiJavaStep {
vendor: step.vendor.as_str().into(),
outcome: match step.outcome {
JavaChainOutcome::Pending => JavaStepOutcome::Pending,
JavaChainOutcome::Skip => JavaStepOutcome::Skip,
JavaChainOutcome::Failed => JavaStepOutcome::Failed,
JavaChainOutcome::Hit => JavaStepOutcome::Hit,
},
outcome_label: step.outcome.label().into(),
detail: step.detail.as_str().into(),
})
.collect()
}
/// Java 本地运行时。
pub fn java_runtimes(state: &AppState) -> Vec<UiJavaRuntime> {
state
.java
.runtimes
.iter()
.map(|runtime| UiJavaRuntime {
binary: runtime.binary.as_str().into(),
version: runtime.version.as_str().into(),
vendor: runtime.vendor.as_str().into(),
origin: runtime.origin.as_str().into(),
})
.collect()
}
/// 安装进度卡片。
pub fn install(state: &AppState) -> UiInstallProgress {
let Some(progress) = &state.install else {
return UiInstallProgress {
visible: false,
stage: InstallStage::Planning,
..UiInstallProgress::default()
};
};
UiInstallProgress {
visible: true,
version: progress.version.as_str().into(),
instance: progress.instance.as_str().into(),
loader: progress.loader.as_str().into(),
stage: match progress.stage {
DtoInstallStage::Planning => InstallStage::Planning,
DtoInstallStage::Downloading => InstallStage::Downloading,
DtoInstallStage::Installing => InstallStage::Installing,
DtoInstallStage::Registering => InstallStage::Registering,
DtoInstallStage::Done => InstallStage::Done,
DtoInstallStage::Failed => InstallStage::Failed,
DtoInstallStage::Cancelled => InstallStage::Cancelled,
},
stage_label: progress.stage.label().into(),
ratio: progress.ratio(),
known: progress.is_known(),
files_label: progress.files_label().into(),
bytes_label: if progress.bytes_total > 0 {
format!(
"{}/{}",
crate::dto::human_bytes(progress.bytes_done),
crate::dto::human_bytes(progress.bytes_total)
)
} else {
String::new()
}
.into(),
speed: progress.speed_label().into(),
eta: progress.eta_label().into(),
message: progress.message.as_str().into(),
terminal: progress.stage.is_terminal(),
failed: progress.stage == DtoInstallStage::Failed,
}
}
/// 设置分组(行 + 控件)。
pub fn setting_groups(state: &AppState) -> Vec<UiSettingGroup> {
state
.settings_groups
.iter()
.map(|group| UiSettingGroup {
id: group.id.as_str().into(),
title: group.title.as_str().into(),
rows: ModelRc::new(VecModel::from(
group
.rows
.iter()
.map(|row| UiSettingRow {
key: row.key.as_str().into(),
label: row.label.as_str().into(),
description: row.description.as_str().into(),
control: match row.control {
SettingControl::Toggle => UiControl::Toggle,
SettingControl::Choice => UiControl::Choice,
SettingControl::Text => UiControl::Text,
SettingControl::ReadOnly => UiControl::Readonly,
SettingControl::Action => UiControl::Action,
},
flag: row.flag,
selected: row.selected,
options: strings(&row.options),
value_text: row.value_text.as_str().into(),
action: row.action.as_str().into(),
})
.collect::<Vec<_>>(),
)),
})
.collect()
}
/// 横幅通知。
pub fn notices(state: &AppState) -> Vec<UiNotice> {
state
.notices
.iter()
.map(|notice| UiNotice {
id: notice.id.as_str().into(),
kind: match notice.level {
NoticeLevel::Info => NoticeKind::Info,
NoticeLevel::Warn => NoticeKind::Warn,
NoticeLevel::Error => NoticeKind::Error,
},
title: notice.title.as_str().into(),
body: notice.body.as_str().into(),
})
.collect()
}
/// 向导状态。
pub fn wizard(state: &AppState) -> UiWizard {
let wizard = &state.wizard;
let step = wizard.step;
let can_next = match step {
WizardStep::Version => !wizard.version.trim().is_empty(),
WizardStep::Name => crate::wizard::validate_name(&wizard.name).is_ok(),
WizardStep::Loader | WizardStep::Confirm => true,
WizardStep::Progress => false,
};
UiWizard {
open: wizard.open,
step: i32::try_from(step.index()).unwrap_or(0),
step_label: step.title().into(),
step_index: i32::try_from(step.index()).unwrap_or(0),
step_total: 5,
version: wizard.version.as_str().into(),
loader: loader_index(wizard.loader),
loader_label: if wizard.loader == crate::dto::LoaderKind::Vanilla {
"原版".to_string()
} else {
format!("{} {}", wizard.loader.label(), wizard.loader_version.trim())
.trim()
.to_string()
}
.into(),
loader_version: wizard.loader_version.as_str().into(),
name: wizard.name.as_str().into(),
error: wizard.error.as_str().into(),
busy: wizard.submitting,
can_next,
}
}
/// 加载器在向导里的单选序号(与 `wizard.slint` 的 LoaderChoice 顺序一致)。
pub fn loader_index(loader: crate::dto::LoaderKind) -> i32 {
match loader {
crate::dto::LoaderKind::Vanilla => 0,
crate::dto::LoaderKind::Fabric => 1,
crate::dto::LoaderKind::Quilt => 2,
crate::dto::LoaderKind::Forge => 3,
crate::dto::LoaderKind::NeoForge => 4,
crate::dto::LoaderKind::OptiFine | crate::dto::LoaderKind::LiteLoader => 0,
}
}
/// 向导里的版本列表(正式版 + 快照;远古版走下载页的显式入口)。
pub fn wizard_versions(state: &AppState) -> Vec<UiVersion> {
state
.versions
.iter()
.filter(|version| !version.kind.is_ancient())
.map(|version| UiVersion {
id: version.id.as_str().into(),
kind: match version.kind {
DtoVersionKind::Release => crate::VersionKind::Release,
_ => crate::VersionKind::Snapshot,
},
released: version.released.as_str().into(),
installed: version.installed,
ancient: false,
})
.collect()
}
#[cfg(test)]
#[path = "pages_tests.rs"]
mod tests;
+172
View File
@@ -0,0 +1,172 @@
//! conv/pages.rs 的单测(拆文件守 400 行上限)。
use super::*;
use crate::dto::{
AccountView, DeviceLoginView, InstallProgressView, JavaPageView, JavaVendorView, NoticeView,
SettingGroupView, SettingRowView, VersionSummary,
};
use crate::wizard::WizardState;
use slint::Model;
#[test]
fn accounts_and_device_login_render() {
let mut state = AppState::new();
state.accounts = vec![AccountView {
id: "account:1".to_string(),
name: "Steve".to_string(),
kind: DtoAccountKind::Offline,
profile_id: "0f8f".to_string(),
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active: true,
}];
state.device_login =
DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
let rows = accounts(&state);
assert_eq!(rows.len(), 1);
assert!(rows[0].active);
assert_eq!(rows[0].kind_label, "离线");
assert_eq!(active_account_name(&state), "Steve");
let device = device(&state);
assert!(device.visible && device.busy);
assert_eq!(device.user_code, "ABCD-EFGH");
assert_eq!(device.phase_label, "等待确认");
}
#[test]
fn browser_login_panel_renders_the_authorization_link() {
let mut state = AppState::new();
state.device_login = DeviceLoginView::browser_waiting(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
300,
None,
);
let device = device(&state);
assert!(device.visible && device.busy);
assert_eq!(device.user_code, "", "授权码流没有用户码");
assert!(device.verification_uri.contains("/authorize?client_id=x"));
assert_eq!(device.qr_link, device.verification_uri);
assert_eq!(device.expires, 300);
}
#[test]
fn install_progress_renders_labels_and_terminal_flags() {
let mut state = AppState::new();
state.install = Some(InstallProgressView {
version: "1.20.4".to_string(),
instance: "生存".to_string(),
loader: "Fabric 0.16.5".to_string(),
stage: DtoInstallStage::Downloading,
files_done: 3,
files_total: 6,
bytes_done: 1024,
bytes_total: 4096,
speed_bps: 2048,
eta_secs: 2,
message: "下载中".to_string(),
});
let ui = install(&state);
assert!(ui.visible && !ui.terminal && !ui.failed);
assert!((ui.ratio - 0.5).abs() < f32::EPSILON);
assert_eq!(ui.files_label, "3/6 文件");
assert_eq!(ui.speed, "2.0 KB/s");
assert_eq!(ui.eta, "剩 2s");
state.install = None;
assert!(!install(&state).visible);
}
#[test]
fn java_chain_and_vendors_map_outcomes() {
let mut state = AppState::new();
state.java = JavaPageView {
vendors: vec![JavaVendorView {
id: "temurin".to_string(),
display: "Eclipse Temurin".to_string(),
rank: 1,
majors: "17/21".to_string(),
sources: vec!["BMCLAPI".to_string(), "GitHub".to_string()],
installed: true,
}],
chain: vec![crate::dto::JavaChainStep {
vendor: "temurin".to_string(),
outcome: JavaChainOutcome::Hit,
detail: "21".to_string(),
}],
..JavaPageView::new()
};
let vendors = java_vendors(&state);
assert_eq!(vendors.len(), 1);
assert_eq!(vendors[0].sources, "BMCLAPI · GitHub");
let chain = java_chain(&state);
assert!(matches!(chain[0].outcome, JavaStepOutcome::Hit));
assert_eq!(chain[0].outcome_label, "命中");
}
#[test]
fn settings_groups_map_controls() {
let mut state = AppState::new();
state.settings_groups = vec![SettingGroupView::new("download", "下载")
.with(SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true))
.with(SettingRowView::choice(
"download.region",
"地区",
"",
&["auto", "cn"],
1,
))];
let groups = setting_groups(&state);
assert_eq!(groups.len(), 1);
let rows = groups[0].rows.clone();
assert_eq!(rows.row_count(), 2);
let first = rows.row_data(0).expect("第一行");
let second = rows.row_data(1).expect("第二行");
assert!(first.flag);
assert!(matches!(second.control, UiControl::Choice));
assert_eq!(second.selected, 1);
}
#[test]
fn notices_and_wizard_render() {
let mut state = AppState::new();
state.notices = vec![NoticeView::new(
"isolation:x",
NoticeLevel::Warn,
"隔离档位已切换",
"两侧都有内容",
)];
let notices = notices(&state);
assert!(matches!(notices[0].kind, NoticeKind::Warn));
state.wizard = WizardState::open();
state.wizard.select_version("1.20.4");
let wizard = wizard(&state);
assert!(wizard.open);
assert_eq!(wizard.step_label, "选择版本");
assert!(wizard.can_next);
assert_eq!(wizard.loader, 0);
}
#[test]
fn wizard_versions_skip_ancient_ones() {
let mut state = AppState::new();
state.versions = vec![
VersionSummary {
id: "1.20.4".to_string(),
kind: DtoVersionKind::Release,
released: "2023-12-07".to_string(),
installed: false,
},
VersionSummary {
id: "rd-132211".to_string(),
kind: DtoVersionKind::Ancient(crate::dto::AncientKind::Classic),
released: "2009-05-13".to_string(),
installed: false,
},
];
let rows = wizard_versions(&state);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "1.20.4");
}
+136
View File
@@ -0,0 +1,136 @@
//! UI 偏好/路由枚举的双向映射(Slint 枚举 ↔ 状态层枚举)。
use crate::dto::{self, VersionCategory};
use crate::prefs;
use crate::router;
use crate::{BgFillKind, DownloadCategory, InstanceView, RouteKind, ThemeMode, TileSize};
/// 路由 → 导航高亮/页面选择。
pub fn route(route: &router::Route) -> RouteKind {
match route {
router::Route::Instances => RouteKind::Instances,
router::Route::Download => RouteKind::Download,
router::Route::Logs => RouteKind::Logs,
router::Route::Settings => RouteKind::Settings,
router::Route::Detail(_) => RouteKind::Detail,
router::Route::Agent => RouteKind::Agent,
}
}
/// Slint 侧导航点击 → 路由(详情页保持当前实例,不在导航里触发)。
pub fn route_from(kind: RouteKind) -> Option<router::Route> {
match kind {
RouteKind::Instances => Some(router::Route::Instances),
RouteKind::Download => Some(router::Route::Download),
RouteKind::Logs => Some(router::Route::Logs),
RouteKind::Settings => Some(router::Route::Settings),
RouteKind::Agent => Some(router::Route::Agent),
RouteKind::Detail => None,
}
}
/// 磁贴尺寸。
pub fn tile_size(size: prefs::TileSize) -> TileSize {
match size {
prefs::TileSize::Small => TileSize::Small,
prefs::TileSize::Medium => TileSize::Medium,
prefs::TileSize::Large => TileSize::Large,
}
}
/// 视图模式。
pub fn instance_view(view: prefs::InstanceView) -> InstanceView {
match view {
prefs::InstanceView::Tiles => InstanceView::Tiles,
prefs::InstanceView::List => InstanceView::List,
}
}
/// 主题三态。
pub fn theme_mode(mode: prefs::ThemeMode) -> ThemeMode {
match mode {
prefs::ThemeMode::Auto => ThemeMode::Auto,
prefs::ThemeMode::Light => ThemeMode::Light,
prefs::ThemeMode::Dark => ThemeMode::Dark,
}
}
/// Slint 主题三态 → 偏好枚举。
pub fn theme_mode_from(mode: ThemeMode) -> prefs::ThemeMode {
match mode {
ThemeMode::Auto => prefs::ThemeMode::Auto,
ThemeMode::Light => prefs::ThemeMode::Light,
ThemeMode::Dark => prefs::ThemeMode::Dark,
}
}
/// Slint 磁贴尺寸 → 偏好枚举。
pub fn tile_size_from(size: TileSize) -> prefs::TileSize {
match size {
TileSize::Small => prefs::TileSize::Small,
TileSize::Medium => prefs::TileSize::Medium,
TileSize::Large => prefs::TileSize::Large,
}
}
/// Slint 视图模式 → 偏好枚举。
pub fn instance_view_from(view: InstanceView) -> prefs::InstanceView {
match view {
InstanceView::Tiles => prefs::InstanceView::Tiles,
InstanceView::List => prefs::InstanceView::List,
}
}
/// 下载分类 → core 侧分类枚举。
pub fn version_category(category: DownloadCategory) -> VersionCategory {
match category {
DownloadCategory::Release => VersionCategory::Release,
DownloadCategory::Snapshot => VersionCategory::Snapshot,
DownloadCategory::AncientClassic => VersionCategory::Ancient(dto::AncientKind::Classic),
DownloadCategory::AncientAlpha => VersionCategory::Ancient(dto::AncientKind::OldAlpha),
DownloadCategory::AncientBeta => VersionCategory::Ancient(dto::AncientKind::OldBeta),
DownloadCategory::AncientUnlisted => VersionCategory::Ancient(dto::AncientKind::Unlisted),
DownloadCategory::Modpack => VersionCategory::Modpack,
DownloadCategory::Mod => VersionCategory::Mod,
}
}
/// 下载分类(偏好 → Slint)——供启动时回填。
pub fn category_from(category: VersionCategory) -> DownloadCategory {
match category {
VersionCategory::Release => DownloadCategory::Release,
VersionCategory::Snapshot => DownloadCategory::Snapshot,
VersionCategory::Ancient(dto::AncientKind::Classic) => DownloadCategory::AncientClassic,
VersionCategory::Ancient(dto::AncientKind::OldAlpha) => DownloadCategory::AncientAlpha,
VersionCategory::Ancient(dto::AncientKind::OldBeta) => DownloadCategory::AncientBeta,
VersionCategory::Ancient(dto::AncientKind::Unlisted) => DownloadCategory::AncientUnlisted,
VersionCategory::Modpack => DownloadCategory::Modpack,
VersionCategory::Mod => DownloadCategory::Mod,
}
}
/// 背景图填充模式(core 侧枚举 → Slint)。
pub fn bg_fill(fill: unlucky_core::setting::appearance::BgFill) -> BgFillKind {
match fill {
unlucky_core::setting::appearance::BgFill::Cover => BgFillKind::Cover,
unlucky_core::setting::appearance::BgFill::Contain => BgFillKind::Contain,
unlucky_core::setting::appearance::BgFill::Tile => BgFillKind::Tile,
}
}
/// Slint 填充模式 → core 侧枚举。
pub fn bg_fill_from(fill: BgFillKind) -> unlucky_core::setting::appearance::BgFill {
match fill {
BgFillKind::Cover => unlucky_core::setting::appearance::BgFill::Cover,
BgFillKind::Contain => unlucky_core::setting::appearance::BgFill::Contain,
BgFillKind::Tile => unlucky_core::setting::appearance::BgFill::Tile,
}
}
/// 强调色十六进制串 → Slint 颜色;空串/非法值给透明(= 用出厂 accent)。
pub fn accent_color(hex: &str) -> slint::Color {
match unlucky_core::setting::appearance::hex_to_rgb(hex) {
Some((r, g, b)) => slint::Color::from_rgb_u8(r, g, b),
None => slint::Color::from_argb_u8(0, 0, 0, 0),
}
}
+103
View File
@@ -0,0 +1,103 @@
//! 版本列表与下载分类映射(下载页)。
use crate::dto::{AncientKind, VersionCategory, VersionKind as DtoVersionKind, VersionSummary};
use crate::state::AppState;
use crate::{UiVersion, VersionKind};
/// 分类侧栏 → 该分类下的版本(远古版按子类细分)。
pub fn in_category(category: VersionCategory, kind: DtoVersionKind) -> bool {
match (category, kind) {
(VersionCategory::Release, DtoVersionKind::Release) => true,
(VersionCategory::Snapshot, DtoVersionKind::Snapshot) => true,
(VersionCategory::Ancient(expected), DtoVersionKind::Ancient(actual)) => expected == actual,
// 未接线的分类(整合包 / Mod)不显示任何版本,而不是把全部版本倒进去
(VersionCategory::Modpack | VersionCategory::Mod, _) => false,
_ => false,
}
}
/// 版本行(下载页与新建向导共用)。
pub fn version_rows(versions: &[VersionSummary]) -> Vec<UiVersion> {
versions
.iter()
.map(|version| UiVersion {
id: version.id.clone().into(),
kind: version_kind(version.kind),
released: version.released.clone().into(),
installed: version.installed,
ancient: version.kind.is_ancient(),
})
.collect()
}
/// 下载页版本行(按当前分类 + 「仅已装」+ 版本号筛选词过滤)。
pub fn versions(state: &AppState) -> Vec<UiVersion> {
let query = state.download_query.trim().to_lowercase();
let filtered: Vec<VersionSummary> = state
.versions
.iter()
.filter(|version| in_category(state.prefs.download_category, version.kind))
.filter(|version| !state.installed_only || version.installed)
.filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query))
.cloned()
.collect();
version_rows(&filtered)
}
fn version_kind(kind: DtoVersionKind) -> VersionKind {
match kind {
DtoVersionKind::Release => VersionKind::Release,
DtoVersionKind::Snapshot => VersionKind::Snapshot,
DtoVersionKind::Ancient(AncientKind::Classic) => VersionKind::AncientClassic,
DtoVersionKind::Ancient(AncientKind::OldAlpha) => VersionKind::AncientAlpha,
DtoVersionKind::Ancient(AncientKind::OldBeta) => VersionKind::AncientBeta,
DtoVersionKind::Ancient(AncientKind::Unlisted) => VersionKind::AncientUnlisted,
}
}
#[cfg(test)]
mod tests {
use super::versions;
use crate::dto::{VersionKind, VersionSummary};
use crate::state::AppState;
fn state_with_versions() -> AppState {
let mut state = AppState::new();
let row = |id: &str, installed: bool| VersionSummary {
id: id.to_string(),
kind: VersionKind::Release,
released: "2026-01-01".to_string(),
installed,
};
state.versions = vec![
row("1.21.9", false),
row("1.20.4", true),
row("1.20.1", true),
];
state
}
#[test]
fn installed_only_filter_hides_uninstalled_versions() {
let mut state = state_with_versions();
assert_eq!(versions(&state).len(), 3);
state.installed_only = true;
let filtered = versions(&state);
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|version| version.installed));
}
#[test]
fn query_filters_by_version_id_substring() {
let mut state = state_with_versions();
state.download_query = "1.20".to_string();
let filtered = versions(&state);
assert_eq!(filtered.len(), 2);
state.download_query = "1.21".to_string();
state.installed_only = true;
// 「仅已装」优先:1.21.9 未安装 → 空列表
assert!(versions(&state).is_empty());
}
}
+305
View File
@@ -0,0 +1,305 @@
//! 对话框队列(HMCL 队列语义 + Metro 化,09 §3)。
//!
//! 语义:**一次只显示一条**。新请求按来源插队(用户操作 > 后台任务 > 提示),同来源 FIFO;
//! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。
//! 非阻塞提示走 [`crate::toast`],不占模态队列。
use serde::{Deserialize, Serialize};
/// 对话框标识。
pub type DialogId = u64;
/// 对话框来源:决定插队优先级。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum DialogSource {
/// 提示类(教程、首次使用说明)
Hint = 0,
/// 后台任务(下载完成、需要选择镜像源)
BackgroundTask = 1,
/// 用户操作触发(AI 敏感写操作的二次确认)
UserAction = 2,
}
impl DialogSource {
/// 数值越大越优先。
pub fn priority(self) -> u8 {
self as u8
}
}
/// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DialogKind {
/// 确认:主按钮 + 次按钮
Confirm {
/// 主按钮文字(如「允许」)
primary: String,
/// 次按钮文字(如「拒绝」)
secondary: String,
},
/// 输入:单行文本
Input {
/// 输入框标签
label: String,
/// 初始值
value: String,
/// 主按钮文字
primary: String,
},
/// 进度:不可点主按钮(任务结束后由 [`crate::dto::AppEvent::DialogClosed`] 收起)
Progress {
/// 阶段文案
label: String,
/// 0.0~1.0
progress: f32,
},
/// 错误:含诊断入口提示
Error {
/// 主按钮文字
primary: String,
/// 诊断/修复提示
hint: Option<String>,
},
}
impl DialogKind {
/// 主按钮文字。
pub fn primary_label(&self) -> &str {
match self {
Self::Confirm { primary, .. }
| Self::Input { primary, .. }
| Self::Error { primary, .. } => primary,
Self::Progress { .. } => "取消",
}
}
/// 次按钮文字(None 表示不显示次按钮)。
pub fn secondary_label(&self) -> Option<&str> {
match self {
Self::Confirm { secondary, .. } => Some(secondary),
Self::Error { .. } => Some("忽略"),
Self::Input { .. } | Self::Progress { .. } => None,
}
}
/// 是否可交互(进度条不可点主按钮)。
pub fn is_blocking_progress(&self) -> bool {
matches!(self, Self::Progress { .. })
}
}
/// 一条对话框请求。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DialogRequest {
/// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号)
pub id: DialogId,
/// 来源(决定插队优先级)
pub source: DialogSource,
/// 标题
pub title: String,
/// 正文
pub body: String,
/// 类型与按钮
pub kind: DialogKind,
}
/// 模态队列:一次一条 + 来源插队。
#[derive(Debug, Default)]
pub struct DialogQueue {
current: Option<DialogRequest>,
pending: Vec<DialogRequest>,
next_id: DialogId,
}
impl DialogQueue {
/// 新建空队列。
pub fn new() -> Self {
Self::default()
}
/// 当前应显示的对话框。
pub fn current(&self) -> Option<&DialogRequest> {
self.current.as_ref()
}
/// 排队中(不含当前)条数。
pub fn pending_len(&self) -> usize {
self.pending.len()
}
/// 是否完全没有对话框(当前 + 排队)。
pub fn is_empty(&self) -> bool {
self.current.is_none() && self.pending.is_empty()
}
/// 入队一条请求(UI 内部调用方用,id 由队列自增分配)。没有当前条时立即成为当前条。
///
/// 返回值用于后续 [`Self::close`] / 进度更新。
pub fn push(
&mut self,
source: DialogSource,
title: impl Into<String>,
body: impl Into<String>,
kind: DialogKind,
) -> DialogId {
self.next_id += 1;
self.push_request(DialogRequest {
id: self.next_id,
source,
title: title.into(),
body: body.into(),
kind,
})
}
/// 入队一条已经组装好的请求(事件通道用)。
///
/// **id 归生产者**:这里原样保留 `request.id`(不重编号),因为关闭事件
/// [`crate::dto::AppEvent::DialogClosed`] 携带的是生产者 id;重编号会让
/// 「任务完成 → 自动收起进度框」在多来源弹框时静默失配。生产者负责自身 id 唯一。
pub fn push_request(&mut self, request: DialogRequest) -> DialogId {
let id = request.id;
let source = request.source;
if self.current.is_none() {
self.current = Some(request);
} else {
let at = self
.pending
.iter()
.position(|queued| queued.source < source)
.unwrap_or(self.pending.len());
self.pending.insert(at, request);
}
id
}
/// 关闭指定对话框:若是当前条则推进到下一条,否则从队列移除。
///
/// 返回 `true` 表示确实移除了。
pub fn close(&mut self, id: DialogId) -> bool {
if self
.current
.as_ref()
.is_some_and(|current| current.id == id)
{
self.advance();
return true;
}
if let Some(at) = self.pending.iter().position(|queued| queued.id == id) {
self.pending.remove(at);
return true;
}
false
}
/// 推进到下一条排队请求(当前条已被处理)。
pub fn advance(&mut self) {
self.current = if self.pending.is_empty() {
None
} else {
Some(self.pending.remove(0))
};
}
/// 关闭当前条并推进到下一条(确认 / 取消 / 点遮罩都走这里)。
///
/// 返回 `false` 表示本来就没有当前条(幂等,双击不会误推进队列)。
pub fn close_current(&mut self) -> bool {
if self.current.is_none() {
return false;
}
self.advance();
true
}
/// 更新进度对话框的进度(任务回调直接喂进来)。
pub fn update_progress(&mut self, id: DialogId, progress: f32) {
let Some(current) = &mut self.current else {
return;
};
if current.id != id {
return;
}
if let DialogKind::Progress {
progress: value, ..
} = &mut current.kind
{
*value = progress.clamp(0.0, 1.0);
}
}
}
#[cfg(test)]
mod tests {
use super::{DialogKind, DialogQueue, DialogSource};
fn confirm() -> DialogKind {
DialogKind::Confirm {
primary: "确定".into(),
secondary: "取消".into(),
}
}
#[test]
fn shows_one_dialog_at_a_time() {
let mut queue = DialogQueue::new();
let first = queue.push(DialogSource::BackgroundTask, "A", "", confirm());
let second = queue.push(DialogSource::BackgroundTask, "B", "", confirm());
assert_eq!(queue.current().map(|d| d.id), Some(first));
assert_eq!(queue.pending_len(), 1);
queue.advance();
assert_eq!(queue.current().map(|d| d.id), Some(second));
assert_eq!(queue.pending_len(), 0);
queue.advance();
assert!(queue.is_empty());
}
#[test]
fn user_action_jumps_the_queue() {
let mut queue = DialogQueue::new();
queue.push(DialogSource::Hint, "提示", "", confirm());
queue.push(DialogSource::Hint, "提示2", "", confirm());
queue.push(DialogSource::BackgroundTask, "后台", "", confirm());
let urgent = queue.push(DialogSource::UserAction, "确认删除", "", confirm());
// 当前仍是队首(一次一条),但排队里用户操作插到最前 → 关掉当前条后立刻轮到它
queue.advance();
assert_eq!(queue.current().map(|d| d.id), Some(urgent));
}
#[test]
fn closing_non_current_dialog_removes_it_from_pending() {
let mut queue = DialogQueue::new();
let first = queue.push(DialogSource::Hint, "A", "", confirm());
let second = queue.push(DialogSource::Hint, "B", "", confirm());
assert!(queue.close(second));
assert_eq!(queue.pending_len(), 0);
assert_eq!(queue.current().map(|d| d.id), Some(first));
assert!(!queue.close(999));
}
#[test]
fn progress_dialog_can_be_updated() {
let mut queue = DialogQueue::new();
let id = queue.push(
DialogSource::BackgroundTask,
"下载中",
"",
DialogKind::Progress {
label: "资源".into(),
progress: 0.0,
},
);
queue.update_progress(id, 1.7);
match queue.current().map(|d| &d.kind) {
Some(DialogKind::Progress { progress, .. }) => {
assert!((*progress - 1.0).abs() < f32::EPSILON);
}
other => panic!("unexpected dialog kind: {other:?}"),
}
}
}
+270
View File
@@ -0,0 +1,270 @@
//! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。
//!
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
//! host 侧在拿到账户快照的那一刻就丢掉凭据,只留「能显示什么」。
use serde::{Deserialize, Serialize};
/// 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AccountKind {
/// 离线账户(HMCL 兼容 UUID)
Offline,
/// 微软正版
Microsoft,
/// authlib-injector(第三方皮肤站)
Authlib,
}
impl AccountKind {
/// 徽标文字。
pub fn label(self) -> &'static str {
match self {
Self::Offline => "离线",
Self::Microsoft => "微软",
Self::Authlib => "第三方",
}
}
/// 稳定 id(事件/持久化用;与 core 的 `kind.as_str()` 同词表)。
pub fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
Self::Microsoft => "microsoft",
Self::Authlib => "authlib_injector",
}
}
/// 从 core 的稳定 id 解析(未知轨道按离线处理,不让一个坏条目毁掉整个列表)。
pub fn parse(raw: &str) -> Self {
match raw {
"microsoft" => Self::Microsoft,
"authlib_injector" => Self::Authlib,
_ => Self::Offline,
}
}
}
/// 账户列表行(**只含可公开字段**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountView {
/// 稳定 id(`account:...`)
pub id: String,
/// 展示名
pub name: String,
/// 轨道
pub kind: AccountKind,
/// 档案 UUID(公开)
pub profile_id: String,
/// 登录名(微软轨才有;可空)
pub login_name: String,
/// 创建时间展示串(`2026-10-31 20:12`)
pub created: String,
/// 是否是当前启动使用的账户
pub active: bool,
}
/// 微软登录阶段(UI 据此切换面板;两条流共用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeviceLoginPhase {
/// 没有登录在进行
Idle,
/// 正在向微软申请设备码(短暂)
Requesting,
/// 已拿到用户码,等待用户在浏览器完成
Waiting,
/// 成功(账户已入库)
Succeeded,
/// 失败(原因在 `message`,已脱敏)
Failed,
}
impl DeviceLoginPhase {
/// 面板是否要显示(非 Idle 都显示)。
pub fn visible(self) -> bool {
!matches!(self, Self::Idle)
}
/// 是否还在跑(Requesting / Waiting)。
pub fn busy(self) -> bool {
matches!(self, Self::Requesting | Self::Waiting)
}
}
/// 设备码登录的可见状态。
///
/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`——
/// 它们本来就是给用户看的,不是凭据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceLoginView {
/// 阶段
pub phase: DeviceLoginPhase,
/// 用户码(`ABCD-EFGH`)
pub user_code: String,
/// 验证地址(`https://www.microsoft.com/link`)
pub verification_uri: String,
/// 二维码内容(= 带码的链接;GUI 用文本展示,终端可直接打开)
pub qr_link: String,
/// 有效期(秒;0 = 未知)
pub expires_in: u64,
/// 状态/错误文案
pub message: String,
/// 成功后入库的账户展示名
pub account_name: String,
}
impl Default for DeviceLoginView {
fn default() -> Self {
Self::new()
}
}
impl DeviceLoginView {
/// 空闲态。
pub fn new() -> Self {
Self {
phase: DeviceLoginPhase::Idle,
user_code: String::new(),
verification_uri: String::new(),
qr_link: String::new(),
expires_in: 0,
message: String::new(),
account_name: String::new(),
}
}
/// 已拿到用户码(`present` 回调时刻)。
pub fn waiting(
user_code: &str,
verification_uri: &str,
expires_in: u64,
qr_hint: &str,
) -> Self {
let qr_link = if qr_hint.is_empty() {
format!("{verification_uri}?code={user_code}")
} else {
qr_hint.to_string()
};
Self {
phase: DeviceLoginPhase::Waiting,
user_code: user_code.to_string(),
verification_uri: verification_uri.to_string(),
qr_link,
expires_in,
message: format!(
"在浏览器打开 {verification_uri} 并输入用户码 {user_code}({} 秒内有效)",
expires_in
),
account_name: String::new(),
}
}
/// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。
///
/// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/
/// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。
pub fn browser_waiting(
authorization_url: &str,
expires_in: u64,
warning: Option<&str>,
) -> Self {
let mut message = format!(
"已在系统浏览器打开微软登录页,请完成授权({} 秒内有效);没弹出就把下面的链接粘到浏览器里",
expires_in.max(1)
);
if let Some(warning) = warning {
message.push_str(&format!("({warning})"));
}
Self {
phase: DeviceLoginPhase::Waiting,
user_code: String::new(),
verification_uri: authorization_url.to_string(),
qr_link: authorization_url.to_string(),
expires_in,
message,
account_name: String::new(),
}
}
/// 失败态。
pub fn failed(message: impl Into<String>) -> Self {
Self {
phase: DeviceLoginPhase::Failed,
message: message.into(),
..Self::new()
}
}
/// 成功态。
pub fn succeeded(account_name: impl Into<String>) -> Self {
let account_name = account_name.into();
Self {
phase: DeviceLoginPhase::Succeeded,
message: format!("已登录:{account_name}"),
account_name,
..Self::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_round_trips_through_stable_ids() {
for kind in [
AccountKind::Offline,
AccountKind::Microsoft,
AccountKind::Authlib,
] {
assert_eq!(AccountKind::parse(kind.as_str()), kind);
assert!(!kind.label().is_empty());
}
}
#[test]
fn device_login_view_never_carries_tokens() {
let view = DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert!(view.phase.busy());
assert!(view.qr_link.contains("ABCD-EFGH"));
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["token", "secret", "password", "refresh"] {
assert!(
!json.to_lowercase().contains(forbidden),
"视图里不该出现 {forbidden}"
);
}
}
#[test]
fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() {
let view = DeviceLoginView::browser_waiting(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
300,
Some("拉起浏览器失败"),
);
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
assert!(view.phase.busy());
assert!(view.user_code.is_empty(), "授权码流没有用户码");
assert_eq!(view.qr_link, view.verification_uri);
assert!(view.message.contains("浏览器"));
assert!(view.message.contains("拉起浏览器失败"), "{}", view.message);
let json = serde_json::to_string(&view).expect("序列化");
for forbidden in ["refresh", "mc-access"] {
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
}
}
#[test]
fn succeeded_and_failed_clear_the_code() {
let ok = DeviceLoginView::succeeded("Steve");
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
assert!(ok.user_code.is_empty());
let bad = DeviceLoginView::failed("网络超时");
assert_eq!(bad.phase, DeviceLoginPhase::Failed);
assert_eq!(bad.message, "网络超时");
}
}
+97
View File
@@ -0,0 +1,97 @@
//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。
use serde::{Deserialize, Serialize};
/// 会话消息角色。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChatRole {
/// 用户
User,
/// 助手(可流式)
Assistant,
/// 工具调用(会话里可见,D4 要求)
Tool,
/// 系统提示(弱化展示)
System,
}
/// 工具调用可视化条目。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCallView {
/// 工具名(与 ToolSpec 注册表同名)
pub name: String,
/// 是否成功
pub ok: bool,
/// 一行结果摘要
pub summary: String,
}
/// 一条会话消息。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage {
/// 角色
pub role: ChatRole,
/// 正文(流式时持续追加)
pub text: String,
/// role == Tool 时带上工具调用详情
pub tool: Option<ToolCallView>,
/// 流式未收敛
pub streaming: bool,
}
impl ChatMessage {
/// 用户消息。
pub fn user(text: impl Into<String>) -> Self {
Self {
role: ChatRole::User,
text: text.into(),
tool: None,
streaming: false,
}
}
/// 助手流式消息起点(空文本 + streaming)。
pub fn assistant_stream() -> Self {
Self {
role: ChatRole::Assistant,
text: String::new(),
tool: None,
streaming: true,
}
}
}
/// agent 侧事件。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentEvent {
/// 追加一条完整消息
MessageAppended(ChatMessage),
/// 给指定序号的流式消息追加增量
MessageDelta {
/// 目标消息在会话里的序号
index: usize,
/// 增量文本
delta: String,
},
/// 流式收敛
MessageFinished {
/// 目标消息序号
index: usize,
},
/// 开始一次工具调用(会话里插入工具条目)
ToolCallStarted {
/// 工具名
name: String,
/// 调用参数摘要
summary: String,
},
/// 工具调用结束
ToolCallFinished {
/// 工具名
name: String,
/// 是否成功
ok: bool,
/// 结果摘要
summary: String,
},
}
+237
View File
@@ -0,0 +1,237 @@
//! 事件与指令:core/agent → app 的事件队列,app → core/agent 的用户意图。
//!
//! 三条通道(01c §5):①不可变快照发布(`*Changed` 整体替换)②事件流(`LogAppended` /
//! `InstallProgress` / `DeviceLoginChanged`)③注入的显示服务(先落成事件,见 `DialogRequested`)。
//! 全部类型 `Serialize + Deserialize`:这是 IPC 契约(AGENTS §6)。
use serde::{Deserialize, Serialize};
use crate::dialogs::{DialogId, DialogRequest};
use crate::dto::CreateRequest;
use crate::dto::{
AccountView, AgentEvent, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, InstanceSummary, JavaPageView, LogLine, LogOutcome, LogSession, NoticeView,
SessionId, SettingGroupView, VersionCategory, VersionSummary,
};
use crate::toast::Toast;
/// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LiveLogBatch {
/// 会话 id
pub session: SessionId,
/// 新增行
pub lines: Vec<LogLine>,
/// 会话是否还在跑(false = 收尾,可以触发诊断/刷新列表)
pub running: bool,
}
/// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFinished {
/// 实例 id
pub id: InstanceId,
/// 会话 id
pub session: SessionId,
/// 结局
pub outcome: LogOutcome,
/// 一句人读结论(`正常退出` / `崩溃(exit 1)` / `启动失败:…`)
pub summary: String,
}
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CoreEvent {
/// 实例列表整体刷新
InstancesChanged(Vec<InstanceSummary>),
/// 单个实例运行时状态变化(启动进度/运行中/失败)
InstanceRuntimeChanged {
/// 实例 id
id: InstanceId,
/// 新状态
runtime: InstanceRuntime,
},
/// 下载页版本列表刷新
VersionListChanged(Vec<VersionSummary>),
/// 日志会话列表刷新
LogSessionsChanged(Vec<LogSession>),
/// 当前会话日志行刷新(整批替换)
LogLinesChanged(Vec<LogLine>),
/// 实时日志增量(启动中尾随 JSONL;`running=false` 表示会话收尾)
LogAppended(LiveLogBatch),
/// 启动收尾(磁贴状态回写 + toast)
LaunchFinished(LaunchFinished),
/// 诊断结论产出
DiagnosisReady(DiagnosisReport),
/// 账户列表刷新
AccountsChanged(Vec<AccountView>),
/// 微软登录状态刷新
DeviceLoginChanged(DeviceLoginView),
/// Java 页快照(发行版/回退链/运行时)
JavaChanged(JavaPageView),
/// 安装进度(`None` = 清掉进度面板)
InstallProgress(Option<InstallProgressView>),
/// 设置分组刷新(设置页右栏)
SettingsChanged(Vec<SettingGroupView>),
/// 横幅通知刷新(隔离切换提示/启动失败等)
NoticesChanged(Vec<NoticeView>),
}
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
/// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AppEvent {
/// 来自 core
Core(CoreEvent),
/// 来自 agent
Agent(AgentEvent),
/// core/agent 请求弹对话框(会进队列,一次一条)
DialogRequested(DialogRequest),
/// 关闭指定对话框(任务完成收起进度条)
DialogClosed(DialogId),
/// 右下角 Toast
Toast(Toast),
}
/// app → core/agent 的用户意图。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AppCommand {
/// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
RefreshAll,
/// 启动实例
LaunchInstance(InstanceId),
/// 停止实例(含取消启动中的管线)
StopInstance(InstanceId),
/// 拉取某分类的版本列表
ListVersions(VersionCategory),
/// 安装指定版本(下载页行内「安装」按钮;原版)
InstallVersion(String),
/// 取消安装(阶段边界生效:当前阶段跑完就停,不再进下一阶段)
CancelInstall,
/// 新建实例(向导提交:版本 + 加载器 + 名字)
CreateInstance(CreateRequest),
/// 修改实例隔离档位(写实例设置文件)
SetIsolation {
/// 实例 id
id: InstanceId,
/// `isolated` / `shared`
mode: String,
},
/// 刷新日志会话列表
RefreshLogSessions,
/// 打开某个会话(拉取日志行 + 触发诊断)
OpenLogSession(SessionId),
/// 导入崩溃日志文件做规则分析(`hs_err_pid*.log` / `latest.log`)
ImportCrashLog(String),
/// 加一个离线账户
AddOfflineAccount(String),
/// 删除账户(按 id 或名字)
RemoveAccount(String),
/// 切换当前启动账户
SwitchAccount(String),
/// 开始微软设备码登录(CLI/无浏览器场景、备用通道)
StartDeviceLogin,
/// 开始微软授权码登录(拉起系统默认浏览器 + 本地回调,GUI 的默认入口)
StartBrowserLogin,
/// 取消/关掉微软登录面板(已发出的请求会在收尾时丢弃结果)
CancelDeviceLogin,
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
InstallJava(String),
/// 写一条设置(`key` 必须是 core schema 里的键)
SetSetting {
/// schema 键
key: String,
/// 新值(字符串形态;host 按 schema 类型转换)
value: String,
},
/// 关掉一条横幅通知
DismissNotice(String),
/// 向 AI 提问(会话状态在 app 侧,agent 只回事件)
AskAgent(String),
/// 中断当前 AI 流
StopAgent,
/// YOLO 开关(跳过确认,审计照记)
SetYolo(bool),
}
impl AppCommand {
/// 构造「切换隔离档位」指令(磁贴/详情页共用)。
pub fn set_isolation(id: &str, mode: &str) -> Self {
Self::SetIsolation {
id: id.to_string(),
mode: mode.to_string(),
}
}
}
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
///
/// 轮询模型(而非回调)是为了让 Slint 的定时器成为唯一调度者,
/// 避免跨线程回调里碰 Slint 模型。
pub trait Backend {
/// 取走自上次调用以来积压的事件(FIFO,不可丢序号)。
fn drain_events(&mut self) -> Vec<AppEvent>;
/// 下发一条用户意图。
fn dispatch(&mut self, command: AppCommand);
/// 时间推进(真实后端在这里收后台线程的结果并尾随日志)。
fn tick(&mut self, elapsed: std::time::Duration) {
let _ = elapsed;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage};
#[test]
fn events_are_serializable_ipc_contracts() {
let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView {
stage: InstallStage::Downloading,
files_done: 3,
files_total: 9,
..InstallProgressView::new()
})));
let text = serde_json::to_string(&event).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, event);
let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView {
id: "account:1".to_string(),
name: "Steve".to_string(),
kind: AccountKind::Offline,
profile_id: "0f8f".to_string(),
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active: true,
}]));
let text = serde_json::to_string(&account).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, account);
// 凭据永不跨界:序列化文本里没有 token 类字段
assert!(!text.to_lowercase().contains("token"));
}
#[test]
fn commands_round_trip() {
let command = AppCommand::CreateInstance(CreateRequest {
version: "1.20.4".to_string(),
loader: crate::dto::LoaderKind::Fabric,
loader_version: "0.16.5".to_string(),
name: "生存".to_string(),
});
let text = serde_json::to_string(&command).expect("序列化");
let back: AppCommand = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, command);
let login = AppCommand::StartDeviceLogin;
assert_eq!(
serde_json::from_str::<AppCommand>(&serde_json::to_string(&login).expect("序列化"))
.expect("反序列化"),
login
);
assert!(DeviceLoginPhase::Waiting.busy());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! 安装进度 DTO:下载页与新建向导共用的「一个安装任务」视图。
//!
//! 只放聚合量(文件计数 / 字节 / 速度 / 剩余时间)+ 一行状态文案:
//! 逐文件的明细留在 core 的 `InstallReport`,UI 不需要也不该持有。
//! 速度与 ETA 由 host 依据相邻两次字节数差分算(core 的事件只给绝对值)。
use serde::{Deserialize, Serialize};
/// 安装阶段。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InstallStage {
/// 解析版本清单 / 规划文件(联网取 metadata)
Planning,
/// 下载并校验文件
Downloading,
/// 落版本 JSON / 跑安装器
Installing,
/// 登记实例
Registering,
/// 完成
Done,
/// 失败
Failed,
/// 用户取消
Cancelled,
}
impl InstallStage {
/// 阶段文案。
pub fn label(self) -> &'static str {
match self {
Self::Planning => "解析版本",
Self::Downloading => "下载文件",
Self::Installing => "生成版本",
Self::Registering => "登记实例",
Self::Done => "已完成",
Self::Failed => "失败",
Self::Cancelled => "已取消",
}
}
/// 是否已经收尾(Done/Failed/Cancelled)。
pub fn is_terminal(self) -> bool {
matches!(self, Self::Done | Self::Failed | Self::Cancelled)
}
}
/// 一次安装的进度快照。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallProgressView {
/// 目标版本 id
pub version: String,
/// 实例名(默认 = 版本 id)
pub instance: String,
/// 加载器徽标文字(`原版` / `Fabric 0.16.5`)
pub loader: String,
/// 阶段
pub stage: InstallStage,
/// 已完成文件数
pub files_done: u64,
/// 文件总数
pub files_total: u64,
/// 已下载字节
pub bytes_done: u64,
/// 总字节(0 = 未知)
pub bytes_total: u64,
/// 当前速度(字节/秒;0 = 未知)
pub speed_bps: u64,
/// 剩余秒数(0 = 未知)
pub eta_secs: u64,
/// 状态/错误文案
pub message: String,
}
impl Default for InstallProgressView {
fn default() -> Self {
Self::new()
}
}
impl InstallProgressView {
/// 规划中的空快照。
pub fn new() -> Self {
Self {
version: String::new(),
instance: String::new(),
loader: String::new(),
stage: InstallStage::Planning,
files_done: 0,
files_total: 0,
bytes_done: 0,
bytes_total: 0,
speed_bps: 0,
eta_secs: 0,
message: String::new(),
}
}
/// 起一个任务的快照。
pub fn start(version: &str, instance: &str, loader: &str) -> Self {
Self {
version: version.to_string(),
instance: instance.to_string(),
loader: loader.to_string(),
stage: InstallStage::Planning,
message: "正在解析版本清单…".to_string(),
..Self::new()
}
}
/// 整体进度 0.0~1.0(优先文件计数,其次字节;都没有时 0)。
pub fn ratio(&self) -> f32 {
if self.files_total > 0 {
(self.files_done as f32 / self.files_total as f32).clamp(0.0, 1.0)
} else if self.bytes_total > 0 {
(self.bytes_done as f32 / self.bytes_total as f32).clamp(0.0, 1.0)
} else {
0.0
}
}
/// 进度是否已知(未知时进度条转不确定态)。
pub fn is_known(&self) -> bool {
self.files_total > 0 || self.bytes_total > 0
}
/// 速度文案(`1.2 MB/s`;未知给空串)。
pub fn speed_label(&self) -> String {
if self.speed_bps == 0 {
return String::new();
}
format!("{}/s", human_bytes(self.speed_bps))
}
/// 剩余时间文案(`剩 12s`;未知给空串)。
pub fn eta_label(&self) -> String {
if self.eta_secs == 0 {
return String::new();
}
if self.eta_secs >= 60 {
format!("剩 {}m{:02}s", self.eta_secs / 60, self.eta_secs % 60)
} else {
format!("剩 {}s", self.eta_secs)
}
}
/// 文件计数文案(`128/512 文件`)。
pub fn files_label(&self) -> String {
if self.files_total == 0 {
return String::new();
}
format!("{}/{} 文件", self.files_done, self.files_total)
}
}
/// 字节数 → 人读(`1.2 MB`)。
pub fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
/// 用相邻两次采样算速度与 ETA(`None` = 本次无法计算,保持上次值)。
pub fn derive_speed(
previous: (u64, std::time::Instant),
current: (u64, std::time::Instant),
bytes_total: u64,
) -> Option<(u64, u64)> {
let elapsed = current.1.saturating_duration_since(previous.1);
if elapsed.as_millis() < 50 {
return None;
}
let delta = current.0.saturating_sub(previous.0);
if delta == 0 {
return None;
}
let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
let speed = delta.saturating_mul(1000) / elapsed_ms.max(1);
let eta = if bytes_total > current.0 && speed > 0 {
(bytes_total - current.0) / speed
} else {
0
};
Some((speed, eta))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn ratio_prefers_file_counts() {
let mut view = InstallProgressView::start("1.20.4", "1.20.4", "原版");
assert_eq!(view.ratio(), 0.0);
assert!(!view.is_known());
view.files_total = 4;
view.files_done = 1;
view.bytes_total = 1000;
view.bytes_done = 900;
assert!((view.ratio() - 0.25).abs() < f32::EPSILON);
assert!(view.is_known());
assert_eq!(view.files_label(), "1/4 文件");
}
#[test]
fn speed_and_eta_labels_are_human_readable() {
let view = InstallProgressView {
speed_bps: 1_258_291,
eta_secs: 75,
..InstallProgressView::new()
};
assert_eq!(view.speed_label(), "1.2 MB/s");
assert_eq!(view.eta_label(), "剩 1m15s");
assert_eq!(InstallProgressView::new().speed_label(), "");
}
#[test]
fn derive_speed_needs_a_measurable_delta() {
let start = Instant::now();
let previous = (0, start);
// 间隔太短 → 不算
assert!(derive_speed(previous, (500, start + Duration::from_millis(10)), 1000).is_none());
// 正常差分:1s 下 500 字节 → 500 B/s,剩 500 字节 → 1s
let (speed, eta) =
derive_speed(previous, (500, start + Duration::from_secs(1)), 1000).expect("应能算");
assert_eq!(speed, 500);
assert_eq!(eta, 1);
}
#[test]
fn terminal_stages() {
assert!(InstallStage::Done.is_terminal());
assert!(InstallStage::Failed.is_terminal());
assert!(!InstallStage::Downloading.is_terminal());
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(2048), "2.0 KB");
}
}
+296
View File
@@ -0,0 +1,296 @@
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
pub type InstanceId = String;
/// 加载器种类(磁贴徽标 + 详情页版本链)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LoaderKind {
/// 原版(无加载器)
Vanilla,
/// Fabric
Fabric,
/// Quilt
Quilt,
/// Forge
Forge,
/// NeoForge
NeoForge,
/// OptiFine
OptiFine,
/// LiteLoader
LiteLoader,
}
impl LoaderKind {
/// 徽标显示文字。
pub fn label(self) -> &'static str {
match self {
Self::Vanilla => "原版",
Self::Fabric => "Fabric",
Self::Quilt => "Quilt",
Self::Forge => "Forge",
Self::NeoForge => "NeoForge",
Self::OptiFine => "OptiFine",
Self::LiteLoader => "LiteLoader",
}
}
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 CLI 的 `--loader` 同词表)。
pub fn as_str(self) -> &'static str {
match self {
Self::Vanilla => "vanilla",
Self::Fabric => "fabric",
Self::Quilt => "quilt",
Self::Forge => "forge",
Self::NeoForge => "neoforge",
Self::OptiFine => "optifine",
Self::LiteLoader => "liteloader",
}
}
/// 从稳定 id 解析(未知给 `None`,由调用方决定报错文案)。
pub fn parse(raw: &str) -> Option<Self> {
Some(match raw.trim().to_ascii_lowercase().as_str() {
"vanilla" | "none" | "" => Self::Vanilla,
"fabric" => Self::Fabric,
"quilt" => Self::Quilt,
"forge" => Self::Forge,
"neoforge" => Self::NeoForge,
"optifine" => Self::OptiFine,
"liteloader" => Self::LiteLoader,
_ => return None,
})
}
/// 向导里的可选加载器(顺序即展示顺序)。
pub fn wizard_choices() -> [Self; 5] {
[
Self::Vanilla,
Self::Fabric,
Self::Quilt,
Self::Forge,
Self::NeoForge,
]
}
}
/// 加载器 + 版本(例如 Fabric 0.16.5)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoaderInfo {
/// 加载器种类
pub kind: LoaderKind,
/// 加载器版本号;原版为空串
pub version: String,
}
impl Default for LoaderInfo {
fn default() -> Self {
Self {
kind: LoaderKind::Vanilla,
version: String::new(),
}
}
}
impl LoaderInfo {
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
pub fn badge(&self) -> String {
if self.kind == LoaderKind::Vanilla || self.version.is_empty() {
self.kind.label().to_string()
} else {
format!("{} {}", self.kind.label(), self.version)
}
}
}
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Progress {
/// 已完成量
pub done: u64,
/// 总量;0 = 未知
pub total: u64,
}
impl Progress {
/// 0.0~1.0;未知总量返回 0.0。
pub fn ratio(self) -> f32 {
if self.total == 0 {
0.0
} else {
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
}
}
/// 总量是否已知。
pub fn is_known(self) -> bool {
self.total > 0
}
}
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LaunchStage {
/// 下载资源
Download,
/// 校验文件
Verify,
/// 组装命令行并拉起进程
Launch,
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
Window,
}
impl LaunchStage {
/// 磁贴上的阶段文案。
pub fn label(self) -> &'static str {
match self {
Self::Download => "下载资源",
Self::Verify => "校验文件",
Self::Launch => "组装命令行",
Self::Window => "等待窗口",
}
}
}
/// 实例运行时状态(磁贴的四种外观)。
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum InstanceRuntime {
/// 空闲
#[default]
Idle,
/// 启动管线进行中
Busy {
/// 当前阶段
stage: LaunchStage,
/// 该阶段进度
progress: Progress,
},
/// 游戏进程存活
Running {
/// 进程号(停止/诊断用)
pid: u32,
},
/// 上次启动失败(磁贴显示「重试」)
Failed {
/// 失败原因(用户可见文案)
reason: String,
},
}
/// 实例摘要:磁贴/列表视图唯一数据源。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InstanceSummary {
/// 稳定 id
pub id: InstanceId,
/// 显示名
pub name: String,
/// 游戏版本(如 1.20.4)
pub mc_version: String,
/// 加载器信息
pub loader: LoaderInfo,
/// ISO-8601 UTC 最后游玩时间;None = 从未启动
pub last_played: Option<String>,
/// 运行时状态
pub runtime: InstanceRuntime,
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
pub icon: Option<PathBuf>,
/// 隔离档位(`isolated` / `shared` / `custom`;未知为空)
pub isolation: String,
/// 该实例绑定的版本 id(详情页/启动用)
pub version_id: String,
/// 版本本体是否在盘上(缺载荷时磁贴亮黄条)
pub version_present: bool,
}
/// 新建实例向导提交给 host 的请求(已通过向导校验)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateRequest {
/// 目标原版版本 id(`1.20.4`)
pub version: String,
/// 加载器
pub loader: LoaderKind,
/// 加载器版本(空 = 让 core 取最新)
pub loader_version: String,
/// 实例名(空 = 用版本 id / 加载器版本 id)
pub name: String,
}
impl CreateRequest {
/// 实际使用的实例名。
pub fn instance_name(&self) -> String {
if self.name.trim().is_empty() {
self.version.clone()
} else {
self.name.trim().to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loader_badges_and_ids() {
let vanilla = LoaderInfo::default();
assert_eq!(vanilla.badge(), "原版");
let fabric = LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
};
assert_eq!(fabric.badge(), "Fabric 0.16.5");
for kind in LoaderKind::wizard_choices() {
assert_eq!(LoaderKind::parse(kind.as_str()), Some(kind));
}
assert_eq!(LoaderKind::parse("fabric"), Some(LoaderKind::Fabric));
assert_eq!(LoaderKind::parse("unknown"), None);
}
#[test]
fn progress_ratio_handles_unknown_total() {
assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0);
assert!((Progress { done: 1, total: 4 }.ratio() - 0.25).abs() < f32::EPSILON);
}
#[test]
fn create_request_defaults_name_to_version() {
let request = CreateRequest {
version: "1.20.4".to_string(),
loader: LoaderKind::Vanilla,
loader_version: String::new(),
name: " ".to_string(),
};
assert_eq!(request.instance_name(), "1.20.4");
}
#[test]
fn instance_summary_is_serde_round_trip_safe() {
let instance = InstanceSummary {
id: "inst-1".to_string(),
name: "生存".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: Some("2026-10-31T12:00:00.000Z".to_string()),
runtime: InstanceRuntime::Busy {
stage: LaunchStage::Window,
progress: Progress { done: 1, total: 2 },
},
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
};
let text = serde_json::to_string(&instance).expect("序列化");
let back: InstanceSummary = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, instance);
}
}
+234
View File
@@ -0,0 +1,234 @@
//! Java 页 DTO:四家发行版、回退链可视化与本地运行时清单(D23/D13)。
//!
//! 回退链是「可观测性」的载体:安装失败时用户要能看见「Temurin 跳过 → Oracle 失败 →
//! Microsoft 命中」这条链,而不是只拿到一句「下载失败」。链的每一步由 host 从
//! `core::game::java::source::ChainLog` 翻译过来。
use serde::{Deserialize, Serialize};
/// 一家发行版的展示行。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaVendorView {
/// 稳定 id(`temurin` / `oracle` / `microsoft` / `zulu`)
pub id: String,
/// 展示名
pub display: String,
/// 优先级序号(1 起)
pub rank: u32,
/// 支持的 Java 大版本摘要(`21/17/11`)
pub majors: String,
/// 可用源(镜像 + 吞吐备注)
pub sources: Vec<String>,
/// 是否已装过这家(托管目录里有现成的)
pub installed: bool,
}
/// 回退链一步的结局。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum JavaChainOutcome {
/// 还没尝试(安装前的计划链)
Pending,
/// 跳过(不支持目标大版本)
Skip,
/// 尝试过但失败
Failed,
/// 命中(下载/安装成功)
Hit,
}
impl JavaChainOutcome {
/// 徽标文字。
pub fn label(self) -> &'static str {
match self {
Self::Pending => "待尝试",
Self::Skip => "跳过",
Self::Failed => "失败",
Self::Hit => "命中",
}
}
/// 从 core 的 `VendorAttempt` 结局字符串解析。
pub fn parse(raw: &str) -> Self {
match raw {
"hit" => Self::Hit,
"failed" => Self::Failed,
"skip" | "skipped" => Self::Skip,
_ => Self::Pending,
}
}
}
/// 回退链一步。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaChainStep {
/// 发行版 id
pub vendor: String,
/// 结局
pub outcome: JavaChainOutcome,
/// 细节(跳过原因 / 失败摘要 / 命中的下载源)
pub detail: String,
}
/// 本地已发现的 Java 运行时。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaRuntimeView {
/// 可执行文件路径
pub binary: String,
/// 版本串(`17.0.9`)
pub version: String,
/// 大版本
pub major: u32,
/// 发行方
pub vendor: String,
/// 来源分类(system / managed / instance)
pub origin: String,
}
/// Java 页快照(一次事件整体替换)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaPageView {
/// 地区判定(`domestic` / `global`)
pub region: String,
/// 优先级的来源(`region_default` / `java.vendorPriority`)
pub priority_source: String,
/// 生效顺序(`temurin → oracle → microsoft → zulu`)
pub order_text: String,
/// 四家展示行(按生效顺序)
pub vendors: Vec<JavaVendorView>,
/// 回退链(计划链或上一次安装的真实链)
pub chain: Vec<JavaChainStep>,
/// 本地运行时
pub runtimes: Vec<JavaRuntimeView>,
/// 托管目录
pub managed_roots: Vec<String>,
/// 是否有 Java 安装在跑
pub busy: bool,
/// 状态/错误文案
pub message: String,
}
impl Default for JavaPageView {
fn default() -> Self {
Self::new()
}
}
impl JavaPageView {
/// 空态(托管目录还没扫到东西)。
pub fn new() -> Self {
Self {
region: "global".to_string(),
priority_source: "region_default".to_string(),
order_text: String::new(),
vendors: Vec::new(),
chain: Vec::new(),
runtimes: Vec::new(),
managed_roots: Vec::new(),
busy: false,
message: String::new(),
}
}
/// 计划链:按生效顺序铺一排「待尝试」(安装前的可视化)。
pub fn planned_chain(vendors: &[JavaVendorView]) -> Vec<JavaChainStep> {
vendors
.iter()
.map(|vendor| JavaChainStep {
vendor: vendor.id.clone(),
outcome: JavaChainOutcome::Pending,
detail: String::new(),
})
.collect()
}
/// 命中的那一家的展示名(`None` = 链上还没命中)。
pub fn hit_vendor(&self) -> Option<&str> {
self.chain
.iter()
.find(|step| step.outcome == JavaChainOutcome::Hit)
.map(|step| step.vendor.as_str())
}
/// 已安装的大版本列表(去重升序)。
pub fn majors(&self) -> Vec<u32> {
let mut majors: Vec<u32> = self.runtimes.iter().map(|runtime| runtime.major).collect();
majors.sort_unstable();
majors.dedup();
majors
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vendor(id: &str, rank: u32) -> JavaVendorView {
JavaVendorView {
id: id.to_string(),
display: id.to_uppercase(),
rank,
majors: "21/17".to_string(),
sources: Vec::new(),
installed: false,
}
}
#[test]
fn planned_chain_is_all_pending_in_rank_order() {
let vendors = vec![vendor("temurin", 1), vendor("oracle", 2)];
let chain = JavaPageView::planned_chain(&vendors);
assert_eq!(chain.len(), 2);
assert!(chain
.iter()
.all(|step| step.outcome == JavaChainOutcome::Pending));
assert_eq!(chain[0].vendor, "temurin");
}
#[test]
fn hit_vendor_finds_the_hit_step() {
let mut page = JavaPageView::new();
page.chain = vec![
JavaChainStep {
vendor: "temurin".to_string(),
outcome: JavaChainOutcome::Skip,
detail: "不支持 8".to_string(),
},
JavaChainStep {
vendor: "oracle".to_string(),
outcome: JavaChainOutcome::Hit,
detail: "BMCLAPI".to_string(),
},
];
assert_eq!(page.hit_vendor(), Some("oracle"));
}
#[test]
fn outcome_parses_core_words() {
assert_eq!(JavaChainOutcome::parse("hit"), JavaChainOutcome::Hit);
assert_eq!(JavaChainOutcome::parse("failed"), JavaChainOutcome::Failed);
assert_eq!(JavaChainOutcome::parse("skip"), JavaChainOutcome::Skip);
assert_eq!(JavaChainOutcome::parse("?"), JavaChainOutcome::Pending);
}
#[test]
fn majors_are_deduped_and_sorted() {
let mut page = JavaPageView::new();
page.runtimes = vec![
JavaRuntimeView {
binary: "a".to_string(),
version: "17".to_string(),
major: 17,
vendor: "Temurin".to_string(),
origin: "managed".to_string(),
},
JavaRuntimeView {
binary: "b".to_string(),
version: "17".to_string(),
major: 17,
vendor: "Oracle".to_string(),
origin: "system".to_string(),
},
];
assert_eq!(page.majors(), vec![17]);
}
}
+78
View File
@@ -0,0 +1,78 @@
//! 日志页 DTO:会话列表、日志行与 D8 诊断结论。
use serde::{Deserialize, Serialize};
/// 一次启动会话(日志页左栏单位)标识。
pub type SessionId = String;
/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LogOutcome {
/// 正常退出
Ok,
/// 崩溃(非零退出码或 hs_err_pid)
Crashed,
/// exit0 但游戏没起来(D8 新增检测)
SilentFail,
}
/// 会话摘要。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogSession {
/// 会话 id
pub id: SessionId,
/// 实例名(会话属于哪个实例)
pub instance: String,
/// 展示用时间(`10-29 19:22`)
pub when: String,
/// 结局
pub outcome: LogOutcome,
/// 时长(秒)
pub duration_secs: u64,
}
/// 日志行等级(错误行高亮)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LogLevel {
/// debug
Debug,
/// info
Info,
/// warn
Warn,
/// error
Error,
}
/// 一行日志。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogLine {
/// 原始文本(含时间戳前缀)
pub text: String,
/// 等级
pub level: LogLevel,
}
/// D8 规则引擎结论(日志页崩溃诊断卡片)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Diagnosis {
/// 命中规则 id(`gpu.driver_old`)
pub rule: String,
/// 结论标题
pub title: String,
/// 0~100 置信度
pub confidence: u8,
/// 建议文案
pub hint: String,
/// 可一键执行的修复动作(无则只给建议)
pub fix: Option<String>,
}
/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosisReport {
/// 归属会话
pub session: SessionId,
/// 诊断内容
pub diagnosis: Diagnosis,
}
+39
View File
@@ -0,0 +1,39 @@
//! app ↔ core/agent 的 IPC 边界(01c §5 纪律)。
//!
//! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*`
//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型,
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架,
//! P3 起由 [`crate::host`] 的真实后端驱动全部页面。
//!
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java /
//! settings / install / notices / events),全量 `pub use` 出去,调用方依旧写
//! `crate::dto::InstanceSummary` 这类路径。
//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`。
//!
//! 全部跨界类型 `Serialize + Deserialize`(AGENTS §6):事件与快照是 IPC 契约,
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
mod accounts;
mod agent;
mod events;
mod install;
mod instances;
mod java;
mod logs;
mod notices;
mod settings;
mod versions;
pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView};
pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView};
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
pub use instances::{
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo,
LoaderKind, Progress,
};
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId};
pub use notices::{NoticeLevel, NoticeView};
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary};
+73
View File
@@ -0,0 +1,73 @@
//! 横幅通知 DTO:实例页/下载页顶部的「需要注意」提示条。
//!
//! 与 Toast 的分工:Toast 是「刚才发生了什么」(3 秒消失),通知条是
//! 「现在需要你处理」(用户点了才收)。隔离模式切换提示(M30 `ModeChangeWarning`)
//! 与启动失败都用它。
use serde::{Deserialize, Serialize};
/// 通知级别。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NoticeLevel {
/// 一般信息
Info,
/// 需要留意(黄条)
Warn,
/// 失败(红条)
Error,
}
impl NoticeLevel {
/// 级别文案(无障碍/测试用)。
pub fn label(self) -> &'static str {
match self {
Self::Info => "信息",
Self::Warn => "注意",
Self::Error => "错误",
}
}
}
/// 一条通知。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NoticeView {
/// 稳定 id(关闭时按它移除)
pub id: String,
/// 级别
pub level: NoticeLevel,
/// 标题
pub title: String,
/// 正文
pub body: String,
}
impl NoticeView {
/// 建一条。
pub fn new(id: &str, level: NoticeLevel, title: &str, body: &str) -> Self {
Self {
id: id.to_string(),
level,
title: title.to_string(),
body: body.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notices_are_serde_round_trip_safe() {
let notice = NoticeView::new(
"isolation:demo",
NoticeLevel::Warn,
"隔离模式已切换",
"旧目录里还有 mods/saves",
);
let text = serde_json::to_string(&notice).expect("序列化");
let back: NoticeView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, notice);
assert_eq!(back.level.label(), "注意");
}
}
+220
View File
@@ -0,0 +1,220 @@
//! 设置页 DTO:设置分组的通用行模型。
//!
//! 「行」是 UI 与 `core::setting` schema 之间的唯一形状:一行 = 一个键 + 一种控件 +
//! 当前值。写回走 [`crate::dto::AppCommand::SetSetting`],由 host 用 `SettingStore::set`
//! 落盘(不自己拼 JSON、不另立配置文件,`AGENTS §6`)。
use serde::{Deserialize, Serialize};
/// 控件类型(决定设置页画什么)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SettingControl {
/// 开关
Toggle,
/// 分段单选(`options` + `selected`)
Choice,
/// 单行文本(`value_text`)
Text,
/// 只读展示(当前路径/当前版本这类)
ReadOnly,
/// 动作按钮(`action` 给回调 id)
Action,
}
/// 一行设置。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingRowView {
/// schema 键(`download.concurrency`)
pub key: String,
/// 行标题
pub label: String,
/// 说明/当前值附注
pub description: String,
/// 控件
pub control: SettingControl,
/// 开关值
pub flag: bool,
/// 单选序号
pub selected: i32,
/// 单选项文案
pub options: Vec<String>,
/// 文本值
pub value_text: String,
/// 动作 id(`detect_region` 这类)
pub action: String,
}
impl SettingRowView {
/// 开关行。
pub fn toggle(key: &str, label: &str, description: &str, value: bool) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Toggle,
flag: value,
selected: 0,
options: Vec::new(),
value_text: String::new(),
action: String::new(),
}
}
/// 单选行。
pub fn choice(
key: &str,
label: &str,
description: &str,
options: &[&str],
selected: i32,
) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Choice,
flag: false,
selected,
options: options.iter().map(|item| (*item).to_string()).collect(),
value_text: String::new(),
action: String::new(),
}
}
/// 文本行。
pub fn text(key: &str, label: &str, description: &str, value: &str) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Text,
flag: false,
selected: 0,
options: Vec::new(),
value_text: value.to_string(),
action: String::new(),
}
}
/// 只读行。
pub fn read_only(label: &str, value: &str) -> Self {
Self {
key: String::new(),
label: label.to_string(),
description: value.to_string(),
control: SettingControl::ReadOnly,
flag: false,
selected: 0,
options: Vec::new(),
value_text: value.to_string(),
action: String::new(),
}
}
/// 动作行。
pub fn action(key: &str, label: &str, description: &str, action: &str) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Action,
flag: false,
selected: 0,
options: Vec::new(),
value_text: String::new(),
action: action.to_string(),
}
}
}
/// 一个设置分组(设置页右栏的一段)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingGroupView {
/// 分组 id(与设置页子导航的 `SettingSection` 同名:download / java / data / ai / mcp)
pub id: String,
/// 分组标题
pub title: String,
/// 行
pub rows: Vec<SettingRowView>,
}
impl SettingGroupView {
/// 建组。
pub fn new(id: &str, title: &str) -> Self {
Self {
id: id.to_string(),
title: title.to_string(),
rows: Vec::new(),
}
}
/// 追加一行(链式)。
pub fn with(mut self, row: SettingRowView) -> Self {
self.rows.push(row);
self
}
}
/// 在分组快照里找一个键的当前值(设置页回填用)。
pub fn find_row<'a>(groups: &'a [SettingGroupView], key: &str) -> Option<&'a SettingRowView> {
groups
.iter()
.flat_map(|group| group.rows.iter())
.find(|row| row.key == key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_carry_exactly_one_control_kind() {
let toggle = SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true);
assert_eq!(toggle.control, SettingControl::Toggle);
assert!(toggle.flag);
let choice = SettingRowView::choice("download.region", "地区", "", &["auto", "cn"], 1);
assert_eq!(choice.options, vec!["auto", "cn"]);
assert_eq!(choice.selected, 1);
let action = SettingRowView::action("java.action", "检测", "", "detect_region");
assert_eq!(action.action, "detect_region");
}
#[test]
fn find_row_scans_all_groups() {
let groups = vec![
SettingGroupView::new("download", "下载").with(SettingRowView::text(
"download.proxy_url",
"代理",
"",
"",
)),
SettingGroupView::new("mcp", "MCP").with(SettingRowView::toggle(
"agent.mcp.enabled",
"开关",
"",
false,
)),
];
assert_eq!(
find_row(&groups, "agent.mcp.enabled").map(|row| row.label.as_str()),
Some("开关")
);
assert!(find_row(&groups, "nope").is_none());
}
#[test]
fn rows_are_serde_round_trip_safe() {
let group = SettingGroupView::new("download", "下载").with(SettingRowView::choice(
"download.concurrency",
"并发",
"",
&["4", "8", "16"],
1,
));
let text = serde_json::to_string(&group).expect("序列化");
let back: SettingGroupView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, group);
}
}
+62
View File
@@ -0,0 +1,62 @@
//! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。
use serde::{Deserialize, Serialize};
/// 版本类型(远古版按 unlisted-versions 的分类拆开)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VersionKind {
/// 正式版
Release,
/// 预览版(snapshot)
Snapshot,
/// 远古版(带子类)
Ancient(AncientKind),
}
/// 远古版子类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AncientKind {
/// classic(rd-*)
Classic,
/// old_alpha(a1.*)
OldAlpha,
/// old_beta(b1.*)
OldBeta,
/// 未列入官方清单的远古版汇总
Unlisted,
}
impl VersionKind {
/// 是否属于「远古版」——列表行要加警示徽标。
pub fn is_ancient(self) -> bool {
matches!(self, Self::Ancient(_))
}
}
/// 下载页版本行。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionSummary {
/// 版本 id(如 `1.20.4` / `rd-132211`)
pub id: String,
/// 版本类型
pub kind: VersionKind,
/// 发布日期(ISO-8601 日期部分)
pub released: String,
/// 本地是否已安装(行尾显示「已装」)
pub installed: bool,
}
/// 下载页左侧分类(页内常驻侧栏的选中项)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VersionCategory {
/// 正式版
Release,
/// 预览版
Snapshot,
/// 远古版某子类
Ancient(AncientKind),
/// 整合包(P1)
Modpack,
/// Mod(P1)
Mod,
}
+274
View File
@@ -0,0 +1,274 @@
//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软登录(两条流)。
//!
//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现,
//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。
//!
//! 两条登录流:授权码([`start_browser_login`],GUI 默认:拉起系统浏览器 + 本地回调,
//! `research/08` §3 方案 A)与设备码([`start_login`],无浏览器/CLI 场景)。除了「怎么拿到
//! MS access_token」之外完全共用:同一个取消槽位、同一段账户入库、同一个刷新链。
//!
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::auth::account::Credentials;
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession};
use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::{AccountStore, AuthResult};
use unlucky_core::download::blocking::block_on;
use crate::dto::{AccountKind, AccountView, DeviceLoginView};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
use super::opener;
/// 账户列表(公开字段)。
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
let store = AccountStore::open(env.layout.auth_data());
store
.accounts()
.iter()
.map(|meta| AccountView {
id: meta.id.as_str().to_string(),
name: meta.display_name().to_string(),
kind: AccountKind::parse(meta.kind.as_str()),
profile_id: meta.profile_id.clone(),
login_name: meta.login_name.clone().unwrap_or_default(),
created: display_ms(meta.created_at_ms),
active: active.is_some_and(|id| id == meta.id.as_str()),
})
.collect()
}
/// 毫秒时间戳 → `2026-10-31 20:12`(UTC;账户列表只用来排序/辨认)。
pub fn display_ms(ms: u64) -> String {
if ms == 0 {
return String::new();
}
let total_secs = ms / 1000;
let days = total_secs / 86_400;
let rem = total_secs % 86_400;
let (year, month, day) = civil_from_days(i64::try_from(days).unwrap_or(0));
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
rem / 3_600,
(rem % 3_600) / 60
)
}
/// Howard Hinnant 的 `civil_from_days`(1970-01-01 为 0)。
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
}
/// 加一个离线账户。
///
/// # Errors
///
/// 名字不合法、同名冲突、落盘失败时返回用户可读文案。
pub fn add_offline(env: &HostEnv, name: &str) -> Result<String, String> {
let name = name.trim();
let meta = offline_meta(name, now_ms()).map_err(|error| error.to_string())?;
let display = meta.display_name().to_string();
let mut store = AccountStore::open(env.layout.auth_data());
store
.insert(meta, Credentials::Offline)
.map_err(|error| format!("添加账户失败:{error}"))?;
store
.save()
.map_err(|error| format!("保存账户失败:{error}"))?;
Ok(display)
}
/// 删除账户(按 id 或名字)。
///
/// # Errors
///
/// 找不到、落盘失败时返回用户可读文案。
pub fn remove(env: &HostEnv, key: &str) -> Result<String, String> {
let mut store = AccountStore::open(env.layout.auth_data());
let meta = store
.remove(key)
.map_err(|error| format!("删除账户失败:{error}"))?;
store
.save()
.map_err(|error| format!("保存账户失败:{error}"))?;
Ok(meta.display_name().to_string())
}
/// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。
#[derive(Debug, Clone, Default)]
pub struct LoginJob {
/// 用户点了取消(线程收尾时据此丢弃结果)
pub cancelled: Arc<AtomicBool>,
}
impl LoginJob {
/// 新建一个未取消的句柄。
pub fn new() -> Self {
Self {
cancelled: Arc::new(AtomicBool::new(false)),
}
}
/// 取消。
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
}
/// 是否已取消。
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
}
}
/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-login", move || {
let auth = match microsoft_auth(&env) {
Ok(auth) => auth,
Err(message) => return fail(&tx, message),
};
let tx_prompt = tx.clone();
let result = block_on(auth.device_code_login(|prompt| {
let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::waiting(
&prompt.user_code,
&prompt.verification_uri,
prompt.expires_in.as_secs(),
&format!("{}#{}", prompt.verification_uri, prompt.user_code),
))));
}));
finish(&env, &tx, &job, result);
});
}
/// 起一次授权码登录(后台线程:绑本地端口 → 拉起系统默认浏览器 → 等回调 → 换链)。
///
/// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的
/// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。
pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-authcode", move || {
let auth = match microsoft_auth(&env) {
Ok(auth) => auth,
Err(message) => return fail(&tx, message),
};
let tx_prompt = tx.clone();
let result = block_on(auth.authcode_login(|prompt| {
// 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。
let opened = opener::open_url(&prompt.authorization_url);
let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting(
&prompt.authorization_url,
prompt.timeout.as_secs(),
opened.err().as_deref(),
))));
}));
finish(&env, &tx, &job, result);
});
}
/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。
fn microsoft_auth(env: &HostEnv) -> Result<MicrosoftAuth, String> {
MicrosoftAuth::from_env()
.map(|auth| {
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
env.layout.logs(),
)))
})
.map_err(|error| error.to_string())
}
fn fail(tx: &JobSender, message: String) {
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(message))));
}
/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。
fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult<MicrosoftSession>) {
if job.is_cancelled() {
let _ = tx.send(HostTask::Login(Box::default()));
return;
}
match result {
Ok(session) => match store_session(env, session) {
Ok(name) => {
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name))));
let _ = tx.send(HostTask::Accounts(accounts(env, None)));
}
Err(message) => fail(tx, message),
},
Err(error) => fail(tx, error.to_string()),
}
}
/// 微软会话入库(元数据 + 凭据分文件,见 `core::auth::AccountStore`)。
fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result<String, String> {
let meta = unlucky_core::auth::account::AccountMeta::new(
unlucky_core::auth::account::AccountKind::Microsoft,
session.profile_id.clone(),
session.profile_name.clone(),
now_ms(),
);
let name = meta.display_name().to_string();
let mut store = AccountStore::open(env.layout.auth_data());
store
.insert(meta, Credentials::Microsoft(session))
.and_then(|()| store.save())
.map_err(|error| format!("账户入库失败:{error}"))?;
Ok(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn time_display_matches_known_timestamps() {
assert_eq!(display_ms(0), "");
// 1_769_000_000_000 ms = 2026-01-21T12:53:20Z(取整到分钟)
assert_eq!(display_ms(1_769_000_000_000), "2026-01-21 12:53");
}
#[test]
fn offline_add_list_remove_round_trips() {
let dir = std::env::temp_dir().join(format!("ull-host-accounts-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");
let name = add_offline(&env, "Steve").expect("加账户");
assert_eq!(name, "Steve");
let list = accounts(&env, None);
assert_eq!(list.len(), 1);
assert_eq!(list[0].kind, AccountKind::Offline);
assert!(!list[0].active);
let active = accounts(&env, Some(list[0].id.as_str()));
assert!(active[0].active);
let removed = remove(&env, "Steve").expect("删账户");
assert_eq!(removed, "Steve");
assert!(accounts(&env, None).is_empty());
assert!(add_offline(&env, " ").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn login_job_cancel_flag_is_shared() {
let job = LoginJob::new();
let clone = job.clone();
assert!(!clone.is_cancelled());
job.cancel();
assert!(clone.is_cancelled());
}
}
+162
View File
@@ -0,0 +1,162 @@
//! `CoreHost::dispatch` 的指令实现(从 `host/mod.rs` 拆出,守 400 行上限)。
//!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.env` / `self.tx` 等。
use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind};
use crate::toast::ToastKind;
use super::accounts::{self, LoginJob};
use super::{game, install, java, launch, logs, settings, versions, CoreHost};
impl CoreHost {
/// 下发一条用户意图(`Backend::dispatch` 的实现体)。
pub(super) fn dispatch_command(&mut self, command: AppCommand) {
self.dispatch_inner(command);
}
}
impl CoreHost {
/// 指令分发(见 `dispatch_command`)。
fn dispatch_inner(&mut self, command: AppCommand) {
match command {
AppCommand::RefreshAll => self.refresh_all(),
AppCommand::LaunchInstance(id) => self.start_launch(&id),
AppCommand::StopInstance(id) => match self.running_pid(&id) {
Some(pid) => match launch::stop(pid) {
Ok(()) => self.toast(ToastKind::Info, "已请求停止游戏进程"),
Err(reason) => self.toast(ToastKind::Error, reason),
},
None => self.toast(ToastKind::Warn, "这个实例当前没有在跑"),
},
AppCommand::ListVersions(_) => versions::spawn_list(self.env.clone(), self.tx.clone()),
AppCommand::InstallVersion(version) => {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
install::spawn_install(
self.env.clone(),
self.tx.clone(),
CreateRequest {
name: version.clone(),
version,
loader: LoaderKind::Vanilla,
loader_version: String::new(),
},
cancel,
);
}
AppCommand::CreateInstance(request) => {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
install::spawn_install(self.env.clone(), self.tx.clone(), request, cancel);
}
AppCommand::CancelInstall => {
// 取消只在阶段边界生效(core 的下载循环没有取消钩子):这里先把面板收回,
// 后台线程会在下一阶段开始前看到标记并收尾。
if let Some(cancel) = &self.install_cancel {
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
}
self.install = None;
self.send(CoreEvent::InstallProgress(None));
}
AppCommand::SetIsolation { id, mode } => {
match game::set_isolation(&self.env, &id, &mode) {
Ok(()) => {
self.toast(ToastKind::Success, "隔离档位已更新");
self.refresh_instances();
}
Err(reason) => self.toast(ToastKind::Error, reason),
}
}
AppCommand::RefreshLogSessions => {
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
}
AppCommand::OpenLogSession(id) => self.open_session(&id),
AppCommand::ImportCrashLog(path) => match logs::analyze_crash_file(&path) {
Ok((label, report)) => {
self.send(CoreEvent::DiagnosisReady(report));
self.toast(ToastKind::Info, label);
}
Err(reason) => self.toast(ToastKind::Warn, reason),
},
AppCommand::AddOfflineAccount(name) => match accounts::add_offline(&self.env, &name) {
Ok(display) => {
self.toast(ToastKind::Success, format!("已添加离线账户 {display}"));
self.refresh_accounts();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::RemoveAccount(key) => match accounts::remove(&self.env, &key) {
Ok(display) => {
if self.active_account.as_deref() == Some(key.as_str()) {
self.active_account = None;
}
self.toast(ToastKind::Info, format!("已删除账户 {display}"));
self.refresh_accounts();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::SwitchAccount(id) => {
self.active_account = Some(id);
self.refresh_accounts();
self.toast(ToastKind::Info, "已切换启动账户");
}
AppCommand::StartDeviceLogin => {
self.login = Some(LoginJob::new());
let job = self.login.clone().unwrap_or_default();
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
phase: crate::dto::DeviceLoginPhase::Requesting,
message: "正在向微软申请设备码…".to_string(),
..crate::dto::DeviceLoginView::new()
}));
accounts::start_login(self.env.clone(), self.tx.clone(), job);
}
AppCommand::StartBrowserLogin => {
self.login = Some(LoginJob::new());
let job = self.login.clone().unwrap_or_default();
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
phase: crate::dto::DeviceLoginPhase::Requesting,
message: "正在准备浏览器登录(本地回调端口)…".to_string(),
..crate::dto::DeviceLoginView::new()
}));
accounts::start_browser_login(self.env.clone(), self.tx.clone(), job);
}
AppCommand::CancelDeviceLogin => {
if let Some(job) = &self.login {
job.cancel();
}
self.login = None;
self.send(CoreEvent::DeviceLoginChanged(
crate::dto::DeviceLoginView::new(),
));
}
AppCommand::InstallJava(vendor) => {
self.java_busy = true;
self.java_message = format!("正在安装 Java({vendor})…");
self.refresh_java();
java::spawn_install(
self.env.clone(),
self.tx.clone(),
unlucky_core::game::java::JdkVendor::parse(&vendor),
);
}
AppCommand::SetSetting { key, value } => match settings::set(&self.env, &key, &value) {
Ok(()) => {
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
self.java_chain.clear();
self.refresh_java();
}
Err(reason) => self.toast(ToastKind::Error, reason),
},
AppCommand::DismissNotice(id) => {
self.notices.retain(|notice| notice.id != id);
self.send(CoreEvent::NoticesChanged(self.notices.clone()));
}
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
self.chat.dispatch(other);
for event in self.chat.drain_events() {
self.events.push_back(event);
}
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
//! 下载引擎与 Java 落点的装配(versions / install / java 三个任务共用)。
//!
//! 与 CLI `install::build_engine` 同源:内容寻址缓存落 `.ULL/cache/`,地区判定走
//! 24h 缓存 + 环境信号(**不做网络探测**,与 D7 的「不探测时」约定一致)。
use std::path::PathBuf;
use std::sync::Arc;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
use unlucky_core::download::region_system::detect_signals;
use unlucky_core::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable,
};
use unlucky_core::game::java::vendor::JdkVendor;
use unlucky_core::game::java::{self, JavaInstallRoot};
use unlucky_core::platform::paths::ProcessEnv;
use super::env::HostEnv;
/// 建下载引擎(缓存/健康度/地区判定都在数据根里)。
pub fn engine(env: &HostEnv) -> DownloadEngine {
let cache_root = env.layout.cache();
let now = now_ms();
let region_path = cache_root.join("region.json");
let region = load_cached(&region_path, now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
let _ = store_cached(&region_path, &profile);
profile
});
DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(region),
Arc::new(HealthRegistry::open_in(&cache_root, now)),
Arc::new(ContentCache::open(&cache_root)),
)
}
/// 是否国内网络(决定默认发行版顺序与镜像优先级)。
pub fn domestic(env: &HostEnv) -> bool {
engine(env).region().is_domestic()
}
/// 设置里的 `java.vendorPriority`(空 = 按地区默认序)。
pub fn java_priority(env: &HostEnv) -> Vec<JdkVendor> {
env.store()
.effective("java.vendorPriority")
.and_then(serde_json::Value::as_array)
.map(|items| {
let texts: Vec<String> = items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect();
unlucky_core::game::java::vendor::parse_priority(&texts)
})
.unwrap_or_default()
}
/// 设置里的 `java.installRoot`(空 = 按数据目录模式派生)。
pub fn java_install_override(env: &HostEnv) -> Option<PathBuf> {
env.store()
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from)
}
/// 托管 JDK 落点(解析失败 → 用户可读文案)。
pub fn java_install_root(env: &HostEnv) -> Result<JavaInstallRoot, String> {
let override_root = java_install_override(env);
JavaInstallRoot::resolve(
env.mode(),
&env.layout,
override_root.as_deref(),
env.platform,
&ProcessEnv,
)
.map_err(|error| error.to_string())
}
/// 托管 JDK 可能存在的全部安装根(扫描运行时用)。
pub fn java_scan_roots(env: &HostEnv) -> Vec<PathBuf> {
java::location::scan_roots(
env.mode(),
&env.layout,
java_install_override(env).as_deref(),
env.platform,
&ProcessEnv,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_uses_the_data_root_cache() {
let dir = std::env::temp_dir().join(format!("ull-host-engine-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.cache()).expect("cache");
let engine = engine(&env);
assert_eq!(engine.config().max_concurrency, 64);
// 地区判定不联网:只读缓存/信号
let _ = domestic(&env);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn java_merge_priority_reads_settings() {
let dir = std::env::temp_dir().join(format!("ull-host-engine-p-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
std::fs::write(
env.launcher_config(),
r#"{"java":{"vendorPriority":["zulu"]}}"#,
)
.expect("写配置");
assert_eq!(java_priority(&env), vec![JdkVendor::Zulu]);
std::fs::remove_dir_all(&dir).ok();
}
}
+170
View File
@@ -0,0 +1,170 @@
//! host 的环境快照:一份 `ResolvedData` + 由其派生的布局视图。
//!
//! app 自己不解析三模式数据根(那是 `core::platform::data_dir` 的活);这里只做两件事:
//! ①按「exe 同级便携 `launcher.json` 的 `core.*` 四键」拼请求;②把解析结果与
//! `Platform` / `DataMode` 打成一个可克隆的结构,交给后台线程。
//!
//! 截图/演示通道用 [`HostEnv::at_root`] 直接钉一个根(`<root>/.ULL` + `<root>/.minecraft`)。
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver};
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::paths::Platform;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
/// 数据根决定键(与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
pub(crate) const DATA_ROOT_KEYS: [&str; 4] = [
"core.data_mode",
"core.data_custom_root",
"core.launcher_home",
"core.game_home",
];
/// host 运行期需要的一切路径/平台信息(可克隆进线程)。
#[derive(Debug, Clone)]
pub struct HostEnv {
/// 数据根解析结果
pub data: ResolvedData,
/// `.ULL/` 布局
pub layout: LauncherLayout,
/// `.minecraft/` 布局
pub game: GameLayout,
/// 平台
pub platform: Platform,
}
impl HostEnv {
/// 按便携目录的 `core.*` 解析真实数据根;解析不出来返回 `None`(host 退回 mock)。
pub fn resolve() -> Option<Self> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf))?;
let request = request_from_portable(&exe_dir);
let resolver = Resolver::new(Platform::current(), Some(exe_dir));
let data = resolver.resolve_default(&request).ok()?.clone();
Some(Self::from_data(data))
}
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`(截图/演示通道)。
///
/// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**:
/// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。
pub fn at_root(root: impl AsRef<Path>) -> Self {
let root = root.as_ref();
let request = request_from_portable(root);
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
Self::from_data(ResolvedData {
mode: DataMode::Portable,
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
redirected_from: None,
})
}
/// 由解析结果建环境。
pub fn from_data(data: ResolvedData) -> Self {
Self {
layout: data.layout(),
game: GameLayout::new(data.game_home.clone()),
platform: Platform::current(),
data,
}
}
/// `config/launcher.json` 路径。
pub fn launcher_config(&self) -> PathBuf {
self.layout.launcher_config()
}
/// 设置存储(每次现读一份;写操作自己 save)。
pub fn store(&self) -> SettingStore {
SettingStore::load(self.launcher_config(), launcher_schema())
}
/// 启动会话根(`.ULL/data/agent/sessions`)。
pub fn sessions_root(&self) -> PathBuf {
self.layout.agent_sessions()
}
/// 数据目录模式。
pub fn mode(&self) -> DataMode {
self.data.mode
}
/// 人读一行(设置页「数据」分组展示)。
pub fn data_line(&self) -> String {
format!(
"{} · 启动器 {} · 游戏 {}",
self.data.mode,
self.data.launcher_home.display(),
self.data.game_home.display()
)
}
}
/// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。
pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
let mut request = DataDirRequest::default();
let path = portable_config_path(exe_dir);
if !path.is_file() {
return request;
}
let store = SettingStore::load(&path, launcher_schema());
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) {
request.mode = mode;
}
if let Some(root) = text(DATA_ROOT_KEYS[1]) {
request.custom_root = Some(PathBuf::from(root));
}
if let Some(home) = text(DATA_ROOT_KEYS[2]) {
request.launcher_home = Some(PathBuf::from(home));
}
if let Some(home) = text(DATA_ROOT_KEYS[3]) {
request.game_home = Some(PathBuf::from(home));
}
request
}
/// `<exe 目录>/.ULL/config/launcher.json`。
pub(crate) fn portable_config_path(exe_dir: &Path) -> PathBuf {
LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn at_root_splits_launcher_and_game_homes() {
let env = HostEnv::at_root("D:/tmp/ull-demo");
assert_eq!(env.layout.home(), Path::new("D:/tmp/ull-demo/.ULL"));
assert_eq!(env.game.root(), Path::new("D:/tmp/ull-demo/.minecraft"));
assert_eq!(env.mode(), DataMode::Portable);
assert!(env.data_line().contains("portable"));
}
#[test]
fn portable_request_reads_core_keys_only_when_file_exists() {
let dir = std::env::temp_dir().join(format!("ull-host-env-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".ULL/config")).expect("建目录");
let request = request_from_portable(&dir);
assert_eq!(request, DataDirRequest::default());
std::fs::write(
portable_config_path(&dir),
r#"{"core":{"data_mode":"custom","data_custom_root":"D:/data/ull"}}"#,
)
.expect("写配置");
let request = request_from_portable(&dir);
assert_eq!(request.mode, DataMode::Custom);
assert_eq!(
request.custom_root.as_deref(),
Some(Path::new("D:/data/ull"))
);
std::fs::remove_dir_all(&dir).ok();
}
}
+260
View File
@@ -0,0 +1,260 @@
//! 实例快照:`profiles.json` + 版本 JSON + 会话索引 + 实例设置 → [`InstanceSummary`]。
//!
//! 「磁贴上的每个字」都在这里翻译完:版本号取自继承链根、加载器取自版本 id 前缀、
//! 最后游玩时间取自会话索引、隔离档位取自实例设置(缺省跟全局 `instances.mode`)。
use std::collections::BTreeMap;
use std::path::Path;
use unlucky_core::game::install::instance::{
read_instance_config, read_profiles, write_instance_config,
};
use unlucky_core::game::isolation::IsolationMode;
use unlucky_core::game::{ProfileList, VersionJson};
use unlucky_core::launch::session_index;
use crate::dto::{
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
};
use super::env::HostEnv;
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
pub fn global_mode(env: &HostEnv) -> IsolationMode {
env.store()
.doc()
.effective_str("instances.mode")
.and_then(IsolationMode::parse)
.unwrap_or_default()
}
/// 读实例清单(文件缺失 = 空列表,不是错误)。
pub fn profiles(env: &HostEnv) -> ProfileList {
read_profiles(&env.layout).unwrap_or_default()
}
/// 全会话索引里「每个实例最近一次启动」的时间串。
pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
let index = session_index::index_path(&env.sessions_root());
let entries = session_index::read_all(&index).unwrap_or_default();
let mut map: BTreeMap<String, String> = BTreeMap::new();
for entry in entries {
if entry.instance_id.is_empty() || entry.started_at.is_empty() {
continue;
}
let current = map.get(&entry.instance_id);
if current.is_none_or(|existing| existing.as_str() < entry.started_at.as_str()) {
map.insert(entry.instance_id.clone(), entry.started_at);
}
}
map
}
/// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。
pub fn describe_version(env: &HostEnv, version_id: &str) -> (String, LoaderInfo) {
let loader = loader_from_id(version_id);
let mut current = version_id.to_string();
let mut seen = 0;
while seen < 16 {
seen += 1;
let path = env.game.version_json(&current);
let Ok(text) = std::fs::read_to_string(&path) else {
break;
};
let Ok(json) = VersionJson::from_json_str(&text) else {
break;
};
match json.inherits_from {
Some(parent) if !parent.is_empty() => current = parent,
_ => break,
}
}
(current, loader)
}
/// 版本 id → 加载器(HMCL 同款命名口径:`1.20.1-forge-47.2.0` / `fabric-loader-0.16.5-1.20.1`)。
pub fn loader_from_id(version_id: &str) -> LoaderInfo {
let lower = version_id.to_ascii_lowercase();
let grab = |marker: &str| -> String {
lower
.split_once(marker)
.map(|(_, rest)| rest.trim_start_matches('-').to_string())
.unwrap_or_default()
};
let (kind, version) = if lower.contains("neoforge") {
(LoaderKind::NeoForge, grab("neoforge-"))
} else if lower.contains("fabric-loader") {
(LoaderKind::Fabric, grab("fabric-loader-"))
} else if lower.contains("quilt-loader") {
(LoaderKind::Quilt, grab("quilt-loader-"))
} else if lower.contains("forge") {
(LoaderKind::Forge, grab("forge-"))
} else if lower.contains("optifine") {
(LoaderKind::OptiFine, grab("optifine"))
} else if lower.contains("liteloader") {
(LoaderKind::LiteLoader, grab("liteloader-"))
} else {
(LoaderKind::Vanilla, String::new())
};
LoaderInfo { kind, version }
}
/// 实例的隔离档位(实例文件优先,缺省显示全局档位)。
pub fn isolation_of(env: &HostEnv, id: &str) -> String {
let global = global_mode(env);
match read_instance_config(&env.layout, id) {
Ok(Some(config)) => config.effective_isolation(global).as_str().to_string(),
_ => global.as_str().to_string(),
}
}
/// 实例快照。
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
let profiles = profiles(env);
let played = last_played(env);
profiles
.profiles
.iter()
.map(|profile| {
let version_id = profile.version_id.clone();
let (mc_version, loader) = describe_version(env, &version_id);
InstanceSummary {
id: profile.id.as_str().to_string(),
name: profile.name.clone(),
mc_version,
loader,
last_played: played.get(profile.id.as_str()).cloned(),
runtime: InstanceRuntime::Idle,
icon: None,
isolation: isolation_of(env, profile.id.as_str()),
version_present: env.game.is_version_installed(&version_id),
version_id,
}
})
.collect()
}
/// 改实例隔离档位(写实例设置文件;不动任何数据)。
///
/// # Errors
///
/// 实例不存在、文件读不了/写不了时返回用户可读文案。
pub fn set_isolation(env: &HostEnv, id: &str, mode: &str) -> Result<(), String> {
let parsed = IsolationMode::parse(mode)
.ok_or_else(|| format!("未知隔离档位 `{mode}`(isolated / shared)"))?;
let profiles = profiles(env);
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == id)
.ok_or_else(|| format!("找不到实例 `{id}`"))?;
let mut config = read_instance_config(&env.layout, id)
.map_err(|error| format!("读实例设置失败:{error}"))?
.unwrap_or_else(|| {
unlucky_core::game::install::instance::InstanceConfig::new(
profile.id.as_str(),
&profile.version_id,
)
});
config.isolation = Some(match parsed {
IsolationMode::Isolated => unlucky_core::game::install::instance::Isolation::Isolated,
IsolationMode::Shared => unlucky_core::game::install::instance::Isolation::Shared,
});
write_instance_config(&env.layout, &config)
.map(|_| ())
.map_err(|error| format!("写实例设置失败:{error}"))
}
/// 启动前的切换检测:把 M30 的 `ModeChangeWarning` 翻成横幅通知。
///
/// 只在真启动时调用(会写 `lastModeChange.warned`,保证同一次切换只提示一次)。
pub fn mode_change_notice(env: &HostEnv, id: &str) -> Option<NoticeView> {
let mut config = read_instance_config(&env.layout, id).ok()??;
let warning = config.take_mode_change_warning()?;
let _ = write_instance_config(&env.layout, &config);
Some(NoticeView::new(
&format!("isolation:{id}"),
NoticeLevel::Warn,
"隔离档位已切换",
&warning.message(),
))
}
/// 建目录(数据根首次运行时)。
pub fn ensure_root(env: &HostEnv) {
let _ = env.layout.ensure();
let _ = std::fs::create_dir_all(env.game.root());
}
/// 版本本体目录是否存在(启动前快速判断)。
pub fn version_present(env: &HostEnv, version_id: &str) -> bool {
env.game.is_version_installed(version_id)
}
/// 路径显示成短串(通知/错误文案用)。
pub fn short_path(path: &Path) -> String {
path.display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loader_parsing_covers_all_shapes() {
let cases = [
("1.20.4", LoaderKind::Vanilla, ""),
(
"fabric-loader-0.16.5-1.20.1",
LoaderKind::Fabric,
"0.16.5-1.20.1",
),
(
"quilt-loader-0.24.0-1.20.1",
LoaderKind::Quilt,
"0.24.0-1.20.1",
),
("1.20.1-forge-47.2.0", LoaderKind::Forge, "47.2.0"),
("1.21.1-neoforge-21.1.80", LoaderKind::NeoForge, "21.1.80"),
];
for (id, kind, version) in cases {
let info = loader_from_id(id);
assert_eq!(info.kind, kind, "{id}");
assert_eq!(info.version, version, "{id}");
assert!(!info.badge().is_empty());
}
}
#[test]
fn isolation_write_then_read_round_trips() {
let dir = std::env::temp_dir().join(format!("ull-host-game-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
// 直接建一份 profiles.json(register 需要版本本体,这里只测隔离写读)
let profiles = ProfileList {
profiles: vec![unlucky_core::game::InstanceProfile {
id: unlucky_core::game::ProfileId::generate(),
name: "demo".to_string(),
version_id: "1.20.1".to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: BTreeMap::new(),
}],
..ProfileList::default()
};
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles)
.expect("写清单");
let id = profiles.profiles[0].id.as_str().to_string();
assert_eq!(isolation_of(&env, &id), "isolated");
set_isolation(&env, &id, "shared").expect("切档位");
assert_eq!(isolation_of(&env, &id), "shared");
assert!(set_isolation(&env, &id, "nope").is_err());
std::fs::remove_dir_all(&dir).ok();
}
}
+145
View File
@@ -0,0 +1,145 @@
//! `HostTask` → `CoreEvent` 的翻译(从 `host/mod.rs` 拆出,守 400 行硬限)。
//!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome};
use crate::toast::ToastKind;
use super::{logs, CoreHost, HostTask, LiveLaunch};
impl CoreHost {
/// 后台消息 → 事件。
pub(super) fn handle(&mut self, task: HostTask) {
match task {
HostTask::Versions(Ok(versions)) => self.send(CoreEvent::VersionListChanged(versions)),
HostTask::Versions(Err(reason)) => {
self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}"));
}
HostTask::Install(progress) => {
self.install = Some(*progress.clone());
self.send(CoreEvent::InstallProgress(Some(*progress)));
}
HostTask::InstallDone { progress, result } => {
self.install_cancel = None;
self.install = Some(*progress.clone());
self.send(CoreEvent::InstallProgress(Some(*progress)));
match result {
Ok(_) => {
self.refresh_instances();
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
}
Err(reason) => self.toast(ToastKind::Error, reason),
}
}
HostTask::Accounts(accounts) => {
self.accounts = accounts.clone();
self.send(CoreEvent::AccountsChanged(accounts));
self.refresh_instances();
}
HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)),
HostTask::Java(page) => self.send(CoreEvent::JavaChanged(*page)),
HostTask::Logs {
sessions,
lines,
diagnosis,
} => {
self.send(CoreEvent::LogSessionsChanged(sessions));
self.send(CoreEvent::LogLinesChanged(lines));
if let Some(report) = diagnosis {
self.send(CoreEvent::DiagnosisReady(report));
}
}
HostTask::Sessions(sessions) => self.send(CoreEvent::LogSessionsChanged(sessions)),
HostTask::Tail(batch) => {
if batch.running {
self.send(CoreEvent::LogLinesChanged(batch.lines));
} else {
self.send(CoreEvent::LogAppended(*batch));
}
}
HostTask::LaunchStarted {
id,
session_id,
pid,
} => {
self.pending.remove(&id);
self.failed.remove(&id);
self.launches.retain(|live| live.instance != id);
self.launches.push(LiveLaunch {
instance: id.clone(),
session: session_id.clone(),
pid,
});
// 实时尾随一次只跟一场(下载页/日志页共用一个滚动窗口);
// 已有别的实例在尾随时不抢占——它的日志仍在 JSONL 里,收尾后可回放。
if self.tail.session().is_none() {
self.tail.attach(
self.env
.sessions_root()
.join("game")
.join(format!("{session_id}.jsonl")),
&session_id,
);
}
self.send(CoreEvent::InstanceRuntimeChanged {
id,
runtime: InstanceRuntime::Running { pid },
});
}
HostTask::Runtime { id, runtime } => {
self.send(CoreEvent::InstanceRuntimeChanged { id, runtime });
}
HostTask::LaunchEnd(finished) => {
// 收尾可能发生在 LaunchStarted 之前(build_request / spawn 失败只发
// LaunchEnd):pending 必须在这里也清掉,否则 in_flight 恒真、该实例
// 本会话内再也起不来。
self.pending.remove(&finished.id);
self.launches.retain(|live| live.instance != finished.id);
let runtime = if finished.outcome == LogOutcome::Ok {
self.failed.remove(&finished.id);
InstanceRuntime::Idle
} else {
// 记住失败原因:随后的 refresh_instances 会重建快照,
// 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。
self.failed
.insert(finished.id.clone(), finished.summary.clone());
InstanceRuntime::Failed {
reason: finished.summary.clone(),
}
};
self.send(CoreEvent::InstanceRuntimeChanged {
id: finished.id.clone(),
runtime,
});
self.send(CoreEvent::LaunchFinished((*finished).clone()));
// 只拆自己那场的尾随:并发时另一实例的实时日志不该被这次收尾掐掉。
if self.tail.session() == Some(finished.session.as_str()) {
self.tail.detach();
}
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
self.refresh_instances();
}
HostTask::Crash(report) => {
self.send(CoreEvent::DiagnosisReady(*report));
}
HostTask::Notice(notice) => self.push_notice(*notice),
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
}
}
/// 尾随运行中会话(每 `TAIL_POLL_TICKS` 拍一次)。
pub(super) fn poll_tail(&mut self) {
let Some((session, lines)) = self.tail.poll() else {
return;
};
if lines.is_empty() {
return;
}
let running = self.launches.iter().any(|live| live.session == session);
self.send(CoreEvent::LogAppended(crate::dto::LiveLogBatch {
session,
lines,
running,
}));
}
}
+103
View File
@@ -0,0 +1,103 @@
//! 启动身份:选中账户 → `AuthPlaceholders`(**凭据只在这里读,读完即丢**)+ 收尾结局映射。
//!
//! 与 CLI `commands/identity.rs` 同源:微软轨过期就静默续期并回写凭据(refresh_token 轮换,
//! 旧值作废);零账户回落离线占位,保证没有账户时照样能启动。
use unlucky_core::auth::account::{AccountKind, Credentials};
use unlucky_core::auth::microsoft::MicrosoftAuth;
use unlucky_core::auth::offline::offline_login;
use unlucky_core::auth::AccountStore;
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::ProfileId;
use unlucky_core::launch::request::AuthPlaceholders;
use crate::dto::LogOutcome;
use super::env::HostEnv;
/// 把选中账户翻成启动占位符(**凭据只在这里读,读完即丢**)。
///
/// 微软轨:过期就静默续期并回写凭据(refresh_token 轮换,旧值作废)。
///
/// # Errors
///
/// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。
pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders, String), String> {
let store = AccountStore::open(env.layout.auth_data());
let chosen = match account {
Some(key) => store
.find(key)
.cloned()
.ok_or_else(|| format!("找不到账户 `{key}`"))?,
None => match store.accounts() {
[] => {
let uuid = ProfileId::generate().as_str().replace('-', "");
return Ok((
AuthPlaceholders {
player_name: "Player".to_string(),
uuid,
user_type: "legacy".to_string(),
user_properties: "{}".to_string(),
..AuthPlaceholders::default()
},
"未登录账户,按离线占位启动".to_string(),
));
}
[only] => only.clone(),
many => return Err(format!("有 {} 个账户,请在账户页选一个再启动", many.len())),
},
};
let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label());
match chosen.kind {
AccountKind::Offline => {
let placeholders =
offline_login(&chosen).map_err(|error| format!("离线账户解析失败:{error}"))?;
Ok((placeholders, line))
}
AccountKind::Microsoft => {
let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else {
return Err(format!(
"账户 {} 的微软凭据缺失或类型不符,请重新登录",
chosen.display_name()
));
};
let auth =
MicrosoftAuth::from_env().map_err(|error| format!("微软登录不可用:{error}"))?;
let (fresh, _) = block_on(auth.ensure_fresh(session))
.map_err(|error| format!("微软令牌刷新失败:{error}"))?;
if &fresh != session {
// refresh_token 已轮换:立刻回写,旧值作废。
let mut store = AccountStore::open(env.layout.auth_data());
let _ = store.update_credentials(&chosen.id, Credentials::Microsoft(fresh.clone()));
let _ = store.save();
}
let placeholders = fresh
.to_placeholders()
.map_err(|error| format!("微软身份转换失败:{error}"))?;
Ok((placeholders, line))
}
AccountKind::AuthlibInjector => {
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else {
return Err(format!(
"账户 {} 的第三方凭据缺失或类型不符",
chosen.display_name()
));
};
let placeholders = session
.to_placeholders()
.map_err(|error| format!("第三方身份转换失败:{error}"))?;
Ok((placeholders, line))
}
}
}
/// `SessionHandle` → 日志页的三种结局。
pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome {
if handle.exit.silent_failure.is_some() {
LogOutcome::SilentFail
} else if handle.succeeded() {
LogOutcome::Ok
} else {
LogOutcome::Crashed
}
}
+393
View File
@@ -0,0 +1,393 @@
//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线 → 登记实例。
//!
//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。
//! Forge/NeoForge 多一段 installer processors([`super::install_processors`]):
//! `ProcessorPlan::build → 依赖库下载 → 选 Java → 串行 java -cp`,**跑完才写版本 JSON**。
//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
//!
//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge(四家,与 CLI 对齐)。OptiFine/LiteLoader
//! 两家 core 侧都还没有安装管线,仍在 GUI 里显式拒装并说清去哪装。
//! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::addon::loader::{
LoaderInstallPlan, LoaderKind as CoreLoaderKind, LoaderPlanInput, LoaderSource,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{
execute, InstallError, InstallPlan, InstallReport, PlanInput, RemoteSource,
};
use crate::dto::{CreateRequest, InstallStage, LoaderKind};
use crate::toast::{Toast, ToastKind};
use super::env::HostEnv;
use super::install_journal::Journal;
use super::install_processors::{self, StageInput};
use super::job::{spawn, HostTask, JobSender};
use super::meter::{sink_for, Meter};
/// 文件级并发上限(与 CLI 同值:16 路压满带宽又不至于打爆镜像)。
pub const CONCURRENCY: usize = 16;
/// 用户取消的稳定文案(阶段边界生效)。
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)";
/// 一次安装任务的运行上下文(后台线程内一路传下去,免得每个函数排七八个参数)。
struct Job<'a> {
env: &'a HostEnv,
tx: &'a JobSender,
meter: &'a Arc<std::sync::Mutex<Meter>>,
cancel: &'a Arc<AtomicBool>,
journal: &'a Journal,
}
impl Job<'_> {
/// processor 段的进度出口(同一套 channel/meter/journal)。
fn reporter(&self) -> install_processors::Reporter<'_> {
install_processors::Reporter {
tx: self.tx,
meter: self.meter,
journal: self.journal,
}
}
}
/// 加载器徽标(`原版` / `Fabric 0.16.5`)。
pub fn loader_label(kind: LoaderKind, version: &str) -> String {
if kind == LoaderKind::Vanilla || version.trim().is_empty() {
kind.label().to_string()
} else {
format!("{} {}", kind.label(), version)
}
}
/// GUI 侧支持的加载器(五选:原版 + 四家加载器,与 CLI 的 `--loader` 词表一致)。
///
/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝,
/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。
pub fn supported(kind: LoaderKind) -> bool {
matches!(
kind,
LoaderKind::Vanilla
| LoaderKind::Fabric
| LoaderKind::Quilt
| LoaderKind::Forge
| LoaderKind::NeoForge
)
}
/// 不支持的加载器 → 给用户的可执行建议。
pub fn unsupported_reason(kind: LoaderKind) -> String {
format!(
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge);请先在实例页导入已有版本,或换成受支持的加载器。",
kind.label()
)
}
/// 后台安装(向导提交 / 下载页行内安装共用)。
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
spawn("install", move || {
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
let journal = Journal::open(&env);
journal.line(&format!(
"—— 开始安装 {}({} {})——",
request.instance_name(),
loader_label(request.loader, ""),
request.version
));
let job = Job {
env: &env,
tx: &tx,
meter: &meter,
cancel: &cancel,
journal: &journal,
};
let result = run(&job, &request);
let mut progress = meter
.lock()
.map(|guard| guard.progress.clone())
.unwrap_or_else(|_| Meter::new(&request).progress);
match result {
Ok(name) => {
journal.line(&format!("完成:实例 {name} 已登记"));
progress.stage = InstallStage::Done;
progress.files_done = progress.files_total;
progress.message = format!("已安装 {name}({})", progress.loader);
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Ok(name),
});
let _ = tx.send(HostTask::Toast(Box::new(Toast {
id: 0,
kind: ToastKind::Success,
text: "安装完成,实例已登记".to_string(),
remaining_ms: 0,
})));
}
Err(reason) => {
let cancelled = reason == CANCELLED;
progress.stage = if cancelled {
InstallStage::Cancelled
} else {
InstallStage::Failed
};
// 失败文案带上日志路径:面板只有一行、Toast 4 秒就消失,
// 装日志是用户事后能自己回看的唯一渠道(进度板之外)。
let text = if cancelled {
reason
} else {
journal.line(&format!("安装未完成:{reason}"));
format!("{reason}(日志:{})", journal.path().display())
};
progress.message.clone_from(&text);
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Err(text),
});
}
}
});
}
/// 真安装(同步跑完,调用方在后台线程里)。
fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
if request.version.trim().is_empty() {
return Err("没有选择版本".to_string());
}
if !supported(request.loader) {
return Err(unsupported_reason(request.loader));
}
let name = request.instance_name();
let env = job.env;
let engine = super::engine::engine(env);
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(InstallStage::Planning, "正在解析版本清单…");
let _ = job
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let version_id = if request.loader == LoaderKind::Vanilla {
install_vanilla(job, &engine, request)?
} else {
install_loader(job, &engine, request)?
};
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(InstallStage::Registering, "正在登记实例…");
let _ = job
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
}
let created_at =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
unlucky_core::game::install::instance::register(
&env.layout,
&name,
&version_id,
Some(&created_at),
)
.map_err(|error| format!("登记实例失败:{error}"))?;
Ok(name)
}
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
fn install_vanilla(
job: &Job<'_>,
engine: &DownloadEngine,
request: &CreateRequest,
) -> Result<String, String> {
let env = job.env;
let version = request.version.trim();
let meta_cache = env.layout.meta_cache();
let now = now_ms();
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
block_on(async {
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
let resolved = source.resolve(version).await.map_err(install_error)?;
let fetched = match resolved
.asset_index
.as_ref()
.filter(|index| index.url.is_some())
{
Some(reference) => {
let (index, bytes) = source.asset_index(reference).await.map_err(install_error)?;
Some((index, env.game.asset_index(&reference.id), bytes))
}
None => None,
};
let mut plan = InstallPlan::build(PlanInput {
layout: &env.game,
version_id: version,
resolved: &resolved,
asset_index: fetched.as_ref().map(|(index, _, _)| index),
environment: &Environment::host(),
})
.map_err(|error| error.to_string())?;
if let Some((_, dest, bytes)) = fetched {
plan.prefetch(dest, bytes);
}
if !plan.is_installable() {
return Err(format!(
"版本 {version} 装不全:{}",
plan.warnings.join(";")
));
}
let (bytes_total, _) = plan.total_bytes();
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
guard.progress.bytes_total = bytes_total;
guard.stage(
InstallStage::Downloading,
&format!("开始下载 {} 个文件…", plan.file_count()),
);
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let report = execute(engine, &plan, CONCURRENCY, Some(sink))
.await
.map_err(install_error)?;
ensure_complete(&report)?;
Ok::<String, String>(version.to_string())
})
}
/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
fn install_loader(
job: &Job<'_>,
engine: &DownloadEngine,
request: &CreateRequest,
) -> Result<String, String> {
let env = job.env;
let version = request.version.trim();
let kind = CoreLoaderKind::parse(request.loader.as_str())
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
let meta_cache = env.layout.meta_cache();
let loader_version = request.loader_version.trim().to_string();
let explicit = (!loader_version.is_empty()).then_some(loader_version.as_str());
let release = block_on(async {
LoaderSource::new(engine, &meta_cache)
.resolve(kind, version, explicit)
.await
})
.map_err(|error| error.to_string())?;
job.journal.line(&format!(
"加载器解析:{} {} → {}",
release.kind.label(),
release.loader_version,
release.version_id
));
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &env.game,
release: &release,
environment: &Environment::host(),
vanilla_installed: env.game.is_version_installed(version),
});
if plan.vanilla_install_needed {
let mut vanilla = request.clone();
vanilla.loader = LoaderKind::Vanilla;
install_vanilla(job, engine, &vanilla)?;
}
// 两段(加载器库 + 处理器依赖库)在动手前一次预记账:进度条只走一条单调曲线,
// 处理器依赖库下完不会先满格、再在加载器库开始时回落到一半。
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
let (bytes, _) = plan.install.total_bytes();
guard.expect(plan.files().len(), bytes);
}
// Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。
if release.requires_installer() {
let input = StageInput {
kind: release.kind,
mc_version: &release.mc_version,
version_id: &release.version_id,
profile: release.install_profile.as_ref(),
installer: release.installer.as_deref(),
installed: env.game.is_version_installed(&release.version_id),
};
// `None` = 这一版不需要跑处理器(版本 JSON 已在盘上 / profile 没有 client 侧步骤),
// 进度与日志都由处理器段自己写全,这里只消费返回值。
let _ = install_processors::stage(
env,
engine,
&input,
&install_processors::CORE,
&job.reporter(),
job.cancel,
)?;
}
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(
InstallStage::Downloading,
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
);
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
let report = block_on(async {
execute(engine, &plan.install, CONCURRENCY, Some(sink))
.await
.map_err(install_error)
})?;
ensure_complete(&report)?;
Ok(release.version_id)
}
/// 安装错误 → 用户可读文案。
fn install_error(error: InstallError) -> String {
match error.hint() {
Some(hint) => format!("{}({hint})", error.message()),
None => error.message().to_string(),
}
}
/// 有文件失败就报错(列前三条明细)。
fn ensure_complete(report: &InstallReport) -> Result<(), String> {
if report.is_complete() {
return Ok(());
}
let details: Vec<String> = report
.failures(3)
.iter()
.filter_map(|outcome| {
outcome
.failure
.as_ref()
.map(|failure| format!("{}:{}", outcome.name, failure.message))
})
.collect();
Err(format!(
"{} 个文件没下成(共 {} 个):{}",
report.failed(),
report.files.len(),
details.join(";")
))
}
/// 安装目录的短显示(诊断/Toast 用)。
pub fn game_root(env: &HostEnv) -> PathBuf {
env.game.root().to_path_buf()
}
#[cfg(test)]
#[path = "install_tests.rs"]
mod tests;
+79
View File
@@ -0,0 +1,79 @@
//! 安装过程的可回溯日志(`.ULL/logs/install.log`,一轮一行)。
//!
//! 起因(M45):Forge/NeoForge 装失败时,GUI 只有一行 Toast,processor 的 stderr 尾部
//! 与「哪一步挂了」在面板一关就没了。这里把安装过程按行追加到 `.ULL/logs/`
//! (与 `auth.jsonl` 同一分区),失败文案里带上日志路径,用户能自己回看。
//!
//! 三条纪律(与 auth 日志同源):
//! 1. **只追加**,不轮转、不截断(体积由「一行一条、只记诊断字段」控制);
//! 2. **只记诊断字段**:阶段、processor 坐标、退出码、已脱敏的 stderr 尾部(core 侧脱敏);
//! 3. **写盘尽力而为**:开不了文件就静默跳过,绝不让日志失败连累安装。
use std::io::Write;
use std::path::{Path, PathBuf};
use unlucky_core::launch::time;
use super::env::HostEnv;
/// 日志文件名(在 `.ULL/logs/` 下)。
pub const INSTALL_LOG_FILE: &str = "install.log";
/// 一轮安装的日志句柄(`Clone` 很便宜:只有一个路径)。
#[derive(Debug, Clone)]
pub struct Journal {
path: PathBuf,
}
impl Journal {
/// 按数据根打开(`logs/` 不存在就补建;建不出来只是没有日志)。
pub fn open(env: &HostEnv) -> Self {
let dir = env.layout.logs();
let _ = std::fs::create_dir_all(&dir);
Self {
path: dir.join(INSTALL_LOG_FILE),
}
}
/// 日志路径(失败文案里给用户看)。
pub fn path(&self) -> &Path {
&self.path
}
/// 追加一行(自动补 RFC3339 时间戳;写失败静默)。
pub fn line(&self, text: &str) {
let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
else {
return;
};
let _ = writeln!(
file,
"[{}] {text}",
time::format_rfc3339_millis(time::now())
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_are_appended_with_a_timestamp() {
let root = std::env::temp_dir().join(format!("ull-install-journal-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let env = HostEnv::at_root(&root);
let journal = Journal::open(&env);
journal.line("第一行");
journal.line("第二行");
let text = std::fs::read_to_string(journal.path()).expect("日志存在");
assert!(text.contains("第一行") && text.contains("第二行"), "{text}");
assert_eq!(text.lines().count(), 2);
assert!(text.starts_with("[2"), "带时间戳:{text}");
assert_eq!(journal.path(), env.layout.logs().join(INSTALL_LOG_FILE));
std::fs::remove_dir_all(&root).ok();
}
}
+378
View File
@@ -0,0 +1,378 @@
//! Forge / NeoForge 的 installer processors 段(M45:GUI 与 CLI 行为对齐)。
//!
//! 顺序与 CLI `ull install --loader forge` 完全一致(`crates/cli/src/commands/install/
//! loader_processors.rs`),只是把进度接到 GUI 的回流通道上:
//!
//! ```text
//! ProcessorPlan::build(纯计算:变量表 + 每步 java -cp + 依赖库清单)
//! → download_libraries(M27 引擎;进度回流 HostTask::Install)
//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘)
//! → run(串行 java -cp;每步一条面板文案 + 一行装日志)
//! ```
//!
//! 四条硬约束(与 CLI 同源,GUI 侧一条都不放宽):
//!
//! 1. **processors 跑完才允许写版本 JSON**:版本清单存在 = 这版装完了,抢在 processors
//! 前写会留下启动必崩却被报成"已安装"的实例(顺序由 `install_loader` 保证);
//! 2. **幂等**:版本 JSON 已在盘上 = 上一轮跑完了,整段跳过;
//! 3. **取消在阶段边界生效**:处理器阶段是原子段(串行外调 java,进程内没有取消钩子),
//! 进入前与退出后各查一次 `cancel`;
//! 4. **失败不静默**:结构化文案(带 `processor_*` 大类码)+ 装日志一行一条,由调用方
//! 再补 Toast(`HostTask::InstallDone` 的既有通路)。
//!
//! 执行后端走 [`Backend`]:真实实现 [`CoreBackend`] 直接转调 core,单测注入 fake,
//! 因此「计划怎么组、Java 找不到怎么说、依赖库下漏怎么报」都能在没有网络/没有 JDK
//! 的机器上断言。
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use unlucky_core::addon::loader::forge::InstallProfile;
use unlucky_core::addon::loader::processors::{
self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink,
StepStatus,
};
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{InstallReport, PlanFile, ProgressSink};
use unlucky_core::game::java::{JavaRequirement, JavaRuntime};
use crate::dto::InstallStage;
use super::env::HostEnv;
use super::install::{CANCELLED, CONCURRENCY};
use super::install_journal::Journal;
use super::job::{HostTask, JobSender};
use super::meter::{sink_for, Meter};
/// 真实后端(无状态,可以放静态里复用)。
pub(super) const CORE: CoreBackend = CoreBackend;
/// processors 段需要的输入(从 `LoaderRelease` 摘出来,便于单测构造)。
pub(super) struct StageInput<'a> {
/// 加载器种类(补库时 maven 基址的兜底来源)。
pub kind: CoreLoaderKind,
/// 目标原版版本。
pub mc_version: &'a str,
/// loader 版本 id(依赖库落点 + 临时目录名)。
pub version_id: &'a str,
/// installer 里读出来的 install profile(`requires_installer` 为真时必须有)。
pub profile: Option<&'a InstallProfile>,
/// installer jar 的磁盘路径。
pub installer: Option<&'a Path>,
/// 版本 JSON 已在盘上 = 上一轮跑完了,整段跳过(幂等)。
pub installed: bool,
}
/// processors 段的执行后端(真实实现转调 core;单测注入 fake)。
pub(super) trait Backend {
/// 下载 processor 依赖库(M27 引擎:镜像/健康度/退避/校验)。
fn download_libraries(
&self,
engine: &DownloadEngine,
files: &[PlanFile],
sink: Option<ProgressSink>,
) -> InstallReport;
/// 按 M33 的解析链挑一个能跑 installer 的 Java。
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime>;
/// 串行跑 processors。
fn run(
&self,
plan: &ProcessorPlan,
java: &JavaRuntime,
sink: Option<StepSink>,
) -> Result<ProcessorRun, ProcessorError>;
}
/// 真实后端:三件事全部转调 core 的同一个执行器(CLI 用的就是它)。
pub(super) struct CoreBackend;
impl Backend for CoreBackend {
fn download_libraries(
&self,
engine: &DownloadEngine,
files: &[PlanFile],
sink: Option<ProgressSink>,
) -> InstallReport {
block_on(processors::download_libraries(
engine,
files,
CONCURRENCY,
sink,
))
}
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime> {
processors::select_java(managed, requirement)
}
fn run(
&self,
plan: &ProcessorPlan,
java: &JavaRuntime,
sink: Option<StepSink>,
) -> Result<ProcessorRun, ProcessorError> {
processors::run(plan, java, sink)
}
}
/// 段内的进度出口:进度面板(`HostTask::Install`)+ 装日志。
pub(super) struct Reporter<'a> {
/// 后台线程 → host 的通道。
pub tx: &'a JobSender,
/// 进度记账器。
pub meter: &'a Arc<Mutex<Meter>>,
/// 装日志。
pub journal: &'a Journal,
}
impl Reporter<'_> {
/// 换阶段并推一条进度。
fn stage(&self, stage: InstallStage, message: &str) {
let Ok(mut guard) = self.meter.lock() else {
return;
};
guard.stage(stage, message);
let _ = self
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
}
/// 先把待下的库记账(文件数 + 已知字节),下载回调再逐个累加。
fn expect(&self, files: usize, bytes: u64) {
let Ok(mut guard) = self.meter.lock() else {
return;
};
guard.expect(files, bytes);
}
/// 失败留痕(文案由调用方决定怎么呈现;这里只保证日志里有一条)。
fn fail(&self, reason: String) -> String {
self.journal.line(&format!("失败:{reason}"));
reason
}
}
/// 跑 Forge / NeoForge 的 installer processors(`Ok(None)` = 这一轮不需要跑)。
///
/// # Errors
///
/// 元数据缺 profile/installer、依赖库没下全、找不到可用 Java、processor 非零退出、
/// 产物没落盘、用户取消时返回给 UI 的文案(已写进装日志)。
pub(super) fn stage(
env: &HostEnv,
engine: &DownloadEngine,
input: &StageInput<'_>,
backend: &dyn Backend,
reporter: &Reporter<'_>,
cancel: &Arc<AtomicBool>,
) -> Result<Option<ProcessorRun>, String> {
if input.installed {
reporter.journal.line(&format!(
"{} 的版本 JSON 已在盘上,跳过 installer processors",
input.version_id
));
return Ok(None);
}
let (Some(profile), Some(installer)) = (input.profile, input.installer) else {
return Err(reporter.fail(metadata_missing(input)));
};
reporter.stage(InstallStage::Installing, "正在组 installer 处理器计划…");
let scratch = env
.layout
.cache()
.join("processors")
.join(safe_name(input.version_id));
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: input.kind,
mc_version: input.mc_version,
version_id: input.version_id,
profile,
layout: &env.game,
installer,
scratch: &scratch,
environment: &Environment::host(),
})
.map_err(|error| reporter.fail(diagnostic(&error)))?;
for warning in &plan.warnings {
reporter.journal.line(&format!("规划告警:{warning}"));
}
if plan.is_empty() {
reporter
.journal
.line("install profile 没有 client 侧 processor,跳过这一段");
return Ok(None);
}
let total = plan.step_count();
let bytes = plan.libraries.iter().filter_map(|file| file.size).sum();
reporter.expect(plan.libraries.len(), bytes);
reporter.stage(
InstallStage::Downloading,
&format!("下载 installer 依赖库({} 个文件)…", plan.libraries.len()),
);
if cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let sink = sink_for(reporter.tx.clone(), Arc::clone(reporter.meter));
let libraries = backend.download_libraries(engine, &plan.libraries, Some(sink));
if !libraries.is_complete() {
return Err(reporter.fail(libraries_failed(&libraries)));
}
let managed = env.layout.java_managed();
let Some(java) = backend.select_java(&managed, &plan.java_requirement) else {
return Err(reporter.fail(java_missing(&plan.java_requirement, &managed)));
};
reporter.journal.line(&format!(
"用 {}({})跑 {total} 步 processor",
java.binary.display(),
java_label(&java)
));
reporter.stage(
InstallStage::Installing,
&format!("运行 installer 处理器 0/{total}({})…", java_label(&java)),
);
if cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let steps = step_sink(
reporter.tx.clone(),
Arc::clone(reporter.meter),
reporter.journal.clone(),
total,
);
let run = backend
.run(&plan, &java, Some(steps))
.map_err(|error| reporter.fail(diagnostic(&error)))?;
for warning in &run.warnings {
reporter.journal.line(&format!("执行告警:{warning}"));
}
reporter.journal.line(&format!(
"processors 完成:执行 {} 步、跳过 {} 步",
run.executed(),
run.skipped()
));
reporter.stage(
InstallStage::Installing,
&format!(
"installer 处理器完成(执行 {} 步、跳过 {} 步),正在写入版本清单…",
run.executed(),
run.skipped()
),
);
if cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
Ok(Some(run))
}
/// 每步完成的回调:面板一行 + 装日志一行。
///
/// `StepSink` 要求 `'static`,所以这里全持所有权(通道/记账器/日志都是克隆)。
fn step_sink(tx: JobSender, meter: Arc<Mutex<Meter>>, journal: Journal, total: usize) -> StepSink {
Arc::new(move |outcome: &StepOutcome| {
let line = step_line(outcome, total);
journal.line(&line);
let Ok(mut guard) = meter.lock() else {
return;
};
guard.stage(InstallStage::Installing, &line);
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
})
}
/// 一步的进度/日志文案(纯函数,单测直接断言)。
pub(super) fn step_line(outcome: &StepOutcome, total: usize) -> String {
let suffix = match outcome.status {
StepStatus::Skipped => "(产物已就绪,跳过)".to_string(),
StepStatus::Executed => format!("({} ms)", outcome.elapsed_ms),
};
format!(
"installer 处理器 {}/{}:{}{suffix}",
outcome.index, total, outcome.label
)
}
/// processor 错误 → 用户可读文案(保留 `processor_*` 大类码,便于按码搜日志)。
pub(super) fn diagnostic(error: &ProcessorError) -> String {
let mut text = format!("{}({})", error.message(), error.kind().as_str());
if let Some(hint) = error.hint() {
text.push_str(";建议:");
text.push_str(hint);
}
text
}
/// 缺 Java 运行时的专用文案(比通用错误多给一条「去哪装」)。
pub(super) fn java_missing(requirement: &JavaRequirement, managed: &Path) -> String {
format!(
"跑 installer 需要 {},但一个可用的 Java 运行时都没找到;请到「Java」页安装托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
requirement.describe(),
managed.display()
)
}
/// 依赖库没下全 → 列前三条失败明细。
pub(super) fn libraries_failed(report: &InstallReport) -> String {
let details: Vec<String> = report
.failures(3)
.iter()
.filter_map(|outcome| {
outcome
.failure
.as_ref()
.map(|failure| format!("{}:{}", outcome.name, failure.message))
})
.collect();
format!(
"installer 依赖库没下全({} 个失败,共 {} 个):{}",
report.failed(),
report.files.len(),
details.join(";")
)
}
/// 需要 installer 但元数据不全(缓存半截 / 换线路重解析过)。
fn metadata_missing(input: &StageInput<'_>) -> String {
format!(
"{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径;重跑一次即可(元数据缓存可能不完整)",
input.kind.label(),
input.version_id
)
}
/// Java 的一行短标识(`Java 21 · managed`)。
fn java_label(java: &JavaRuntime) -> String {
match java.major {
Some(major) => format!("Java {major} · {}", java.origin.as_str()),
None => format!("Java 主版本未知 · {}", java.origin.as_str()),
}
}
/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉,与 CLI 同规则)。
fn safe_name(raw: &str) -> String {
raw.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect::<String>()
.trim_end_matches('.')
.to_string()
}
#[cfg(test)]
#[path = "install_processors_fixture.rs"]
mod fixture;
#[cfg(test)]
#[path = "install_processors_tests.rs"]
mod tests;
@@ -0,0 +1,272 @@
//! installer processors 段的单测夹具(拆文件守 400 行上限)。
//!
//! 不需要网络、不需要 JDK、不真的起 java:`Harness` 给一个临时数据根 + 进度通道,
//! `Fake` 回放预置的下载/Java/执行结果并记录被调顺序与组出来的命令行。
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use unlucky_core::addon::loader::forge::{InstallProfile, Processor};
use unlucky_core::addon::loader::processors::{
ProcessorError, ProcessorPlan, ProcessorRun, StepOutcome, StepSink, StepStatus,
};
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
use unlucky_core::download::{DownloadEngine, DownloadErrorKind};
use unlucky_core::game::install::{
FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink,
};
use unlucky_core::game::java::{JavaOrigin, JavaRequirement, JavaRuntime};
use super::{Backend, Reporter, StageInput};
use crate::dto::{CreateRequest, LoaderKind};
use crate::host::env::HostEnv;
use crate::host::install_journal::Journal;
use crate::host::job::{channel, HostTask, JobSender};
use crate::host::meter::Meter;
static SEQ: AtomicUsize = AtomicUsize::new(0);
/// 一步的计划视图(序号 / 坐标 / 参数 / classpath 条数)。
pub(super) type StepView = (usize, String, Vec<String>, usize);
/// 一段 cases 的公共环境:临时数据根 + 引擎 + 进度通道。
pub(super) struct Harness {
dir: PathBuf,
pub(super) env: HostEnv,
pub(super) engine: DownloadEngine,
tx: JobSender,
rx: Receiver<HostTask>,
pub(super) meter: Arc<Mutex<Meter>>,
pub(super) journal: Journal,
pub(super) cancel: Arc<AtomicBool>,
}
impl Harness {
pub(super) fn new() -> Self {
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("ull-proc-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
let engine = crate::host::engine::engine(&env);
let request = CreateRequest {
version: "1.20.1".to_string(),
loader: LoaderKind::Forge,
loader_version: "47.2.0".to_string(),
name: "proc".to_string(),
};
let (tx, rx) = channel();
Self {
journal: Journal::open(&env),
meter: Arc::new(Mutex::new(Meter::new(&request))),
cancel: Arc::new(AtomicBool::new(false)),
dir,
env,
engine,
tx,
rx,
}
}
pub(super) fn reporter(&self) -> Reporter<'_> {
Reporter {
tx: &self.tx,
meter: &self.meter,
journal: &self.journal,
}
}
/// 进度面板收到的所有文案。
pub(super) fn messages(&self) -> Vec<String> {
self.rx
.try_iter()
.filter_map(|task| match task {
HostTask::Install(progress) => Some(progress.message.clone()),
_ => None,
})
.collect()
}
/// 装日志全文。
pub(super) fn log(&self) -> String {
std::fs::read_to_string(self.journal.path()).unwrap_or_default()
}
}
impl Drop for Harness {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.dir).ok();
}
}
/// 注入用的假后端:回放预置结果 + 记录调用顺序与被组出来的命令行。
pub(super) struct Fake {
pub(super) libraries_ok: bool,
pub(super) java: Option<JavaRuntime>,
pub(super) run_error: Option<ProcessorError>,
calls: RefCell<Vec<&'static str>>,
steps: RefCell<Vec<StepView>>,
}
impl Fake {
pub(super) fn new() -> Self {
Self {
libraries_ok: true,
java: Some(java()),
run_error: None,
calls: RefCell::new(Vec::new()),
steps: RefCell::new(Vec::new()),
}
}
pub(super) fn calls(&self) -> Vec<&'static str> {
self.calls.borrow().clone()
}
/// 被组出来的每步(序号 / 坐标 / 参数 / classpath 条数)。
pub(super) fn steps(&self) -> Vec<StepView> {
self.steps.borrow().clone()
}
}
impl Backend for Fake {
fn download_libraries(
&self,
_engine: &DownloadEngine,
files: &[PlanFile],
_sink: Option<ProgressSink>,
) -> InstallReport {
self.calls.borrow_mut().push("libraries");
let ok = self.libraries_ok;
let outcomes = files
.iter()
.map(|file| FileOutcome {
role: file.role,
name: file.name.clone(),
dest: file.dest.clone(),
status: if ok {
FileStatus::Skipped
} else {
FileStatus::Failed
},
bytes: 0,
source: None,
failure: (!ok).then(|| FileFailure {
kind: DownloadErrorKind::Network,
message: "连接超时".to_string(),
}),
})
.collect();
InstallReport {
files: outcomes,
version_json: None,
warnings: Vec::new(),
}
}
fn select_java(&self, _managed: &Path, _requirement: &JavaRequirement) -> Option<JavaRuntime> {
self.calls.borrow_mut().push("java");
self.java.clone()
}
fn run(
&self,
plan: &ProcessorPlan,
java: &JavaRuntime,
sink: Option<StepSink>,
) -> Result<ProcessorRun, ProcessorError> {
self.calls.borrow_mut().push("run");
*self.steps.borrow_mut() = plan
.steps
.iter()
.map(|step| {
(
step.index,
step.label.clone(),
step.args.clone(),
step.classpath.len(),
)
})
.collect();
if let Some(error) = &self.run_error {
return Err(error.clone());
}
let outcomes: Vec<StepOutcome> = plan
.steps
.iter()
.map(|step| StepOutcome {
index: step.index,
label: step.label.clone(),
status: StepStatus::Executed,
outputs: step.outputs.len(),
elapsed_ms: 12,
stdout_tail: None,
stderr_tail: None,
})
.collect();
if let Some(sink) = &sink {
for outcome in &outcomes {
sink(outcome);
}
}
Ok(ProcessorRun {
java: java.clone(),
steps: outcomes,
warnings: Vec::new(),
})
}
}
/// 一个主版本已知的假 Java。
pub(super) fn java() -> JavaRuntime {
JavaRuntime {
binary: PathBuf::from("java"),
origin: JavaOrigin::Managed,
version: Some("21.0.4".to_string()),
major: Some(21),
bits: 64,
vendor: Some("temurin".to_string()),
}
}
/// 一个最小可用的 install profile:一个 client 侧 processor、两个字面量参数。
pub(super) fn profile() -> InstallProfile {
let mut profile = InstallProfile::default();
profile.processors.push(Processor {
sides: vec!["client".to_string()],
jar: Some("net.minecraftforge:installertools:1.3.0".to_string()),
classpath: vec!["net.minecraftforge:jarsplitter:1.1.4".to_string()],
args: vec!["--task".to_string(), "'MCP_DATA'".to_string()],
..Processor::default()
});
profile
}
pub(super) fn input<'a>(profile: &'a InstallProfile, installer: &'a Path) -> StageInput<'a> {
StageInput {
kind: CoreLoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: Some(profile),
installer: Some(installer),
installed: false,
}
}
pub(super) fn installer() -> PathBuf {
PathBuf::from("cache/meta/forge-installer.jar")
}
pub(super) fn outcome(status: StepStatus) -> StepOutcome {
StepOutcome {
index: 3,
label: "net.minecraftforge:installertools:1.3.0".to_string(),
status,
outputs: 2,
elapsed_ms: 1234,
stdout_tail: None,
stderr_tail: None,
}
}
@@ -0,0 +1,213 @@
//! host/install_processors.rs 的单测(拆文件守 400 行上限)。
//!
//! 夹具在 [`super::fixture`]:这里只断言「计划怎么组」「找不到 Java 怎么说」
//! 「哪一步挂了怎么报」「进度文案长什么样」。
use std::path::PathBuf;
use unlucky_core::addon::loader::forge::InstallProfile;
use unlucky_core::addon::loader::processors::{ProcessorError, StepStatus};
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
use unlucky_core::download::DownloadErrorKind;
use unlucky_core::game::install::{FileFailure, FileOutcome, FileRole, FileStatus, InstallReport};
use unlucky_core::game::java::JavaRequirement;
use super::fixture::{input, installer, outcome, profile, Fake, Harness};
use super::{diagnostic, java_missing, libraries_failed, stage, step_line, StageInput};
#[test]
fn step_line_names_the_step_and_marks_the_outcome() {
let executed = step_line(&outcome(StepStatus::Executed), 9);
assert_eq!(
executed,
"installer 处理器 3/9:net.minecraftforge:installertools:1.3.0(1234 ms)"
);
let skipped = step_line(&outcome(StepStatus::Skipped), 9);
assert!(skipped.contains("产物已就绪"), "{skipped}");
}
#[test]
fn diagnostic_keeps_the_stable_code_and_the_hint() {
let failed = ProcessorError::failed("net.minecraftforge:installertools:1.3.0", "1", "boom")
.with_hint("确认 Java 版本与磁盘权限");
let text = diagnostic(&failed);
assert!(text.contains("processor_failed"), "{text}");
assert!(text.contains("退出码 1"), "{text}");
assert!(text.contains("boom"), "stderr 尾部要留:{text}");
assert!(text.contains("建议:确认 Java 版本"), "{text}");
let plan = diagnostic(&ProcessorError::plan("install profile 缺 `jar`"));
assert!(plan.contains("processor_plan"), "{plan}");
}
#[test]
fn java_missing_points_at_the_java_page() {
let managed = PathBuf::from("/tmp/.ULL/java");
let text = java_missing(&JavaRequirement::table(21, None), &managed);
assert!(text.contains("Java 21+"), "{text}");
assert!(text.contains("Java」页"), "{text}");
assert!(text.contains("JAVA_HOME"), "{text}");
assert!(text.contains(&managed.display().to_string()), "{text}");
}
#[test]
fn libraries_failed_lists_at_most_three() {
let files: Vec<FileOutcome> = (0..4)
.map(|index| FileOutcome {
role: FileRole::Library,
name: format!("lib{index}.jar"),
dest: PathBuf::from(format!("lib{index}.jar")),
status: FileStatus::Failed,
bytes: 0,
source: None,
failure: Some(FileFailure {
kind: DownloadErrorKind::Network,
message: "连接超时".to_string(),
}),
})
.collect();
let report = InstallReport {
files,
version_json: None,
warnings: Vec::new(),
};
let text = libraries_failed(&report);
assert!(text.contains("4 个失败,共 4 个"), "{text}");
assert!(text.contains("lib0.jar"), "{text}");
assert!(!text.contains("lib3.jar"), "最多列三条:{text}");
}
#[test]
fn stage_skips_when_the_version_json_is_already_there() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let mut input = input(&profile, &installer);
input.installed = true;
let fake = Fake::new();
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
assert!(matches!(result, Ok(None)));
assert!(fake.calls().is_empty(), "已装完就不该再动网络/Java");
assert!(h.log().contains("跳过 installer processors"), "{}", h.log());
}
#[test]
fn stage_skips_an_empty_processor_list() {
let h = Harness::new();
let installer = installer();
let profile = InstallProfile::default();
let input = input(&profile, &installer);
let fake = Fake::new();
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
assert!(matches!(result, Ok(None)), "没有 processor 就没这段");
assert!(fake.calls().is_empty());
}
#[test]
fn stage_reports_missing_installer_metadata() {
let h = Harness::new();
let input = StageInput {
kind: CoreLoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: None,
installer: None,
installed: false,
};
let fake = Fake::new();
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("install profile"), "{err}");
assert!(h.log().contains("失败:"), "{}", h.log());
}
#[test]
fn stage_builds_the_command_line_from_the_install_profile() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let fake = Fake::new();
let run = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel)
.expect("stage 成功")
.expect("应当真的跑了 processors");
assert_eq!(run.executed(), 1);
let steps = fake.steps();
assert_eq!(steps.len(), 1);
let (index, label, args, classpath) = &steps[0];
assert_eq!(*index, 1);
assert_eq!(label, "net.minecraftforge:installertools:1.3.0");
assert_eq!(args, &vec!["--task".to_string(), "MCP_DATA".to_string()]);
assert_eq!(*classpath, 1, "jar 自己不在 -cp 里(拼命令行时才补)");
assert_eq!(fake.calls(), vec!["libraries", "java", "run"]);
let messages = h.messages();
assert!(
messages.iter().any(|m| m.contains("installer 处理器 1/1")),
"面板要看到当前处理器:{messages:?}"
);
assert!(
messages
.last()
.is_some_and(|m| m.contains("正在写入版本清单")),
"{messages:?}"
);
assert!(
h.log().contains("processors 完成:执行 1 步"),
"{}",
h.log()
);
}
#[test]
fn stage_fails_loudly_without_a_usable_java() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let mut fake = Fake::new();
fake.java = None;
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("Java"), "{err}");
assert!(err.contains("托管目录"), "{err}");
assert_eq!(
fake.calls(),
vec!["libraries", "java"],
"没 Java 就不该起进程"
);
assert!(h.log().contains("失败:"), "{}", h.log());
}
#[test]
fn stage_fails_loudly_when_a_processor_exits_nonzero() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let mut fake = Fake::new();
fake.run_error = Some(ProcessorError::failed(
"net.minecraftforge:installertools:1.3.0",
"1",
"java.lang.NoSuchMethodError",
));
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("processor_failed"), "{err}");
assert!(err.contains("NoSuchMethodError"), "{err}");
let log = h.log();
assert!(log.contains("失败:"), "{log}");
assert!(log.contains("NoSuchMethodError"), "日志要能回溯:{log}");
}
#[test]
fn stage_reports_incomplete_library_downloads() {
let h = Harness::new();
let installer = installer();
let profile = profile();
let input = input(&profile, &installer);
let mut fake = Fake::new();
fake.libraries_ok = false;
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
assert!(err.contains("依赖库没下全"), "{err}");
assert!(err.contains("连接超时"), "{err}");
assert_eq!(fake.calls(), vec!["libraries"], "库没下全就别去选 Java");
let progress = h.meter.lock().expect("进度锁").progress.clone();
assert!(progress.files_total >= 2, "依赖库要计入文件总数");
}
+218
View File
@@ -0,0 +1,218 @@
//! host/install.rs 的单测(拆文件守 400 行上限)。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
use super::{loader_label, run, supported, unsupported_reason, Job};
use crate::dto::{CreateRequest, LoaderKind};
use crate::host::env::HostEnv;
use crate::host::install_journal::Journal;
use crate::host::job::{channel, JobSender};
use crate::host::meter::Meter;
static SEQ: AtomicUsize = AtomicUsize::new(0);
/// 一个装在临时根里的任务上下文(只走「联网前就返回」的用例)。
struct Fixture {
dir: PathBuf,
env: HostEnv,
tx: JobSender,
meter: Arc<Mutex<Meter>>,
cancel: Arc<AtomicBool>,
journal: Journal,
}
impl Fixture {
fn new(request: &CreateRequest) -> Self {
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("ull-host-install-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
let journal = Journal::open(&env);
Self {
dir,
env,
tx: channel().0,
meter: Arc::new(Mutex::new(Meter::new(request))),
cancel: Arc::new(AtomicBool::new(false)),
journal,
}
}
fn job(&self) -> Job<'_> {
Job {
env: &self.env,
tx: &self.tx,
meter: &self.meter,
cancel: &self.cancel,
journal: &self.journal,
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.dir).ok();
}
}
fn request(loader: LoaderKind, version: &str) -> CreateRequest {
CreateRequest {
version: version.to_string(),
loader,
loader_version: String::new(),
name: String::new(),
}
}
/// 一个「刚下完」的文件结局(记账用)。
fn downloaded(bytes: u64) -> FileOutcome {
FileOutcome {
role: FileRole::Library,
name: "lib.jar".to_string(),
dest: PathBuf::from("lib.jar"),
status: FileStatus::Downloaded,
bytes,
source: None,
failure: None,
}
}
#[test]
fn progress_is_pre_accounted_across_segments_so_the_bar_never_snaps_to_full() {
// M45 r1 P2-2 的回归:处理器依赖库下完那一刻,面板曾显示 36/36 满格 + 3.4MB/10MB,
// 之后加载器库的 29 个文件才被计入 → 进度条从 100% 回落到 55% 再涨回去。
let request = request(LoaderKind::Forge, "1.20.1");
let mut meter = Meter::new(&request);
// 原版段:30 个文件全下完(install_vanilla 对总量是赋值,不是累加)
meter.progress.files_total = 30;
meter.progress.bytes_total = 3_000_000;
for _ in 0..30 {
meter.note(&downloaded(100_000));
}
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
// install_loader 的顺序:加载器库与处理器依赖库都在动手前一次预记账
meter.expect(29, 10_000_000);
meter.expect(36, 4_000_000);
assert_eq!(meter.progress.files_total, 95, "两段之和一次到位");
assert_eq!(
meter.progress.bytes_total, 17_000_000,
"字节是相加不是取最大"
);
assert!(
meter.progress.ratio() < 0.7,
"预记账后不能还停在满格:{}",
meter.progress.ratio()
);
// 处理器依赖库 36 个下完 → 仍没满格(加载器库还在后面)
for _ in 0..36 {
meter.note(&downloaded(110_000));
}
let ratio = meter.progress.ratio();
assert!(
(0.6..1.0).contains(&ratio),
"回落到中间态而不是满格:{ratio}"
);
assert!(
meter.progress.bytes_done < meter.progress.bytes_total,
"字节总量要覆盖已下载量:{}/{}",
meter.progress.bytes_done,
meter.progress.bytes_total
);
// 加载器库也下完 → 这时候才该是满格
for _ in 0..29 {
meter.note(&downloaded(340_000));
}
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn loader_support_matrix_is_explicit() {
// M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。
for kind in [
LoaderKind::Vanilla,
LoaderKind::Fabric,
LoaderKind::Quilt,
LoaderKind::Forge,
LoaderKind::NeoForge,
] {
assert!(supported(kind), "{} 应当受支持", kind.label());
}
// 这两家 core 侧还没有安装管线,仍然显式拒装并说清去哪装。
for kind in [LoaderKind::OptiFine, LoaderKind::LiteLoader] {
assert!(!supported(kind), "{} 不该被当成可装", kind.label());
let reason = unsupported_reason(kind);
assert!(reason.contains(kind.label()), "{reason}");
assert!(reason.contains("还没有接入"), "{reason}");
}
}
#[test]
fn forge_install_is_no_longer_refused_at_the_door() {
// 旧文案是把用户赶去终端跑 `ull install`;M45 之后 GUI 与 CLI 同一条路径。
assert!(supported(LoaderKind::Forge));
assert!(supported(LoaderKind::NeoForge));
assert_eq!(loader_label(LoaderKind::Forge, "47.2.0"), "Forge 47.2.0");
assert_eq!(
loader_label(LoaderKind::NeoForge, "21.1.80"),
"NeoForge 21.1.80"
);
}
#[test]
fn meter_counts_files_and_skips_skipped_bytes() {
let request = CreateRequest {
version: "1.20.4".to_string(),
loader: LoaderKind::Fabric,
loader_version: "0.16.5".to_string(),
name: "生存".to_string(),
};
let mut meter = Meter::new(&request);
assert_eq!(meter.progress.loader, "Fabric 0.16.5");
assert_eq!(meter.progress.instance, "生存");
meter.progress.files_total = 2;
let mut outcome = FileOutcome {
role: FileRole::Library,
name: "a.jar".to_string(),
dest: PathBuf::from("a.jar"),
status: FileStatus::Downloaded,
bytes: 100,
source: Some("bmclapi".to_string()),
failure: None,
};
meter.note(&outcome);
assert_eq!(meter.progress.files_done, 1);
assert_eq!(meter.progress.bytes_done, 100);
outcome.status = FileStatus::Skipped;
outcome.bytes = 500;
meter.note(&outcome);
assert_eq!(meter.progress.files_done, 2);
assert_eq!(meter.progress.bytes_done, 100, "跳过的文件不计入已下载字节");
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn empty_version_is_rejected_before_network() {
let request = request(LoaderKind::Vanilla, "");
let fixture = Fixture::new(&request);
let err = run(&fixture.job(), &request).unwrap_err();
assert!(err.contains("没有选择版本"), "{err}");
}
#[test]
fn unsupported_loader_is_rejected_before_network() {
let request = request(LoaderKind::OptiFine, "1.20.1");
let fixture = Fixture::new(&request);
let err = run(&fixture.job(), &request).unwrap_err();
assert!(err.contains("还没有接入"), "{err}");
}
+304
View File
@@ -0,0 +1,304 @@
//! Java 页:四家发行版 + 回退链可视化 + 本地运行时 + 托管 JDK 安装。
//!
//! 回退链的两态:
//! - **计划链**(还没装):按生效顺序铺一排「待尝试」,让用户先看清会按什么顺序找;
//! - **真实链**(装过一次/装失败):`core::game::java::ChainLog` 的每跳结局与原因。
//!
//! 安装走 `java::install`(core 自己跑回退链),host 只负责把 `ChainLog` 翻成 DTO。
use unlucky_core::game::java::probe::JavaOrigin;
use unlucky_core::game::java::source::ChainLog;
use unlucky_core::game::java::{self, JdkVendor};
use crate::dto::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
/// 托管 JDK 的默认目标大版本(D13 基准 JDK17)。
pub const DEFAULT_JDK_MAJOR: u32 = 17;
/// 该装哪个大版本:取第一个实例版本的要求,没有实例就给 17。
pub fn target_major(env: &HostEnv) -> u32 {
let profiles = super::game::profiles(env);
profiles
.profiles
.first()
.map(|profile| {
java::requirement_for_version(&env.game, &profile.version_id)
.minimum
.max(8)
})
.unwrap_or(DEFAULT_JDK_MAJOR)
}
/// 四家发行版的展示行(镜像与吞吐口径与 CLI `ull java vendors` 一致)。
pub fn vendors(env: &HostEnv, installed_vendors: &[String]) -> Vec<JavaVendorView> {
let domestic = super::engine::domestic(env);
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
let table = unlucky_core::download::MirrorTable::builtin();
order
.iter()
.enumerate()
.map(|(index, vendor)| {
let id = vendor.id();
JavaVendorView {
id: id.to_string(),
display: vendor.display().to_string(),
rank: u32::try_from(index + 1).unwrap_or(u32::MAX),
majors: vendor
.hint_majors()
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join("/"),
sources: sources_of(&table, *vendor),
installed: installed_vendors.iter().any(|known| known == id),
}
})
.collect()
}
/// 一家的源清单(与 CLI 展示同形)。
fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) -> Vec<String> {
match vendor {
JdkVendor::Temurin => table
.adoptium
.iter()
.filter(|mirror| mirror.enabled)
.map(|mirror| mirror.name.clone())
.chain(std::iter::once(
"GitHub 官方(国内套 gh-proxy)".to_string(),
))
.collect(),
JdkVendor::Oracle => table
.oracle_mirrors()
.map(|mirror| format!("{} {}", mirror.name, mirror.base()))
.collect(),
JdkVendor::Microsoft => vec!["aka.ms → VS CDN(无国内镜像)".to_string()],
JdkVendor::Zulu => table
.zulu()
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
.unwrap_or_default(),
}
}
/// 本地运行时(托管 / JAVA_HOME / PATH / 系统安装位置,同一路径只留优先级高者)。
pub fn runtimes(env: &HostEnv) -> Vec<JavaRuntimeView> {
let roots = super::engine::java_scan_roots(env);
let candidates = java::discover::dedupe(java::candidates_in_roots(&roots));
candidates
.iter()
.map(|runtime| JavaRuntimeView {
binary: runtime.binary.display().to_string(),
version: runtime
.version
.clone()
.unwrap_or_else(|| "版本未知".to_string()),
major: runtime.major.unwrap_or(java::DEFAULT_MAJOR),
vendor: runtime.vendor.clone().unwrap_or_else(|| "—".to_string()),
origin: runtime.origin.category().to_string(),
})
.collect()
}
/// 托管目录(展示「装到哪」)。
pub fn managed_roots(env: &HostEnv) -> Vec<String> {
super::engine::java_scan_roots(env)
.iter()
.map(|root| root.display().to_string())
.collect()
}
/// 计划链(按生效顺序铺「待尝试」)。
pub fn planned_chain(env: &HostEnv) -> Vec<JavaChainStep> {
let domestic = super::engine::domestic(env);
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
order
.iter()
.map(|vendor| JavaChainStep {
vendor: vendor.id().to_string(),
outcome: JavaChainOutcome::Pending,
detail: String::new(),
})
.collect()
}
/// 真实链(`ChainLog` → DTO)。
pub fn chain_steps(log: &ChainLog) -> Vec<JavaChainStep> {
log.attempts()
.iter()
.map(|attempt| JavaChainStep {
vendor: attempt.vendor.id().to_string(),
outcome: if attempt.ok {
JavaChainOutcome::Hit
} else if attempt.detail.contains("不支持") {
JavaChainOutcome::Skip
} else {
JavaChainOutcome::Failed
},
detail: attempt.detail.clone(),
})
.collect()
}
/// 整页快照。
pub fn page(env: &HostEnv, chain: Vec<JavaChainStep>, busy: bool, message: &str) -> JavaPageView {
let runtimes = runtimes(env);
let installed_vendors: Vec<String> = runtimes
.iter()
.filter(|runtime| runtime.origin == JavaOrigin::Managed.category())
.map(|runtime| runtime.vendor.clone())
.collect();
let vendor_rows = vendors(env, &installed_vendors);
let domestic = super::engine::domestic(env);
let priority_set = !super::engine::java_priority(env).is_empty();
let chain = if chain.is_empty() {
planned_chain(env)
} else {
chain
};
JavaPageView {
region: if domestic { "domestic" } else { "global" }.to_string(),
priority_source: if priority_set {
"java.vendorPriority"
} else {
"region_default"
}
.to_string(),
order_text: vendor_rows
.iter()
.map(|vendor| vendor.display.clone())
.collect::<Vec<_>>()
.join(" → "),
vendors: vendor_rows,
chain,
runtimes,
managed_roots: managed_roots(env),
busy,
message: message.to_string(),
}
}
/// 后台装一个托管 JDK(`vendor` 为空 = 按回退链自己选)。
pub fn spawn_install(env: HostEnv, tx: JobSender, vendor: Option<JdkVendor>) {
spawn("java-install", move || {
let major = target_major(&env);
let mut busy = page(&env, Vec::new(), true, &format!("正在安装 Java {major}…"));
busy.busy = true;
let _ = tx.send(HostTask::Java(Box::new(busy)));
let engine = super::engine::engine(&env);
let root = match super::engine::java_install_root(&env) {
Ok(root) => root,
Err(reason) => {
let _ = tx.send(HostTask::Java(Box::new(page(
&env,
Vec::new(),
false,
&format!("解析 Java 安装位置失败:{reason}"),
))));
return;
}
};
let request = match vendor {
Some(vendor) => java::InstallRequest::with_vendor(major, vendor)
.with_priority(super::engine::java_priority(&env)),
None => {
java::InstallRequest::new(major).with_priority(super::engine::java_priority(&env))
}
};
let result =
unlucky_core::download::blocking::block_on(java::install(&engine, &root, request));
match result {
Ok(installed) => {
let message = if installed.reused() {
format!(
"Java {}({})已就绪:{}",
installed.major,
installed.vendor.id(),
installed.root.display()
)
} else {
format!(
"已安装 Java {}({})→ {} · {} 个文件",
installed.major,
installed.vendor.display(),
installed.root.display(),
installed.files
)
};
let chain = chain_steps(&installed.chain);
let _ = tx.send(HostTask::Java(Box::new(page(&env, chain, false, &message))));
}
Err(error) => {
let _ = tx.send(HostTask::Java(Box::new(page(
&env,
Vec::new(),
false,
&format!("Java 安装失败:{error}"),
))));
}
}
});
}
/// 「安装一个 Java」的 UI 文案(下载页 Java 分组的按钮说明)。
pub fn install_hint(env: &HostEnv) -> String {
let major = target_major(env);
format!("按回退链取一个 Java {major}(托管目录,装完自动被启动链选中)")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn planned_chain_covers_four_vendors() {
let dir = std::env::temp_dir().join(format!("ull-host-java-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
let chain = planned_chain(&env);
assert_eq!(chain.len(), 4);
assert!(chain
.iter()
.all(|step| step.outcome == JavaChainOutcome::Pending));
assert_eq!(chain[0].vendor, "temurin", "国内/国外默认都是 Temurin 第一");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn chain_log_maps_to_steps() {
let mut log = ChainLog::new();
log.skip(
JdkVendor::Microsoft,
"不支持 Java 8(microsoft 只发 11/17/21/25)",
);
log.hit(JdkVendor::Temurin, "命中 Adoptium 17");
let steps = chain_steps(&log);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].outcome, JavaChainOutcome::Skip);
assert_eq!(steps[1].outcome, JavaChainOutcome::Hit);
}
#[test]
fn page_reports_region_and_priority_source() {
let dir = std::env::temp_dir().join(format!("ull-host-java-page-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config");
std::fs::write(
env.launcher_config(),
r#"{"java":{"vendorPriority":["zulu"]}}"#,
)
.expect("写配置");
let page = page(&env, Vec::new(), false, "");
assert_eq!(page.priority_source, "java.vendorPriority");
assert_eq!(page.vendors[0].id, "zulu");
assert_eq!(page.vendors[0].rank, 1);
assert!(page.order_text.contains("Azul Zulu"));
assert_eq!(page.vendors.len(), 4);
std::fs::remove_dir_all(&dir).ok();
}
}
+136
View File
@@ -0,0 +1,136 @@
//! 后台任务的回流消息:`HostTask` 是唯一「线程 → host」的载荷。
//!
//! 线程只做 core 调用(网络/磁盘),产出的都是 `crate::dto` 的 DTO;
//! host 的 `tick` 把它们翻成 [`crate::dto::CoreEvent`]。
//! 这样「跨线程传的是什么」在一处看全,也不会出现半成品领域类型漏回 UI。
use std::sync::mpsc::{Receiver, Sender};
use crate::dto::{
AccountView, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LogLine, LogOutcome, LogSession,
NoticeView, SessionId, VersionSummary,
};
use crate::toast::Toast;
/// 后台任务 → host 的消息。
#[derive(Debug, Clone, PartialEq)]
pub enum HostTask {
/// 版本清单(下载页)
Versions(Result<Vec<VersionSummary>, String>),
/// 安装进度(高频,host 只做聚合与转发)
Install(Box<InstallProgressView>),
/// 安装收尾(成功带实例名,失败带文案)
InstallDone {
/// 最终进度快照
progress: Box<InstallProgressView>,
/// 成功时为 `Ok(实例名)`
result: Result<String, String>,
},
/// 账户列表
Accounts(Vec<AccountView>),
/// 设备码登录状态
Login(Box<DeviceLoginView>),
/// Java 页(发行版/回退链/运行时)
Java(Box<JavaPageView>),
/// 日志页快照(会话列表 + 选中会话的行 + 诊断)
Logs {
/// 会话列表
sessions: Vec<LogSession>,
/// 选中会话的行
lines: Vec<LogLine>,
/// 诊断结论
diagnosis: Option<DiagnosisReport>,
},
/// 实时日志增量
Tail(Box<LiveLogBatch>),
/// 启动开始(pid + 会话 id)
LaunchStarted {
/// 实例 id
id: InstanceId,
/// 会话 id
session_id: SessionId,
/// 进程号
pid: u32,
},
/// 启动阶段推进(磁贴进度环)
Runtime {
/// 实例 id
id: InstanceId,
/// 阶段状态
runtime: InstanceRuntime,
},
/// 启动收尾
LaunchEnd(Box<LaunchFinished>),
/// 崩溃日志导入的分析结论
Crash(Box<DiagnosisReport>),
/// 横幅通知(隔离切换提示等)
Notice(Box<NoticeView>),
/// Toast
Toast(Box<Toast>),
/// 会话列表刷新(只换左栏)
Sessions(Vec<LogSession>),
}
/// 后台线程用的发送端(`Sender` 克隆即可)。
pub type JobSender = Sender<HostTask>;
/// host 侧的接收端。
pub type JobReceiver = Receiver<HostTask>;
/// 建一对通道。
pub fn channel() -> (JobSender, JobReceiver) {
std::sync::mpsc::channel()
}
/// 起一个带名字的后台线程(名字只进 panic 信息,方便定位)。
pub fn spawn(name: &str, job: impl FnOnce() + Send + 'static) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name(format!("ull-host-{name}"))
.spawn(job)
.unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}"))
}
/// 人读一行「启动结束」。
pub fn launch_summary(outcome: LogOutcome, exit_code: Option<i32>) -> String {
match outcome {
LogOutcome::Ok => "正常退出".to_string(),
LogOutcome::Crashed => match exit_code {
Some(code) => format!("崩溃(exit {code})"),
None => "崩溃".to_string(),
},
LogOutcome::SilentFail => "静默失败(exit 0 但游戏没起来)".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_carries_tasks_across_threads() {
let (tx, rx) = channel();
let handle = spawn("test", move || {
tx.send(HostTask::Toast(Box::new(Toast {
id: 1,
kind: crate::toast::ToastKind::Info,
text: "hi".to_string(),
remaining_ms: 0,
})))
.expect("发送");
});
handle.join().expect("线程结束");
let task = rx.recv().expect("收到");
assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi"));
}
#[test]
fn launch_summary_names_the_outcome() {
assert_eq!(launch_summary(LogOutcome::Ok, Some(0)), "正常退出");
assert_eq!(
launch_summary(LogOutcome::Crashed, Some(1)),
"崩溃(exit 1)"
);
assert!(launch_summary(LogOutcome::SilentFail, Some(0)).contains("静默"));
}
}
+353
View File
@@ -0,0 +1,353 @@
//! 启动全链:实例 → `LaunchRequest` → 子进程 → JSONL 事件流(GUI 版)。
//!
//! 与 CLI `ull launch` 同源同序(`core` 的 `game::resolve` / `arguments.expand` /
//! `launch::adapter::build_request` / `Launcher::spawn`),差异只有两处:
//! - **不阻塞 UI**:装配与等待都在后台线程里,进度/日志经 [`super::job::HostTask`] 回流;
//! - **身份来自 GUI 选中的账户**(`AccountStore` → `AuthPlaceholders`),凭据只在
//! 本模块内被读一次,绝不进 DTO。
//!
//! 停止:`kill_tree(pid)`(core 的三段式进程树回收),`finish()` 会照常收尾写索引。
use std::path::PathBuf;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::adapter::{
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
};
use unlucky_core::launch::request::{JavaSelection, LaunchRequest, MemoryOptions};
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::launch::tree_kill;
use unlucky_core::launch::{Launcher, LauncherConfig};
use unlucky_core::platform::paths::ProcessEnv;
use crate::dto::{
InstanceId, InstanceRuntime, LaunchFinished, LaunchStage, LoaderKind, LogOutcome, Progress,
};
use super::env::HostEnv;
use super::job::{launch_summary, HostTask, JobSender};
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
/// 一次启动需要的实例信息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchTarget {
/// 实例 id
pub id: InstanceId,
/// 实例名
pub name: String,
/// 绑定的版本 id
pub version_id: String,
/// 账户 id(`None` = 离线占位)
pub account: Option<String>,
}
/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。
pub struct JavaPick {
/// 交给 adapter 的选择
pub selection: JavaSelection,
/// 人读一行
pub summary: String,
}
/// 选一个能用的 Java:托管 > JAVA_HOME > PATH(`core::game::java` 的探测与优先级)。
pub fn pick_java(env: &HostEnv, version_id: &str) -> JavaPick {
let store = env.store();
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
let roots = unlucky_core::game::java::location::scan_roots(
env.mode(),
&env.layout,
install_root.as_deref(),
env.platform,
&ProcessEnv,
);
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
match picked {
Some(runtime) => {
let major = runtime
.major
.unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR);
let summary = format!(
"Java {}({}{})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
runtime.origin.category(),
runtime
.vendor
.as_ref()
.map(|vendor| format!(" · {vendor}"))
.unwrap_or_default()
);
JavaPick {
selection: JavaSelection::new(runtime.binary, major, runtime.bits),
summary,
}
}
None => {
let binary = PathBuf::from(unlucky_core::game::java::java_binary_name());
let major = required.minimum;
JavaPick {
selection: JavaSelection::new(binary, major, 64),
summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"),
}
}
}
}
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
///
/// # Errors
///
/// 版本 JSON 缺失/非法、继承链断裂、隔离派生失败时返回用户可读文案。
pub fn build_request(
env: &HostEnv,
target: &LaunchTarget,
) -> Result<(LaunchRequest, Vec<String>), String> {
let version_id = target.version_id.clone();
let manifest_path = env.game.version_json(&version_id);
let text = std::fs::read_to_string(&manifest_path)
.map_err(|error| format!("读版本 JSON 失败 {}:{error}", manifest_path.display()))?;
let manifest =
VersionJson::from_json_str(&text).map_err(|error| format!("版本 JSON 非法:{error}"))?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(env.game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
let resolved =
game::resolve(&source, &manifest).map_err(|error| format!("解析继承链失败:{error}"))?;
let environment = Environment::host();
let expanded = resolved
.arguments
.as_ref()
.map(|arguments| arguments.expand(&environment))
.unwrap_or_default();
let mut libraries: Vec<LibraryInput> = Vec::new();
for library in resolved.libraries.as_deref().unwrap_or_default() {
if !library.applies(&environment) || library.native_classifier(&environment).is_some() {
continue;
}
let Ok(coordinate) = library.coordinate() else {
continue;
};
let path = if library.is_local() {
env.game.version_library(&version_id, &coordinate)
} else {
env.game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, library.name.clone()));
}
let natives = unlucky_core::launch::natives::collect(
&env.game,
&version_id,
resolved.libraries.as_deref().unwrap_or_default(),
&environment,
);
let game_dir = run_dir(env, target)?;
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
let assets_root = env.game.assets_dir();
let assets_index = resolved
.assets
.clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&target.name),
version_type: None,
game_version: None,
main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game,
legacy_arguments: resolved.minecraft_arguments.as_deref(),
libraries: &libraries,
natives: &natives,
primary_jar: env.game.version_jar(&jar_id),
game_dir,
assets_index: assets_index.as_deref(),
natives_dir: env.game.natives_dir(&version_id),
library_dir: env.game.libraries_dir(),
assets_root: assets_root.clone(),
game_assets: assets_root,
};
let java = pick_java(env, &version_id);
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
let memory = env
.store()
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok());
let options = AdapterOptions {
java: java.selection,
memory: MemoryOptions {
min_mb: None,
max_mb: memory,
},
auth,
launcher_name: LAUNCHER_NAME.to_string(),
launcher_version: env!("CARGO_PKG_VERSION").to_string(),
language: "zh-CN".to_string(),
..AdapterOptions::strict()
};
let output = adapter::build_request(&snapshot, options);
let mut warnings: Vec<String> = output.warnings.iter().map(describe_warning).collect();
warnings.push(java.summary);
warnings.push(identity_line);
let mut request = output.request;
request.instance.id = target.id.clone();
request.instance.name = target.name.clone();
Ok((request, warnings))
}
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, String> {
let global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &target.id)
.map_err(|error| format!("读实例设置失败:{error}"))?;
let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None)
.map_err(|error| format!("隔离目录派生失败:{error}"))?;
if let Some(mut config) = config {
let at = format_rfc3339_millis(now());
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
let _ =
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
}
}
Ok(derivation.game_dir)
}
/// 起一次启动(后台线程;进度/日志/收尾全部走 `HostTask`)。
pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
let id = target.id.clone();
let tx_progress = tx.clone();
super::job::spawn("launch", move || {
let _ = tx_progress.send(HostTask::Runtime {
id: id.clone(),
runtime: InstanceRuntime::Busy {
stage: LaunchStage::Launch,
progress: Progress { done: 0, total: 1 },
},
});
let (request, warnings) = match build_request(&env, &target) {
Ok(ok) => ok,
Err(reason) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: String::new(),
outcome: LogOutcome::Crashed,
summary: format!("启动失败:{reason}"),
})));
return;
}
};
for warning in &warnings {
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
&format!("launch-warn:{}", target.id),
crate::dto::NoticeLevel::Info,
"启动提示",
warning,
))));
}
let config = LauncherConfig::new(env.sessions_root())
.with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
let session = match Launcher::new(config).spawn(&request) {
Ok(session) => session,
Err(error) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: String::new(),
outcome: LogOutcome::Crashed,
summary: format!("启动失败:{error}"),
})));
return;
}
};
let session_id = session.session_id().to_string();
let pid = session.pid();
let _ = tx.send(HostTask::LaunchStarted {
id: id.clone(),
session_id: session_id.clone(),
pid,
});
let _ = tx.send(HostTask::Runtime {
id: id.clone(),
runtime: InstanceRuntime::Running { pid },
});
match session.finish() {
Ok(handle) => {
let outcome = super::identity::outcome_of(&handle);
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: session_id,
outcome,
summary: format!(
"{}({},{:.1}s)",
launch_summary(outcome, Some(handle.exit.code)),
handle.exit.exit_type.as_str(),
handle.uptime_ms as f64 / 1000.0
),
})));
}
Err(error) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: session_id,
outcome: LogOutcome::SilentFail,
summary: format!("启动收尾失败:{error}"),
})));
}
}
});
}
/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。
pub fn stop(pid: u32) -> Result<(), String> {
tree_kill::kill_tree(pid, std::time::Duration::from_secs(3))
.map_err(|error| format!("停止进程 {pid} 失败:{error}"))
}
/// 适配层告警 → 人读一行。
fn describe_warning(warning: &AdapterWarning) -> String {
match warning {
AdapterWarning::MissingLibrary { name, path } => {
format!("缺少库文件 {name}({})", path.display())
}
AdapterWarning::MissingGameArguments => "版本 JSON 没有游戏参数".to_string(),
AdapterWarning::MissingAssetsIndex => "版本 JSON 没有资源索引".to_string(),
AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(),
AdapterWarning::MissingNativeJar { name, path } => {
format!("缺少 natives 构件 {name}({})", path.display())
}
}
}
/// 加载器 → 适配层的模组加载器标记(GUI 只用于显示,不改命令行)。
pub fn loader_label(kind: LoaderKind) -> &'static str {
match kind {
LoaderKind::Vanilla => "原版",
LoaderKind::Fabric => "Fabric",
LoaderKind::Quilt => "Quilt",
LoaderKind::Forge => "Forge",
LoaderKind::NeoForge => "NeoForge",
LoaderKind::OptiFine => "OptiFine",
LoaderKind::LiteLoader => "LiteLoader",
}
}
/// 隔离档位的展示串(`isolated` / `shared`)。
pub fn isolation_label(mode: IsolationMode) -> &'static str {
mode.as_str()
}
+267
View File
@@ -0,0 +1,267 @@
//! host 启动状态机单测(P2-1/P2-2 的回归网)。
//!
//! 覆盖三件事:
//! 1. `running_pid` 按实例 id 取 pid(并发启动不会把 A 的停止键指到 B);
//! 2. 重复启动守卫(`launches` 已落盘 + `pending` 窗口两态都要挡);
//! 3. `refresh_instances` 重建快照后仍保留真实运行时(Running / Busy / Failed)。
use unlucky_core::game::install::instance::write_profiles;
use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList};
use super::*;
use crate::dto::{LaunchFinished, LogOutcome};
use crate::toast::ToastKind;
/// 临时数据根守卫:测试结束(含断言 panic)时把目录删掉,不留空壳。
struct TempRoot(std::path::PathBuf);
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 建一个只有一条实例档案的数据根,返回 `(host, instance_id, 守卫)`。
fn host_with_instance(label: &str) -> (CoreHost, String, TempRoot) {
let dir = std::env::temp_dir().join(format!(
"ull-host-launch-{label}-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
let profile = InstanceProfile {
id: ProfileId::generate(),
name: "demo".to_string(),
version_id: "1.20.1".to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: std::collections::BTreeMap::new(),
};
let id = profile.id.as_str().to_string();
let profiles = ProfileList {
profiles: vec![profile],
..ProfileList::default()
};
write_profiles(&env.layout, &profiles).expect("写 profiles");
(CoreHost::headless(env), id, TempRoot(dir))
}
fn live(instance: &str, session: &str, pid: u32) -> LiveLaunch {
LiveLaunch {
instance: instance.to_string(),
session: session.to_string(),
pid,
}
}
#[test]
fn running_pid_is_per_instance_not_a_single_slot() {
let (mut host, id, _root) = host_with_instance("pid");
host.launches.push(live("a", "s-a", 111));
host.launches.push(live("b", "s-b", 222));
assert_eq!(host.running_pid("a"), Some(111));
assert_eq!(host.running_pid("b"), Some(222));
assert_eq!(
host.running_pid("c"),
None,
"没启动过的实例不该拿到别人的 pid"
);
// A 收尾后只影响 A:这正是 review r1 P2-1 的场景(旧实现共用一个槽位)。
host.launches.retain(|entry| entry.instance != "a");
assert_eq!(host.running_pid("a"), None);
assert_eq!(host.running_pid("b"), Some(222));
// 落盘实例默认是 Idle,兜底分支不误报。
assert_eq!(host.running_pid(&id), None);
}
#[test]
fn duplicate_launch_is_rejected_before_spawning() {
let (mut host, id, _root) = host_with_instance("dup");
let _ = host.drain_events();
host.launches.push(live(&id, "s", 111));
host.start_launch(&id);
let events = host.drain_events();
assert!(
!events.iter().any(|event| matches!(
event,
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
)),
"已在运行/启动中的实例不该再发 Busy(那会起第二个进程)"
);
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::Toast(toast) if toast.kind == ToastKind::Warn)),
"至少要给用户一条「已经在启动/运行中」的提示"
);
assert_eq!(host.launches.len(), 1, "启动表不该被重复启动污染");
}
#[test]
fn pending_window_also_blocks_a_second_launch() {
let (mut host, id, _root) = host_with_instance("pending");
// 走 start_launch 用的同一个入口,模拟「已受理、后台线程还没回报 LaunchStarted」。
host.mark_pending(&id);
assert!(host.in_flight(&id));
let _ = host.drain_events();
host.start_launch(&id);
let events = host.drain_events();
assert!(
!events.iter().any(|event| matches!(
event,
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
)),
"pending 窗口内重复点启动必须被挡住"
);
assert_eq!(host.pending.len(), 1);
}
#[test]
fn launch_started_registers_and_launch_end_clears() {
let (mut host, id, _root) = host_with_instance("cycle");
host.mark_pending(&id);
host.handle(HostTask::LaunchStarted {
id: id.clone(),
session_id: "20261031T120000-abcd12".to_string(),
pid: 4_242,
});
assert!(!host.pending.contains(&id), "回报后 pending 要清掉");
assert_eq!(host.running_pid(&id), Some(4_242));
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: "20261031T120000-abcd12".to_string(),
outcome: LogOutcome::Ok,
summary: "正常退出".to_string(),
})));
assert!(host.launches.is_empty(), "收尾后启动表要清空");
assert!(!host.failed.contains_key(&id));
assert_eq!(host.running_pid(&id), None);
}
#[test]
fn refresh_keeps_running_instances_running() {
let (mut host, id, _root) = host_with_instance("refresh-run");
host.launches.push(live(&id, "s", 4_242));
let _ = host.drain_events();
host.refresh_instances();
let instance = instances_from_events(host.drain_events())
.into_iter()
.find(|instance| instance.id == id)
.expect("刷新后应有该实例");
assert_eq!(
instance.runtime,
InstanceRuntime::Running { pid: 4_242 },
"刷新(装版本/账户变更/别的实例收尾)不能把在跑实例打回 Idle"
);
}
#[test]
fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
let (mut host, id, _root) = host_with_instance("refresh-busy");
// Busy:走 start_launch 用的同一个入口(mark_pending),而不是直接改字段。
host.mark_pending(&id);
let pending = instances_from_events({
host.refresh_instances();
host.drain_events()
})
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
assert!(matches!(
pending.runtime,
InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Launch,
..
}
));
// Failed:走唯一真实写入路径 LaunchEnd(不是手动塞 failed 表)。
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: "20261031T120000-dead01".to_string(),
outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(),
})));
assert!(!host.pending.contains(&id), "LaunchEnd 必须清 pending");
assert!(!host.in_flight(&id), "收尾后该实例要能被重新启动");
let failed = instances_from_events({
host.refresh_instances();
host.drain_events()
})
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
assert_eq!(
failed.runtime,
InstanceRuntime::Failed {
reason: "崩溃(exit 1)".to_string()
},
"失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)"
);
}
#[test]
fn launch_end_before_launch_started_still_releases_the_instance() {
let (mut host, id, _root) = host_with_instance("fail-early");
// build_request / spawn 失败的现场:start_launch 已标 pending、后台线程只发 LaunchEnd。
host.mark_pending(&id);
assert!(host.in_flight(&id));
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: String::new(),
outcome: LogOutcome::Crashed,
summary: "启动失败:找不到 Java".to_string(),
})));
assert!(
!host.pending.contains(&id),
"LaunchEnd 早于 LaunchStarted 时也必须清 pending(否则该实例本会话再也起不来)"
);
assert!(!host.in_flight(&id), "失败后要能重试");
let failed = instances_from_events({
host.refresh_instances();
host.drain_events()
})
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
assert_eq!(
failed.runtime,
InstanceRuntime::Failed {
reason: "启动失败:找不到 Java".to_string()
},
"早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉"
);
}
/// 从事件队列里取最后一次 `InstancesChanged`。
fn instances_from_events(events: Vec<AppEvent>) -> Vec<crate::dto::InstanceSummary> {
events
.into_iter()
.rev()
.find_map(|event| match event {
AppEvent::Core(CoreEvent::InstancesChanged(instances)) => Some(instances),
_ => None,
})
.unwrap_or_default()
}
+342
View File
@@ -0,0 +1,342 @@
//! 日志 / 诊断:会话索引 → 会话列表,事件 JSONL → 日志行,规则引擎 → 诊断卡片。
//!
//! 与 CLI `ull logs` / `ull diagnose` 同源(`launch::session_index` + `diagnose` 规则引擎):
//! 「同一份 jsonl 的两种读法」只有这一处实现(M39 ports/events.rs 的设计)。
//! 实时滚动 = host 每拍尾随启动会话的 JSONL(只读新增字节),不额外开第二条事件通道。
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use serde_json::Value;
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
use unlucky_core::diagnose::loader::RuleLoader;
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
use crate::dto::{
Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
};
use super::env::HostEnv;
/// 送进规则引擎的默认尾巴行数(与 CLI 同值,避免两条路看到不同证据)。
pub const DEFAULT_TAIL: usize = 2000;
/// 日志页一次最多渲染的行数(超出的截断,避免大文件把 UI 拖死)。
pub const MAX_RENDERED_LINES: usize = 2_000;
/// 会话索引目录(`.ULL/data/agent/sessions`)。
pub fn root(env: &HostEnv) -> PathBuf {
env.sessions_root()
}
/// 会话列表(新→旧)。
pub fn sessions(env: &HostEnv) -> Vec<LogSession> {
let index = session_index::index_path(&root(env));
let mut entries = session_index::read_all(&index).unwrap_or_default();
entries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
entries.iter().map(session_of).collect()
}
/// 索引条目 → 左栏会话行。
pub fn session_of(entry: &SessionIndexEntry) -> LogSession {
LogSession {
id: entry.session_id.clone(),
instance: if entry.instance_name.is_empty() {
entry.instance_id.clone()
} else {
entry.instance_name.clone()
},
when: display_time(&entry.started_at),
outcome: outcome_of(entry),
duration_secs: entry.duration_ms / 1000,
}
}
/// 结局:静默失败 > 崩溃(非 0 / crash_found) > 正常。
pub fn outcome_of(entry: &SessionIndexEntry) -> LogOutcome {
if entry.silent_failure {
LogOutcome::SilentFail
} else if entry.crash_found
|| entry.exit_code.is_some_and(|code| code != 0)
|| (entry.exit_code.is_none() && !entry.ended_at.is_empty())
{
LogOutcome::Crashed
} else {
LogOutcome::Ok
}
}
/// `2026-10-29T19:22:45.123Z` → `10-29 19:22`。
pub fn display_time(started_at: &str) -> String {
let body = started_at.trim_end_matches('Z');
let Some((date, time)) = body.split_once('T') else {
return started_at.to_string();
};
let date = date.get(5..).unwrap_or(date);
let time = time.get(..5).unwrap_or(time);
format!("{date} {time}")
}
/// 事件流文件路径(`<sessions>/game/<log_path>`)。
pub fn event_path(env: &HostEnv, entry: &SessionIndexEntry) -> PathBuf {
root(env).join("game").join(&entry.log_path)
}
/// 会话 id 或唯一前缀 → 索引条目。
fn find(env: &HostEnv, key: &str) -> Option<SessionIndexEntry> {
let index = session_index::index_path(&root(env));
let entries = session_index::read_all(&index).ok()?;
if let Some(exact) = entries.iter().find(|entry| entry.session_id == key) {
return Some(exact.clone());
}
entries
.iter()
.find(|entry| entry.session_id.starts_with(key))
.cloned()
}
/// 事件 JSON → 一行日志(`stdout`/`stderr` 的 `payload.line`)。
pub fn line_of(event: &Value) -> Option<LogLine> {
let text = event.pointer("/payload/line").and_then(Value::as_str)?;
let kind = event
.get("kind")
.and_then(Value::as_str)
.unwrap_or_default();
let level = match event
.pointer("/payload/level")
.and_then(Value::as_str)
.unwrap_or_default()
{
"error" => LogLevel::Error,
"warn" | "warning" => LogLevel::Warn,
"debug" | "trace" => LogLevel::Debug,
_ => {
if kind == "stderr" {
LogLevel::Warn
} else {
LogLevel::Info
}
}
};
Some(LogLine {
text: text.to_string(),
level,
})
}
/// 整个事件流 → 日志行(解析失败的行跳过,容忍被 kill 时截断的尾行)。
pub fn read_lines(path: &Path) -> Vec<LogLine> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
text.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter_map(|event| line_of(&event))
.collect()
}
/// 打开一个会话:日志行 + 规则引擎结论。
pub fn open(env: &HostEnv, key: &str) -> (Vec<LogLine>, Option<DiagnosisReport>) {
let Some(entry) = find(env, key) else {
return (Vec::new(), None);
};
let path = event_path(env, &entry);
let lines = read_lines(&path);
let diagnosis = diagnose_lines(&entry, &lines);
(lines, diagnosis)
}
/// 规则引擎跑一遍(**规则先跑,模型补位**:这里只出规则结论)。
pub fn diagnose_lines(entry: &SessionIndexEntry, lines: &[LogLine]) -> Option<DiagnosisReport> {
let stderr_text: String = lines
.iter()
.filter(|line| line.level == LogLevel::Error || line.level == LogLevel::Warn)
.map(|line| line.text.as_str())
.collect::<Vec<_>>()
.join("\n");
let body: String = lines
.iter()
.rev()
.take(DEFAULT_TAIL)
.map(|line| line.text.as_str())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
if body.is_empty() {
return None;
}
let error_lines = u32::try_from(
lines
.iter()
.filter(|line| line.level == LogLevel::Error)
.count(),
)
.unwrap_or(u32::MAX);
let exit = ExitSignal::classify(
entry.exit_code,
Some(entry.duration_ms),
error_lines,
&stderr_text,
);
let mut input = DiagnosisInput::new().with_exit(exit);
input.push_text(SignalOrigin::Stderr, body);
let loader = RuleLoader::embedded();
let report = Diagnoser::new(loader.rule_set().rules().to_vec()).analyze(&input);
let finding = report
.findings
.first()
.or_else(|| report.secondary.first())?;
Some(DiagnosisReport {
session: entry.session_id.clone(),
diagnosis: Diagnosis {
rule: finding.rule_id.clone(),
title: finding.message.clone(),
confidence: confidence_percent(finding.confidence),
hint: finding.detail.clone().unwrap_or_else(|| {
report
.exit
.as_ref()
.map(|exit| exit.exit_type.as_str().to_string())
.unwrap_or_else(|| "未知退出".to_string())
}),
fix: finding
.materialized_actions()
.first()
.map(|action| action.tool.to_string()),
},
})
}
/// 0.0~1.0 → 0~100(四舍五入)。
pub fn confidence_percent(confidence: f32) -> u8 {
let percent = (confidence.clamp(0.0, 1.0) * 100.0).round();
u8::try_from(percent as u32).unwrap_or(100)
}
/// 导入崩溃日志(`hs_err_pid*.log` / `crash-*.txt` / `latest.log`)做规则分析。
///
/// 文件名含 `hs_err` 时先走 `hs_err::parse`(结构化字段进规则引擎),否则整份文本当信号喂进去。
pub fn analyze_crash_file(path: &str) -> Result<(String, DiagnosisReport), String> {
let path = Path::new(path);
if !path.is_file() {
return Err(format!("找不到崩溃日志:{}", path.display()));
}
let text = std::fs::read_to_string(path).map_err(|error| format!("读崩溃日志失败:{error}"))?;
let name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "crash.log".to_string());
let is_hs_err = name.contains("hs_err");
let mut input = DiagnosisInput::new();
if is_hs_err {
input.push_hs_err(unlucky_core::diagnose::hs_err::parse(&text));
}
input.push_text(
if is_hs_err {
SignalOrigin::HsErr
} else if name.contains("crash") {
SignalOrigin::CrashReport
} else {
SignalOrigin::LatestLog
},
text,
);
let loader = RuleLoader::embedded();
let report = Diagnoser::new(loader.rule_set().rules().to_vec()).analyze(&input);
let finding = report
.findings
.first()
.or_else(|| report.secondary.first())
.ok_or_else(|| "规则引擎没有从这份日志里认出已知问题".to_string())?;
Ok((
format!("导入的日志:{name}"),
DiagnosisReport {
session: format!("file:{name}"),
diagnosis: Diagnosis {
rule: finding.rule_id.clone(),
title: finding.message.clone(),
confidence: confidence_percent(finding.confidence),
hint: finding
.detail
.clone()
.unwrap_or_else(|| "规则引擎命中(详见证据行)".to_string()),
fix: finding
.materialized_actions()
.first()
.map(|action| action.tool.to_string()),
},
},
))
}
/// 实时尾随器:只读文件新增的字节,解析出完整的 JSON 行。
#[derive(Debug, Default)]
pub struct Tail {
path: Option<PathBuf>,
session: Option<SessionId>,
offset: u64,
}
impl Tail {
/// 开始尾随一个会话。
pub fn attach(&mut self, path: PathBuf, session: &str) {
self.path = Some(path);
self.session = Some(session.to_string());
self.offset = 0;
}
/// 停止尾随。
pub fn detach(&mut self) {
self.path = None;
self.session = None;
self.offset = 0;
}
/// 当前尾随的会话。
pub fn session(&self) -> Option<&str> {
self.session.as_deref()
}
/// 读新行(返回 `(会话 id, 新行)`;没有新内容返回 `None`)。
pub fn poll(&mut self) -> Option<(String, Vec<LogLine>)> {
let path = self.path.clone()?;
let session = self.session.clone()?;
let file = std::fs::File::open(&path).ok()?;
let len = file.metadata().ok()?.len();
if len <= self.offset {
return None;
}
let mut file = file;
file.seek(SeekFrom::Start(self.offset)).ok()?;
let mut buffer = String::new();
file.read_to_string(&mut buffer).ok()?;
// 只吃到最后一个换行:半行留到下一拍(避免把 JSON 截断解析成脏行)
let last_newline = buffer.rfind('\n')?;
let complete = &buffer[..=last_newline];
self.offset += u64::try_from(complete.len()).unwrap_or(u64::MAX);
let lines: Vec<LogLine> = complete
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter_map(|event| line_of(&event))
.collect();
Some((session, lines))
}
}
/// 会话是否已收尾(索引里出现 `ended_at` 或文件停止增长超过一拍)。
pub fn session_finished(env: &HostEnv, session: &str) -> bool {
let index = session_index::index_path(&root(env));
session_index::read_all(&index)
.unwrap_or_default()
.iter()
.any(|entry| entry.session_id == session && !entry.ended_at.is_empty())
}
#[cfg(test)]
#[path = "logs_tests.rs"]
mod tests;
+86
View File
@@ -0,0 +1,86 @@
//! host/logs.rs 的单测(拆文件守 400 行上限)。
use super::*;
#[test]
fn time_display_and_outcomes() {
assert_eq!(display_time("2026-10-29T19:22:45.123Z"), "10-29 19:22");
assert_eq!(display_time("nope"), "nope");
let mut entry = SessionIndexEntry {
v: 1,
session_id: "s".to_string(),
instance_id: String::new(),
instance_name: String::new(),
game_version: String::new(),
started_at: String::new(),
ended_at: "2026-10-29T19:22:45Z".to_string(),
duration_ms: 1000,
exit_code: Some(0),
exit_type: Some("normal".to_string()),
silent_failure: false,
window_detected: true,
event_count: 1,
log_path: "s.jsonl".to_string(),
crash_found: false,
diagnosis_top: None,
};
assert_eq!(outcome_of(&entry), LogOutcome::Ok);
entry.exit_code = Some(1);
assert_eq!(outcome_of(&entry), LogOutcome::Crashed);
entry.silent_failure = true;
assert_eq!(outcome_of(&entry), LogOutcome::SilentFail);
}
#[test]
fn lines_map_levels_and_skip_non_line_events() {
let stdout = serde_json::json!({"kind":"stdout","payload":{"line":"hi","level":"info"}});
assert_eq!(line_of(&stdout).unwrap().level, LogLevel::Info);
let stderr = serde_json::json!({"kind":"stderr","payload":{"line":"boom","level":"error"}});
assert_eq!(line_of(&stderr).unwrap().level, LogLevel::Error);
let other = serde_json::json!({"kind":"cmd_built","payload":{"argv":[]}});
assert!(line_of(&other).is_none());
}
#[test]
fn tail_only_consumes_complete_lines() {
let dir = std::env::temp_dir().join(format!("ull-host-tail-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("目录");
let path = dir.join("s.jsonl");
std::fs::write(
&path,
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"one\"}}\n",
)
.expect("写");
let mut tail = Tail::default();
tail.attach(path.clone(), "s");
let (session, lines) = tail.poll().expect("首拍有新行");
assert_eq!(session, "s");
assert_eq!(lines.len(), 1);
assert!(tail.poll().is_none(), "没有新增就不该再产出");
// 半行不消费
let mut text = std::fs::read_to_string(&path).expect("读");
text.push_str("{\"kind\":\"stdout\",\"payload\":{\"line\":\"tw");
std::fs::write(&path, text).expect("写");
assert!(tail.poll().is_none(), "半个 JSON 行不能解析");
let mut text = std::fs::read_to_string(&path).expect("读");
text.push_str("o\"}}\n");
std::fs::write(&path, text).expect("写");
let (_, lines) = tail.poll().expect("补全后应产出");
assert_eq!(lines[0].text, "two");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn crash_import_reports_missing_file() {
let err = analyze_crash_file("D:/definitely/missing/hs_err_pid1.log").unwrap_err();
assert!(err.contains("找不到崩溃日志"), "{err}");
}
#[test]
fn confidence_percent_is_rounded_and_clamped() {
assert_eq!(confidence_percent(0.923), 92);
assert_eq!(confidence_percent(-1.0), 0);
assert_eq!(confidence_percent(2.0), 100);
}
+80
View File
@@ -0,0 +1,80 @@
//! 安装进度的记账器(文件计数 / 字节 / 速度 / ETA)。
//!
//! `execute` 的文件级回调已经把每个文件的结局给了我们,这里只做聚合;
//! 速度用相邻两次采样的字节差算(core 的事件只给绝对值)。
use std::sync::Arc;
use std::time::Instant;
use unlucky_core::game::install::{FileOutcome, FileStatus, ProgressSink};
use crate::dto::{derive_speed, CreateRequest, InstallProgressView, InstallStage};
use super::install::loader_label;
use super::job::{HostTask, JobSender};
/// 一次安装的上下文(进度回调共享)。
pub(super) struct Meter {
pub(super) progress: InstallProgressView,
previous: Option<(u64, Instant)>,
}
impl Meter {
pub(super) fn new(request: &CreateRequest) -> Self {
Self {
progress: InstallProgressView::start(
&request.version,
&request.instance_name(),
&loader_label(request.loader, &request.loader_version),
),
previous: None,
}
}
/// 换阶段(重置速度采样)。
pub(super) fn stage(&mut self, stage: InstallStage, message: &str) {
self.progress.stage = stage;
self.progress.message = message.to_string();
self.previous = None;
}
/// 预记账一段待下载的文件(文件数 + 字节)。
///
/// 一次安装可能分几段(原版 / 处理器依赖库 / 加载器库):每段开工前都先记账,
/// 整条进度曲线才是单调的——某一段下完不会先到 100%,再因为下一段的文件被计入而回落。
pub(super) fn expect(&mut self, files: usize, bytes: u64) {
let files = u64::try_from(files).unwrap_or(u64::MAX);
self.progress.files_total = self.progress.files_total.saturating_add(files);
self.progress.bytes_total = self.progress.bytes_total.saturating_add(bytes);
}
/// 记账一个文件的结局。
pub(super) fn note(&mut self, outcome: &FileOutcome) {
self.progress.files_done += 1;
if outcome.status != FileStatus::Skipped {
self.progress.bytes_done = self.progress.bytes_done.saturating_add(outcome.bytes);
}
let now = Instant::now();
if let Some(previous) = self.previous {
if let Some((speed, eta)) = derive_speed(
previous,
(self.progress.bytes_done, now),
self.progress.bytes_total,
) {
self.progress.speed_bps = speed;
self.progress.eta_secs = eta;
}
}
self.previous = Some((self.progress.bytes_done, now));
}
}
/// 进度 sink(`execute` 的文件级回调)。
pub(super) fn sink_for(tx: JobSender, meter: Arc<std::sync::Mutex<Meter>>) -> ProgressSink {
Arc::new(move |outcome: &FileOutcome| {
let Ok(mut meter) = meter.lock() else {
return;
};
meter.note(outcome);
let _ = tx.send(HostTask::Install(Box::new(meter.progress.clone())));
})
}
Loaded 100 of 570 files, more files were not shown because too many files have changed in this diff. Show more