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
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:
commit
5f8382565e
570 files changed
+104468
No files matched your search
@@ -0,0 +1,34 @@
|
||||
# 换行策略:仓库内一律 LF(rustfmt 设 newline_style = "Unix",CI 在 Linux 上跑 fmt --check)。
|
||||
# eol=lf 对工作区同样生效 —— Windows 克隆也不会被转换成 CRLF,避免「本地 fmt 过、CI fmt 挂」。
|
||||
* text=auto eol=lf
|
||||
|
||||
# 明确为文本(显式列出,防止被误判成二进制)
|
||||
*.rs text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.md text eol=lf
|
||||
*.slint text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.json text eol=lf
|
||||
Cargo.lock text eol=lf
|
||||
|
||||
# 二进制:不做换行转换、不做文本 diff
|
||||
*.png binary
|
||||
*.ico binary
|
||||
*.icns binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.zip binary
|
||||
*.7z binary
|
||||
*.gz binary
|
||||
*.tar binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.lib binary
|
||||
*.pdb binary
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
@@ -0,0 +1,28 @@
|
||||
# Reference source repositories (local-only, git-ignored)
|
||||
/HMCL/
|
||||
/PCL/
|
||||
|
||||
# Local development docs
|
||||
/DevDocs/
|
||||
|
||||
# Local acceptance screenshots (screenshot channel output, never committed)
|
||||
/.screens/
|
||||
/.tmp-screens/
|
||||
|
||||
# Agent workspace (local-only)
|
||||
/.tower/
|
||||
|
||||
# Build outputs
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
target/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
@@ -0,0 +1,5 @@
|
||||
edition = "2021"
|
||||
max_width = 100
|
||||
newline_style = "Unix"
|
||||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
||||
Generated
+7415
File diff suppressed because it is too large.
Load diff
+88
@@ -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"
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 AskaEth (YeiJ)
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,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,特此致谢。
|
||||
@@ -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.
|
||||
@@ -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 的设计与实现从它们身上学到了太多,谨以此项目向两位前辈致敬。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 与 Cargo.toml 的 rust-version 同步;改 MSRV 时两处一起改。
|
||||
msrv = "1.92"
|
||||
|
||||
# core 的模块边界很薄,参数多于 8 个基本说明该拆结构体了。
|
||||
too-many-arguments-threshold = 8
|
||||
# 领域类型(版本号、哈希、URL)普遍是短名,不做最小名长惩罚。
|
||||
min-ident-chars-threshold = 2
|
||||
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "unlucky-agent"
|
||||
description = "Unlucky-Launcher AI Agent:工具注册表 + 内置客户端 + MCP server(双形态共用一个内核)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
# 依赖方向:agent 依赖 core 的公共 API,core 不知道 agent 的存在(AGENTS.md §2)。
|
||||
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
|
||||
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
|
||||
[dependencies]
|
||||
unlucky-core = { path = "../core", version = "0.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
|
||||
@@ -0,0 +1,283 @@
|
||||
//! 审计日志:**每一次工具调用**都留痕(02 文档 §3.1 末条 / mission M16 任务 4)。
|
||||
//!
|
||||
//! 落盘位置由装配处给(`<data_root>/agent/audit/audit-<日期>.jsonl`,按天分文件,
|
||||
//! 见 `PlanDoc/04-data-and-config.md` §2)。一行一条 JSON,只追加不修改。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//! 1. **参数先脱敏再落盘**([`crate::redact::redact_args`])——`set_config` 能改 AI 端点,
|
||||
//! 参数里会有 api_key;
|
||||
//! 2. 写失败**不打断**工具执行(审计是旁路),但要把失败打到 stderr 留痕;
|
||||
//! 3. YOLO 与「是否经确认」分别记录(`yolo=true, confirmed=false` 与
|
||||
//! `yolo=false, confirmed=true` 是两件事,事故复盘的差别就在这)。
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::clock::{date_of_unix_ms, format_unix_ms, SharedClock};
|
||||
use crate::redact::redact_args;
|
||||
|
||||
/// 一次工具调用的结局(审计用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuditOutcome {
|
||||
/// 成功返回。
|
||||
Ok,
|
||||
/// 工具返回错误(含端口失败、未找到会话等)。
|
||||
ToolError,
|
||||
/// 参数没过 schema 校验(模型给错了参数)。
|
||||
InvalidArgs,
|
||||
/// 用户/策略拒绝。
|
||||
Denied,
|
||||
/// 只发出了确认请求,尚未执行(编排层在把请求交给闸门之前先记一条,
|
||||
/// 这样「AI 想改 X 但被拒」和「用户点了允许」在审计里是两条独立的证据)。
|
||||
AwaitingConfirmation,
|
||||
/// 模型调了不存在的工具。
|
||||
UnknownTool,
|
||||
}
|
||||
|
||||
/// 一条审计记录。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AuditRecord {
|
||||
/// RFC3339 UTC。
|
||||
pub ts: String,
|
||||
/// 会话 id(agent 对话框的会话,不是游戏会话)。
|
||||
pub agent_session_id: String,
|
||||
/// 工具名。
|
||||
pub tool: String,
|
||||
/// 参数(已脱敏)。
|
||||
pub args: Value,
|
||||
/// 结局。
|
||||
pub outcome: AuditOutcome,
|
||||
/// 一句话结果摘要。
|
||||
pub summary: String,
|
||||
/// 是否运行在 YOLO 模式。
|
||||
pub yolo: bool,
|
||||
/// 是否经过用户确认(YOLO 下为 false——自动放行不是"确认")。
|
||||
pub confirmed: bool,
|
||||
/// 工具执行耗时(毫秒)。
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// 审计出口。
|
||||
pub trait AuditSink: Send + Sync {
|
||||
/// 记录一条(实现方不应阻塞工具执行链;失败自行吞掉并留痕)。
|
||||
fn record(&self, record: &AuditRecord);
|
||||
}
|
||||
|
||||
/// 按天滚动的 JSONL 审计(生产实现)。
|
||||
pub struct JsonlAudit {
|
||||
dir: PathBuf,
|
||||
clock: SharedClock,
|
||||
guard: Mutex<()>,
|
||||
}
|
||||
|
||||
impl JsonlAudit {
|
||||
/// `dir` 通常是 `<data_root>/agent/audit`;目录按需创建。
|
||||
#[must_use]
|
||||
pub fn new(dir: impl Into<PathBuf>, clock: SharedClock) -> Self {
|
||||
Self {
|
||||
dir: dir.into(),
|
||||
clock,
|
||||
guard: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 给定时刻对应的文件路径(测试与 UI「查看审计」共用)。
|
||||
#[must_use]
|
||||
pub fn path_for(&self, unix_ms: u64) -> PathBuf {
|
||||
self.dir
|
||||
.join(format!("audit-{}.jsonl", date_of_unix_ms(unix_ms)))
|
||||
}
|
||||
|
||||
/// 追加一行;失败返回 `std::io::Error`(由 [`AuditSink::record`] 吞掉并打日志)。
|
||||
fn append(&self, record: &AuditRecord) -> std::io::Result<()> {
|
||||
let path = self.path_for(self.clock.now_unix_ms());
|
||||
let line = serde_json::to_string(record)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let _guard = self.guard.lock();
|
||||
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.write_all(b"\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl AuditSink for JsonlAudit {
|
||||
fn record(&self, record: &AuditRecord) {
|
||||
if let Err(err) = self.append(record) {
|
||||
// 审计是旁路:写不进去也不能让工具调用失败,但要留下痕迹。
|
||||
eprintln!("[unlucky-agent] 审计写入失败: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存审计(测试 + 「P0 不落盘」的降级路径)。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MemoryAudit {
|
||||
records: Mutex<Vec<AuditRecord>>,
|
||||
}
|
||||
|
||||
impl MemoryAudit {
|
||||
/// 空审计。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 已记录条数。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.records.lock().map(|r| r.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 是否一条都没有。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// 取快照。
|
||||
#[must_use]
|
||||
pub fn records(&self) -> Vec<AuditRecord> {
|
||||
self.records.lock().map(|r| r.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 按工具名筛出记录(测试断言用)。
|
||||
#[must_use]
|
||||
pub fn records_for(&self, tool: &str) -> Vec<AuditRecord> {
|
||||
self.records()
|
||||
.into_iter()
|
||||
.filter(|r| r.tool == tool)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuditSink for MemoryAudit {
|
||||
fn record(&self, record: &AuditRecord) {
|
||||
if let Ok(mut records) = self.records.lock() {
|
||||
records.push(record.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 丢弃审计(不推荐,只给"连内存都不想留"的极端场景/基准测试)。
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NullAudit;
|
||||
|
||||
impl AuditSink for NullAudit {
|
||||
fn record(&self, _record: &AuditRecord) {}
|
||||
}
|
||||
|
||||
/// 组装一条记录时的"其余字段"(时间戳从时钟取,参数先脱敏)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AuditDraft<'a> {
|
||||
/// 工具名。
|
||||
pub tool: &'a str,
|
||||
/// 原始参数(内部会脱敏)。
|
||||
pub args: &'a Value,
|
||||
/// 结局。
|
||||
pub outcome: AuditOutcome,
|
||||
/// 一句话结果摘要。
|
||||
pub summary: &'a str,
|
||||
/// 是否 YOLO。
|
||||
pub yolo: bool,
|
||||
/// 是否经用户确认。
|
||||
pub confirmed: bool,
|
||||
/// 耗时(毫秒)。
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// 组装一条审计记录(时间戳从时钟取、参数脱敏,调用方不用自己拼)。
|
||||
#[must_use]
|
||||
pub fn make_record(
|
||||
clock: &dyn crate::clock::Clock,
|
||||
agent_session_id: &str,
|
||||
draft: AuditDraft<'_>,
|
||||
) -> AuditRecord {
|
||||
AuditRecord {
|
||||
ts: format_unix_ms(clock.now_unix_ms()),
|
||||
agent_session_id: agent_session_id.to_string(),
|
||||
tool: draft.tool.to_string(),
|
||||
args: redact_args(draft.args),
|
||||
outcome: draft.outcome,
|
||||
summary: draft.summary.to_string(),
|
||||
yolo: draft.yolo,
|
||||
confirmed: draft.confirmed,
|
||||
duration_ms: draft.duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷:给出数据根下的审计目录(`<data_root>/agent/audit`)。
|
||||
#[must_use]
|
||||
pub fn audit_dir(data_root: &Path) -> PathBuf {
|
||||
data_root.join("agent").join("audit")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::FixedClock;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn record_redacts_args_before_store() {
|
||||
let audit = MemoryAudit::new();
|
||||
let clock = FixedClock::new(1_793_304_753_123);
|
||||
let args = json!({ "path": "ai.api_key", "value": "sk-x" });
|
||||
let rec = make_record(
|
||||
&clock,
|
||||
"s1",
|
||||
AuditDraft {
|
||||
tool: "set_config",
|
||||
args: &args,
|
||||
outcome: AuditOutcome::Ok,
|
||||
summary: "ok",
|
||||
yolo: false,
|
||||
confirmed: true,
|
||||
duration_ms: 3,
|
||||
},
|
||||
);
|
||||
audit.record(&rec);
|
||||
let stored = &audit.records()[0];
|
||||
assert_eq!(stored.args["value"], json!("***"), "敏感路径的值必须掩码");
|
||||
assert_eq!(stored.ts, "2026-10-29T20:12:33.123Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_audit_writes_one_line_per_record_per_day() {
|
||||
let dir = std::env::temp_dir().join(format!("unlucky-agent-audit-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
|
||||
let audit = JsonlAudit::new(&dir, Arc::clone(&clock));
|
||||
let args = json!({});
|
||||
let rec = make_record(
|
||||
clock.as_ref(),
|
||||
"s1",
|
||||
AuditDraft {
|
||||
tool: "get_config",
|
||||
args: &args,
|
||||
outcome: AuditOutcome::Ok,
|
||||
summary: "ok",
|
||||
yolo: false,
|
||||
confirmed: false,
|
||||
duration_ms: 1,
|
||||
},
|
||||
);
|
||||
audit.record(&rec);
|
||||
audit.record(&rec);
|
||||
let path = dir.join("audit-2026-10-29.jsonl");
|
||||
let content = std::fs::read_to_string(&path).expect("审计文件应存在");
|
||||
assert_eq!(content.lines().count(), 2);
|
||||
let first: AuditRecord = serde_json::from_str(content.lines().next().unwrap()).unwrap();
|
||||
assert_eq!(first.tool, "get_config");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! 对话协议类型 + [`ChatProvider`] trait(四态端点配置在 [`super::provider`])。
|
||||
//!
|
||||
//! 这里**没有协议方言**:OpenAI/Ollama/Anthropic 的报文差异全部由实现方
|
||||
//! (阶段 B 的 [`super::rig`])吃掉。本模块只定义循环需要的三样东西:
|
||||
//! 消息、请求/回复、流式增量回调。
|
||||
//!
|
||||
//! 一次工具调用的完整往返:
|
||||
//! `Assistant { tool_calls }` → 编排层执行 → `Tool { call_id, name, content }` → 再次补全。
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::provider::{ProviderConfig, ProviderError};
|
||||
use crate::event::{AgentEvent, AgentEventSink};
|
||||
use crate::registry::ToolDefinition;
|
||||
|
||||
/// 一条工具调用请求(模型产出)。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
/// 调用 id(OpenAI 方言的 `tool_call_id`;Ollama 原生没有 id,由适配层生成)。
|
||||
pub id: String,
|
||||
/// 工具名。
|
||||
pub name: String,
|
||||
/// 参数(JSON 对象)。
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
/// 对话消息。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "role", rename_all = "snake_case")]
|
||||
pub enum ChatMessage {
|
||||
/// 系统提示词(每轮由 [`super::BrainConfig::system_prompt`] 重新注入,不进历史)。
|
||||
System {
|
||||
/// 提示词正文。
|
||||
content: String,
|
||||
},
|
||||
/// 用户输入。
|
||||
User {
|
||||
/// 正文。
|
||||
content: String,
|
||||
},
|
||||
/// 模型回复(可能带工具调用)。
|
||||
Assistant {
|
||||
/// 文本正文(工具调用轮可能为空)。
|
||||
text: String,
|
||||
/// 工具调用。
|
||||
tool_calls: Vec<ToolCall>,
|
||||
},
|
||||
/// 工具结果回灌。
|
||||
Tool {
|
||||
/// 对应调用 id。
|
||||
call_id: String,
|
||||
/// 工具名(Ollama 原生靠 `tool_name` 配对,不是 id)。
|
||||
name: String,
|
||||
/// 结果 JSON 字符串。
|
||||
content: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// 便捷:系统消息。
|
||||
#[must_use]
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self::System {
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷:用户消息。
|
||||
#[must_use]
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self::User {
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷:工具结果消息。
|
||||
#[must_use]
|
||||
pub fn tool(
|
||||
call_id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::Tool {
|
||||
call_id: call_id.into(),
|
||||
name: name.into(),
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次补全请求。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChatRequest {
|
||||
/// 模型名。
|
||||
pub model: String,
|
||||
/// 消息(system 在首位)。
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 工具定义(双投影的 rig 侧载荷)。
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
/// 采样温度。
|
||||
pub temperature: Option<f32>,
|
||||
/// token 上限。
|
||||
pub max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
/// token 用量(本地模型可能不报)。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Usage {
|
||||
/// 输入 token。
|
||||
pub prompt_tokens: Option<u32>,
|
||||
/// 输出 token。
|
||||
pub completion_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
/// 一次补全结果。
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ChatReply {
|
||||
/// 文本正文。
|
||||
pub text: String,
|
||||
/// 工具调用(非空 ⇒ 循环继续)。
|
||||
pub tool_calls: Vec<ToolCall>,
|
||||
/// 用量。
|
||||
pub usage: Option<Usage>,
|
||||
}
|
||||
|
||||
impl ChatReply {
|
||||
/// 纯文本回复。
|
||||
#[must_use]
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 带工具调用的回复。
|
||||
#[must_use]
|
||||
pub fn with_tool_calls(tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
tool_calls,
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式增量接收者。实现方必须**非阻塞**。
|
||||
pub trait DeltaObserver: Send + Sync {
|
||||
/// 收到一段文本增量。
|
||||
fn on_delta(&self, text: &str);
|
||||
}
|
||||
|
||||
/// 丢弃增量(不关心流式的调用方)。
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopObserver;
|
||||
|
||||
impl DeltaObserver for NoopObserver {
|
||||
fn on_delta(&self, _text: &str) {}
|
||||
}
|
||||
|
||||
/// 把增量转成 [`AgentEvent::TextDelta`](P1 聊天面板逐字渲染就靠它)。
|
||||
#[derive(Clone)]
|
||||
pub struct EventDeltaObserver {
|
||||
sink: Arc<dyn AgentEventSink>,
|
||||
}
|
||||
|
||||
impl EventDeltaObserver {
|
||||
/// 用事件流构造。
|
||||
#[must_use]
|
||||
pub fn new(sink: Arc<dyn AgentEventSink>) -> Self {
|
||||
Self { sink }
|
||||
}
|
||||
}
|
||||
|
||||
impl DeltaObserver for EventDeltaObserver {
|
||||
fn on_delta(&self, text: &str) {
|
||||
self.sink.emit(AgentEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集增量(测试断言流式拼接)。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CollectingObserver {
|
||||
chunks: std::sync::Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl CollectingObserver {
|
||||
/// 收完的内容。
|
||||
#[must_use]
|
||||
pub fn collected(&self) -> String {
|
||||
self.chunks.lock().map(|c| c.concat()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeltaObserver for CollectingObserver {
|
||||
fn on_delta(&self, text: &str) {
|
||||
if let Ok(mut chunks) = self.chunks.lock() {
|
||||
chunks.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 补全未来的箱子类型。
|
||||
pub type ProviderFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Result<ChatReply, ProviderError>> + Send + 'a>>;
|
||||
|
||||
/// LLM 端点抽象:**这是 agent 唯一的异步边界**。
|
||||
///
|
||||
/// P0 实现 = 测试里的 mock;阶段 B 实现 = [`super::rig`] 的 rig 适配器。
|
||||
/// 换实现(genai / 自写循环)只需要满足本 trait,[`super::Brain`] 不动。
|
||||
pub trait ChatProvider: Send + Sync {
|
||||
/// 当前配置(循环用它取 model/temperature,也用它判断是否启用)。
|
||||
fn config(&self) -> &ProviderConfig;
|
||||
/// 一次补全。流式增量通过 `observer` 回吐。
|
||||
fn complete<'a>(
|
||||
&'a self,
|
||||
request: &'a ChatRequest,
|
||||
observer: &'a dyn DeltaObserver,
|
||||
) -> ProviderFuture<'a>;
|
||||
}
|
||||
|
||||
/// 装配期用的 provider 工厂:按配置造实现(阶段 B 在这里接 rig,测试接 mock)。
|
||||
pub type ProviderFactory =
|
||||
Arc<dyn Fn(&ProviderConfig) -> Result<Arc<dyn ChatProvider>, ProviderError> + Send + Sync>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::event::MemoryEventSink;
|
||||
|
||||
#[test]
|
||||
fn collecting_observer_concatenates_chunks() {
|
||||
let observer = CollectingObserver::default();
|
||||
observer.on_delta("正在");
|
||||
observer.on_delta("读取日志");
|
||||
assert_eq!(observer.collected(), "正在读取日志");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_observer_emits_text_delta() {
|
||||
let sink = Arc::new(MemoryEventSink::new());
|
||||
let observer = EventDeltaObserver::new(sink.clone());
|
||||
observer.on_delta("嗨");
|
||||
assert_eq!(sink.streamed_text(), "嗨");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_helpers_round_trip_through_json() {
|
||||
let messages = vec![
|
||||
ChatMessage::system("你是助手"),
|
||||
ChatMessage::user("看看日志"),
|
||||
ChatMessage::tool("call_1", "read_game_log", "{}"),
|
||||
];
|
||||
let json = serde_json::to_string(&messages).expect("可序列化");
|
||||
let back: Vec<ChatMessage> = serde_json::from_str(&json).expect("可反序列化");
|
||||
assert_eq!(back, messages);
|
||||
assert!(json.contains("\"role\":\"tool\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! 工具执行编排:schema 校验 → 分级确认 → 执行 → 审计 → 事件。
|
||||
//!
|
||||
//! **YOLO 只在这里**(mission M16 任务 3 的分工):工具自己不判断 policy,
|
||||
//! 只声明「这次需要确认 + diff」;本模块按 [`ConfirmPolicy`] 决定问不问。
|
||||
//!
|
||||
//! 一次工具调用最多两条审计记录:
|
||||
//! 1. 需要确认时先记一条 `awaiting_confirmation`(「AI 想改 X」本身必须留痕);
|
||||
//! 2. 结局记一条 `ok` / `denied` / `tool_error`(带 `yolo` 与 `confirmed` 标记)。
|
||||
//!
|
||||
//! 这两条区分了三种在复盘里完全不同的事:用户点了拒绝、YOLO 自动放行、用户点了允许。
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::audit::{make_record, AuditDraft, AuditOutcome, AuditSink};
|
||||
use crate::brain::chat::ToolCall;
|
||||
use crate::clock::SharedClock;
|
||||
use crate::confirm::{ConfirmDecision, ConfirmGate, ConfirmPolicy, ConfirmRequest, DenyGuidance};
|
||||
use crate::event::{AgentEvent, AgentEventSink};
|
||||
use crate::registry::validate::validate;
|
||||
use crate::registry::{ToolCtx, ToolError, ToolRegistry};
|
||||
|
||||
/// 一次工具执行的结局(回灌给模型的内容 + 成败标记)。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolExecOutcome {
|
||||
/// 回灌内容(结构化)。
|
||||
pub content: Value,
|
||||
/// 是否成功(UI/事件用;模型看的是 `content`)。
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
impl ToolExecOutcome {
|
||||
/// 成功。
|
||||
#[must_use]
|
||||
pub fn ok(content: Value) -> Self {
|
||||
Self { content, ok: true }
|
||||
}
|
||||
|
||||
/// 失败(`code` 给模型看,帮助它改正后重试)。
|
||||
#[must_use]
|
||||
pub fn error(code: &str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: json!({ "status": "error", "code": code, "message": message.into() }),
|
||||
ok: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 序列化成回灌给模型的字符串(工具消息的 `content`)。
|
||||
#[must_use]
|
||||
pub fn to_model_content(&self) -> String {
|
||||
serde_json::to_string(&self.content).unwrap_or_else(|_| {
|
||||
json!({ "status": "error", "code": "internal", "message": "结果序列化失败" })
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具执行器(无状态,可跨轮复用)。
|
||||
pub struct ToolExecutor {
|
||||
registry: Arc<ToolRegistry>,
|
||||
audit: Arc<dyn AuditSink>,
|
||||
gate: Arc<dyn ConfirmGate>,
|
||||
policy: ConfirmPolicy,
|
||||
clock: SharedClock,
|
||||
sink: Arc<dyn AgentEventSink>,
|
||||
guidance: DenyGuidance,
|
||||
confirm_seq: AtomicU64,
|
||||
}
|
||||
|
||||
impl ToolExecutor {
|
||||
/// 装配(默认拒绝口径 = 内置对话:[`DenyGuidance::InApp`])。
|
||||
pub fn new(
|
||||
registry: Arc<ToolRegistry>,
|
||||
audit: Arc<dyn AuditSink>,
|
||||
gate: Arc<dyn ConfirmGate>,
|
||||
policy: ConfirmPolicy,
|
||||
clock: SharedClock,
|
||||
sink: Arc<dyn AgentEventSink>,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
audit,
|
||||
gate,
|
||||
policy,
|
||||
clock,
|
||||
sink,
|
||||
guidance: DenyGuidance::InApp,
|
||||
confirm_seq: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 换拒绝口径(外部 MCP 客户端用 [`DenyGuidance::External`]:没有弹窗可点)。
|
||||
///
|
||||
/// 只改**文案**:策略、闸门、审计标记一个字都不变——这正是「同一注册表、
|
||||
/// 同一安全策略、同一审计」在代码上的落点。
|
||||
#[must_use]
|
||||
pub fn with_guidance(mut self, guidance: DenyGuidance) -> Self {
|
||||
self.guidance = guidance;
|
||||
self
|
||||
}
|
||||
|
||||
/// 执行一次工具调用(含确认与审计)。
|
||||
pub async fn execute(
|
||||
&self,
|
||||
agent_session_id: &str,
|
||||
call: &ToolCall,
|
||||
step: usize,
|
||||
) -> ToolExecOutcome {
|
||||
let started_at = self.clock.now_unix_ms();
|
||||
self.sink.emit(AgentEvent::ToolCallStarted {
|
||||
tool: call.name.clone(),
|
||||
// 与审计同一套脱敏:事件(P1 会持久化)不能带出凭据明文。
|
||||
args: crate::redact::redact_args(&call.arguments),
|
||||
step,
|
||||
});
|
||||
|
||||
let Some(spec) = self.registry.get(&call.name).cloned() else {
|
||||
let message = format!(
|
||||
"没有名为 `{}` 的工具。可用工具:{}",
|
||||
call.name,
|
||||
self.registry.names().join("、")
|
||||
);
|
||||
self.record(
|
||||
agent_session_id,
|
||||
&call.name,
|
||||
&call.arguments,
|
||||
AuditOutcome::UnknownTool,
|
||||
&message,
|
||||
false,
|
||||
false,
|
||||
started_at,
|
||||
);
|
||||
self.finish_event(&call.name, false, &message, false);
|
||||
return ToolExecOutcome::error("unknown_tool", message);
|
||||
};
|
||||
|
||||
if let Err(errors) = validate(&spec.input_schema, &call.arguments) {
|
||||
let message = format!("参数不合法:{}", errors.join(";"));
|
||||
self.record(
|
||||
agent_session_id,
|
||||
&call.name,
|
||||
&call.arguments,
|
||||
AuditOutcome::InvalidArgs,
|
||||
&message,
|
||||
false,
|
||||
false,
|
||||
started_at,
|
||||
);
|
||||
self.finish_event(&call.name, false, &message, false);
|
||||
return ToolExecOutcome::error("invalid_args", message);
|
||||
}
|
||||
|
||||
let ctx = ToolCtx {
|
||||
agent_session_id: agent_session_id.to_string(),
|
||||
confirmed: false,
|
||||
};
|
||||
let quoted = match (spec.handler)(&ctx, &call.arguments) {
|
||||
Ok(output) => output,
|
||||
Err(err) => return self.fail(agent_session_id, call, err, started_at),
|
||||
};
|
||||
|
||||
let Some(confirmation) = quoted.confirmation.clone() else {
|
||||
return self.succeed(agent_session_id, call, quoted, false, false, started_at);
|
||||
};
|
||||
|
||||
// ── 需要确认:决定问不问(YOLO 只影响这里)──
|
||||
let asked = self.policy.requires_confirmation(confirmation.sensitivity);
|
||||
let yolo_auto = !asked && confirmation.sensitivity == crate::confirm::Sensitivity::High;
|
||||
|
||||
if asked {
|
||||
self.record(
|
||||
agent_session_id,
|
||||
&call.name,
|
||||
&call.arguments,
|
||||
AuditOutcome::AwaitingConfirmation,
|
||||
&confirmation.summary,
|
||||
false,
|
||||
false,
|
||||
started_at,
|
||||
);
|
||||
let request = ConfirmRequest {
|
||||
id: format!(
|
||||
"confirm-{}",
|
||||
self.confirm_seq.fetch_add(1, Ordering::Relaxed) + 1
|
||||
),
|
||||
tool: call.name.clone(),
|
||||
summary: confirmation.summary.clone(),
|
||||
details: confirmation.details.clone(),
|
||||
sensitivity: confirmation.sensitivity,
|
||||
};
|
||||
self.sink.emit(AgentEvent::ConfirmRequested {
|
||||
request: Box::new(request.clone()),
|
||||
});
|
||||
if self.gate.request(request).await == ConfirmDecision::Denied {
|
||||
let message = self.guidance.message().to_string();
|
||||
self.record(
|
||||
agent_session_id,
|
||||
&call.name,
|
||||
&call.arguments,
|
||||
AuditOutcome::Denied,
|
||||
&message,
|
||||
false,
|
||||
false,
|
||||
started_at,
|
||||
);
|
||||
self.sink.emit(AgentEvent::ToolDenied {
|
||||
tool: call.name.clone(),
|
||||
reason: message.clone(),
|
||||
});
|
||||
self.finish_event(&call.name, false, &message, true);
|
||||
return ToolExecOutcome::error("denied", message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 已获授权(用户允许或 YOLO):带 confirmed 重放 ──
|
||||
let confirmed_ctx = ToolCtx {
|
||||
agent_session_id: agent_session_id.to_string(),
|
||||
confirmed: true,
|
||||
};
|
||||
match (spec.handler)(&confirmed_ctx, &call.arguments) {
|
||||
Ok(output) => {
|
||||
if let Some(second) = output.confirmation {
|
||||
// 工具在"已确认"状态下仍要确认 = 实现写错了,明确报出来而不是死循环。
|
||||
let message = format!(
|
||||
"工具 `{}` 在已确认状态下仍请求确认:{}",
|
||||
call.name, second.summary
|
||||
);
|
||||
self.record(
|
||||
agent_session_id,
|
||||
&call.name,
|
||||
&call.arguments,
|
||||
AuditOutcome::ToolError,
|
||||
&message,
|
||||
yolo_auto,
|
||||
asked,
|
||||
started_at,
|
||||
);
|
||||
self.finish_event(&call.name, false, &message, true);
|
||||
return ToolExecOutcome::error("internal", message);
|
||||
}
|
||||
self.succeed(agent_session_id, call, output, asked, yolo_auto, started_at)
|
||||
}
|
||||
Err(err) => {
|
||||
self.fail_after_confirm(agent_session_id, call, err, asked, yolo_auto, started_at)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn succeed(
|
||||
&self,
|
||||
agent_session_id: &str,
|
||||
call: &ToolCall,
|
||||
output: crate::registry::ToolOutput,
|
||||
confirmed: bool,
|
||||
yolo: bool,
|
||||
started_at: u64,
|
||||
) -> ToolExecOutcome {
|
||||
self.record(
|
||||
agent_session_id,
|
||||
&call.name,
|
||||
&call.arguments,
|
||||
AuditOutcome::Ok,
|
||||
&output.summary,
|
||||
yolo,
|
||||
confirmed,
|
||||
started_at,
|
||||
);
|
||||
self.finish_event(
|
||||
&call.name,
|
||||
true,
|
||||
&output.summary,
|
||||
output.confirmation.is_some(),
|
||||
);
|
||||
ToolExecOutcome::ok(output.content)
|
||||
}
|
||||
|
||||
fn fail(
|
||||
&self,
|
||||
agent_session_id: &str,
|
||||
call: &ToolCall,
|
||||
err: ToolError,
|
||||
started_at: u64,
|
||||
) -> ToolExecOutcome {
|
||||
self.fail_after_confirm(agent_session_id, call, err, false, false, started_at)
|
||||
}
|
||||
|
||||
fn fail_after_confirm(
|
||||
&self,
|
||||
agent_session_id: &str,
|
||||
call: &ToolCall,
|
||||
err: ToolError,
|
||||
confirmed: bool,
|
||||
yolo: bool,
|
||||
started_at: u64,
|
||||
) -> ToolExecOutcome {
|
||||
let message = err.message().to_string();
|
||||
self.record(
|
||||
agent_session_id,
|
||||
&call.name,
|
||||
&call.arguments,
|
||||
AuditOutcome::ToolError,
|
||||
&message,
|
||||
yolo,
|
||||
confirmed,
|
||||
started_at,
|
||||
);
|
||||
self.finish_event(&call.name, false, &message, false);
|
||||
ToolExecOutcome::error(err.code(), message)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn record(
|
||||
&self,
|
||||
agent_session_id: &str,
|
||||
tool: &str,
|
||||
args: &Value,
|
||||
outcome: AuditOutcome,
|
||||
summary: &str,
|
||||
yolo: bool,
|
||||
confirmed: bool,
|
||||
started_at: u64,
|
||||
) {
|
||||
let duration_ms = self.clock.now_unix_ms().saturating_sub(started_at);
|
||||
let draft = AuditDraft {
|
||||
tool,
|
||||
args,
|
||||
outcome,
|
||||
summary,
|
||||
yolo,
|
||||
confirmed,
|
||||
duration_ms,
|
||||
};
|
||||
self.audit
|
||||
.record(&make_record(self.clock.as_ref(), agent_session_id, draft));
|
||||
}
|
||||
|
||||
fn finish_event(&self, tool: &str, ok: bool, summary: &str, requires_confirmation: bool) {
|
||||
self.sink.emit(AgentEvent::ToolCallFinished {
|
||||
tool: tool.to_string(),
|
||||
ok,
|
||||
summary: summary.to_string(),
|
||||
requires_confirmation,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Agent 大脑:Provider 抽象 + 对话循环 + 流式事件。
|
||||
//!
|
||||
//! 选型(M10 定稿):`rig-core` + `rig-agent`,**版本锁 `=0.45.x`**——上游 12 周 5 个 minor
|
||||
//! 的 breaking 频率决定了必须锁死;依赖面只在本文件收口,其它模块不许直接碰 rig,
|
||||
//! 以便随时换 genai / 自写循环逃生(`DevDocs/PlanDoc/02-ai-agent-design.md` §0)。
|
||||
//!
|
||||
//! Provider 形态(02 §2):OpenAI 兼容(默认,含本地 llama.cpp `127.0.0.1:PORT/v1`)、
|
||||
//! Anthropic 原生、Ollama 原生、以及「禁用」——禁用时规则引擎仍可用。
|
||||
//!
|
||||
//! 统一 trait(M16 落地):`stream_chat(messages, tools) -> Stream<Event>`,含 tool_call 事件。
|
||||
//! 本 crate 的落地形态是 [`chat::ChatProvider::complete`](`request + observer → ChatReply`):
|
||||
//! rig 0.45 的流在 [`rig`] 适配层里展开成增量回调,循环侧只消费「文本增量 + 工具调用」两件事。
|
||||
//! **rig 依赖面收口在 [`rig`] 一个文件**,本文件不 `use rig_*`。
|
||||
//!
|
||||
//! ## 对话循环(02 文档 §3.3)
|
||||
//!
|
||||
//! ```text
|
||||
//! run_turn(user_input)
|
||||
//! ├─ provider.complete(request) ← 流式增量经 EventDeltaObserver → UI
|
||||
//! ├─ 有 tool_calls → ToolExecutor.execute(校验/确认/YOLO/审计)→ Tool 消息回灌
|
||||
//! └─ 没有 tool_calls → 结束,返回最终文本
|
||||
//! ```
|
||||
//!
|
||||
//! 三条不变量:
|
||||
//! 1. **system prompt 每轮重新注入**,不进历史(改提示词立刻生效,也不会被压缩逻辑吃掉);
|
||||
//! 2. 工具结果**永远回灌**(包括失败),模型才有机会改正;
|
||||
//! 3. 步数上限 [`DEFAULT_MAX_STEPS`] 兜底,防止模型在两个工具间打转烧 token。
|
||||
|
||||
pub mod chat;
|
||||
pub mod exec;
|
||||
pub mod provider;
|
||||
pub mod rig;
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::audit::AuditSink;
|
||||
use crate::clock::SharedClock;
|
||||
use crate::confirm::{ConfirmGate, ConfirmPolicy};
|
||||
use crate::event::{AgentEvent, AgentEventSink};
|
||||
use crate::registry::ToolRegistry;
|
||||
|
||||
pub use chat::{
|
||||
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, EventDeltaObserver,
|
||||
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
|
||||
};
|
||||
pub use exec::{ToolExecOutcome, ToolExecutor};
|
||||
pub use provider::{ProviderConfig, ProviderError, ProviderKind};
|
||||
|
||||
/// 一轮对话最多几轮工具调用。
|
||||
pub const DEFAULT_MAX_STEPS: usize = 8;
|
||||
|
||||
/// 内置系统提示词(P0 版本;P2 会按实例上下文按需注入)。
|
||||
#[must_use]
|
||||
pub fn default_system_prompt() -> String {
|
||||
[
|
||||
"你是 Unlucky-Launcher(ULL,Minecraft 启动器)的内置助手。",
|
||||
"你能调用工具:读配置(get_config)、改配置(set_config)、列出游戏会话(list_game_sessions)、",
|
||||
"读会话日志(read_game_log)、跑崩溃规则引擎(diagnose_crash)。",
|
||||
"纪律:",
|
||||
"1. 先查后改:改任何配置前先用 get_config 确认键名与现值。",
|
||||
"2. 崩溃问题先看 diagnose_crash 的规则结论;规则未命中或需要更多信息时,才用 read_game_log",
|
||||
" 取 stderr / exit / crash_found,不要拉全量日志。",
|
||||
"3. 只能改 get_config 列出的可写键。敏感键会要求用户确认:被拒绝就换方案,不要重复尝试同一改动。",
|
||||
"4. 用中文回答,给用户可以立即执行的下一步;不确定时直说不确定,不要编造规则或日志内容。",
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// 循环参数。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrainConfig {
|
||||
/// 最多几轮工具调用。
|
||||
pub max_steps: usize,
|
||||
/// 系统提示词。
|
||||
pub system_prompt: String,
|
||||
}
|
||||
|
||||
impl Default for BrainConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_steps: DEFAULT_MAX_STEPS,
|
||||
system_prompt: default_system_prompt(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrainConfig {
|
||||
/// 自定义提示词(步数用默认)。
|
||||
#[must_use]
|
||||
pub fn with_system_prompt(system_prompt: impl Into<String>) -> Self {
|
||||
Self {
|
||||
max_steps: DEFAULT_MAX_STEPS,
|
||||
system_prompt: system_prompt.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 循环的旁路依赖(审计/确认/时钟/事件)。
|
||||
pub struct BrainDeps {
|
||||
/// 审计出口。
|
||||
pub audit: Arc<dyn AuditSink>,
|
||||
/// 确认闸门(P0 = [`crate::confirm::NoUiGate`])。
|
||||
pub gate: Arc<dyn ConfirmGate>,
|
||||
/// YOLO 等策略快照。
|
||||
pub policy: ConfirmPolicy,
|
||||
/// 时间源。
|
||||
pub clock: SharedClock,
|
||||
/// 事件出口。
|
||||
pub sink: Arc<dyn AgentEventSink>,
|
||||
}
|
||||
|
||||
impl BrainDeps {
|
||||
/// 装配。
|
||||
pub fn new(
|
||||
audit: Arc<dyn AuditSink>,
|
||||
gate: Arc<dyn ConfirmGate>,
|
||||
policy: ConfirmPolicy,
|
||||
clock: SharedClock,
|
||||
sink: Arc<dyn AgentEventSink>,
|
||||
) -> Self {
|
||||
Self {
|
||||
audit,
|
||||
gate,
|
||||
policy,
|
||||
clock,
|
||||
sink,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一轮对话的结局。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TurnOutcome {
|
||||
/// 最终文本(可能是空的,例如撞上步数上限且模型最后一句只有工具调用)。
|
||||
pub text: String,
|
||||
/// 用掉的轮次。
|
||||
pub steps: usize,
|
||||
/// 工具调用总次数。
|
||||
pub tool_calls: usize,
|
||||
/// 为什么停下来。
|
||||
pub stopped_reason: StopReason,
|
||||
}
|
||||
|
||||
/// 停止原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StopReason {
|
||||
/// 模型给出了最终回答。
|
||||
Completed,
|
||||
/// 撞上 [`BrainConfig::max_steps`]。
|
||||
StepLimit,
|
||||
}
|
||||
|
||||
/// 循环级错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BrainError {
|
||||
/// AI 被禁用(规则引擎仍可用)。
|
||||
Disabled,
|
||||
/// provider 报错。
|
||||
Provider(ProviderError),
|
||||
}
|
||||
|
||||
impl fmt::Display for BrainError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Disabled => f.write_str(DISABLED_MESSAGE),
|
||||
Self::Provider(err) => write!(f, "LLM 调用失败:{err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BrainError {}
|
||||
|
||||
/// AI 被禁用时给用户的话(装配层直接展示,不用先构造 Brain)。
|
||||
pub const DISABLED_MESSAGE: &str =
|
||||
"AI 已禁用:请在设置 → AI 中配置端点。规则引擎崩溃诊断不受影响。";
|
||||
|
||||
/// 对话循环。
|
||||
pub struct Brain {
|
||||
provider: Arc<dyn ChatProvider>,
|
||||
registry: Arc<ToolRegistry>,
|
||||
executor: ToolExecutor,
|
||||
config: BrainConfig,
|
||||
sink: Arc<dyn AgentEventSink>,
|
||||
}
|
||||
|
||||
impl Brain {
|
||||
/// 装配。
|
||||
pub fn new(
|
||||
provider: Arc<dyn ChatProvider>,
|
||||
registry: Arc<ToolRegistry>,
|
||||
deps: BrainDeps,
|
||||
config: BrainConfig,
|
||||
) -> Self {
|
||||
let executor = ToolExecutor::new(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&deps.audit),
|
||||
Arc::clone(&deps.gate),
|
||||
deps.policy,
|
||||
Arc::clone(&deps.clock),
|
||||
Arc::clone(&deps.sink),
|
||||
);
|
||||
Self {
|
||||
provider,
|
||||
registry,
|
||||
executor,
|
||||
config,
|
||||
sink: deps.sink,
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前 provider 配置(设置页/UI 展示用)。
|
||||
#[must_use]
|
||||
pub fn provider_config(&self) -> &ProviderConfig {
|
||||
self.provider.config()
|
||||
}
|
||||
|
||||
/// 跑一轮对话。`history` 由调用方持有(P1 落盘为 `agent/sessions/*.jsonl`)。
|
||||
///
|
||||
/// # Errors
|
||||
/// AI 被禁用([`BrainError::Disabled`])或 provider 报错([`BrainError::Provider`])。
|
||||
pub async fn run_turn(
|
||||
&self,
|
||||
agent_session_id: &str,
|
||||
history: &mut Vec<ChatMessage>,
|
||||
user_input: &str,
|
||||
) -> Result<TurnOutcome, BrainError> {
|
||||
if !self.provider.config().is_enabled() {
|
||||
self.sink.emit(AgentEvent::Error {
|
||||
message: DISABLED_MESSAGE.to_string(),
|
||||
});
|
||||
return Err(BrainError::Disabled);
|
||||
}
|
||||
|
||||
self.sink.emit(AgentEvent::TurnStarted {
|
||||
session_id: agent_session_id.to_string(),
|
||||
user_text: user_input.to_string(),
|
||||
});
|
||||
history.push(ChatMessage::user(user_input));
|
||||
|
||||
let observer = EventDeltaObserver::new(Arc::clone(&self.sink));
|
||||
let mut tool_calls = 0usize;
|
||||
let mut last_text = String::new();
|
||||
let mut used_steps = 0usize;
|
||||
|
||||
for step in 1..=self.config.max_steps {
|
||||
used_steps = step;
|
||||
let request = self.build_request(history);
|
||||
let reply = match self.provider.complete(&request, &observer).await {
|
||||
Ok(reply) => reply,
|
||||
Err(err) => {
|
||||
self.sink.emit(AgentEvent::Error {
|
||||
message: err.to_string(),
|
||||
});
|
||||
return Err(BrainError::Provider(err));
|
||||
}
|
||||
};
|
||||
|
||||
if !reply.text.trim().is_empty() {
|
||||
last_text = reply.text.clone();
|
||||
self.sink.emit(AgentEvent::AssistantText {
|
||||
text: reply.text.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if reply.tool_calls.is_empty() {
|
||||
history.push(ChatMessage::Assistant {
|
||||
text: reply.text.clone(),
|
||||
tool_calls: Vec::new(),
|
||||
});
|
||||
self.sink.emit(AgentEvent::TurnFinished {
|
||||
steps: step,
|
||||
text: reply.text.clone(),
|
||||
});
|
||||
return Ok(TurnOutcome {
|
||||
text: reply.text,
|
||||
steps: step,
|
||||
tool_calls,
|
||||
stopped_reason: StopReason::Completed,
|
||||
});
|
||||
}
|
||||
|
||||
history.push(ChatMessage::Assistant {
|
||||
text: reply.text.clone(),
|
||||
tool_calls: reply.tool_calls.clone(),
|
||||
});
|
||||
for call in &reply.tool_calls {
|
||||
let outcome = self.executor.execute(agent_session_id, call, step).await;
|
||||
tool_calls += 1;
|
||||
history.push(ChatMessage::tool(
|
||||
call.id.clone(),
|
||||
call.name.clone(),
|
||||
outcome.to_model_content(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.sink.emit(AgentEvent::TurnFinished {
|
||||
steps: used_steps,
|
||||
text: last_text.clone(),
|
||||
});
|
||||
Ok(TurnOutcome {
|
||||
text: last_text,
|
||||
steps: used_steps,
|
||||
tool_calls,
|
||||
stopped_reason: StopReason::StepLimit,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_request(&self, history: &[ChatMessage]) -> ChatRequest {
|
||||
let config = self.provider.config();
|
||||
let mut messages = Vec::with_capacity(history.len() + 1);
|
||||
messages.push(ChatMessage::system(self.config.system_prompt.clone()));
|
||||
messages.extend_from_slice(history);
|
||||
ChatRequest {
|
||||
model: config.model.clone(),
|
||||
messages,
|
||||
tools: self.registry.definitions(),
|
||||
temperature: config.temperature,
|
||||
max_tokens: config.max_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Provider 四态端点配置(02 文档 §2)。
|
||||
//!
|
||||
//! | 形态 | 端点 | 备注 |
|
||||
//! |------|------|------|
|
||||
//! | OpenAI 兼容(默认) | `base_url` + `api_key` + `model` | 覆盖 OpenAI/DeepSeek/Qwen/GLM/**本地 llama.cpp server**(`http://127.0.0.1:8080/v1`)/LM Studio |
|
||||
//! | Anthropic 原生 | `api_key` + `model` | Claude 系列(没有可用的 Rust 单厂商 SDK,见 research/02 §3.6) |
|
||||
//! | Ollama 原生 | `http://127.0.0.1:11434` | 本地另一种主流 |
|
||||
//! | 禁用 | — | 完全关闭 agent,规则引擎(`diagnose_crash`)仍可用 |
|
||||
//!
|
||||
//! 本文件只放**配置与校验**;对话协议在 [`super::chat`]。这样 rig 的 pre-1.0 破坏面
|
||||
//! (research/02 §7 R1)被关在 [`super::rig`] 一个文件里,这里连 rig 的名字都不出现。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
|
||||
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
|
||||
/// Ollama 默认端点(research/02 §5.2)。
|
||||
pub const OLLAMA_DEFAULT_BASE_URL: &str = "http://127.0.0.1:11434";
|
||||
/// Anthropic 官方 API 端点。
|
||||
pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
|
||||
|
||||
/// Provider 四态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderKind {
|
||||
/// OpenAI 兼容(含本地 llama.cpp / LM Studio / DeepSeek / Qwen…)。
|
||||
OpenAiCompatible,
|
||||
/// Anthropic 原生。
|
||||
Anthropic,
|
||||
/// Ollama 原生。
|
||||
Ollama,
|
||||
/// 完全禁用 agent。
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
/// 该形态是否必须提供 API key(本地推理通常不需要)。
|
||||
#[must_use]
|
||||
pub fn requires_api_key(self) -> bool {
|
||||
matches!(self, Self::Anthropic)
|
||||
}
|
||||
|
||||
/// 该形态的默认端点(OpenAI 兼容没有统一默认值,必须显式填;
|
||||
/// 本地 llama.cpp 走 [`ProviderConfig::llamacpp_local`] 带的显式默认)。
|
||||
#[must_use]
|
||||
pub fn default_base_url(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiCompatible => "",
|
||||
Self::Anthropic => ANTHROPIC_DEFAULT_BASE_URL,
|
||||
Self::Ollama => OLLAMA_DEFAULT_BASE_URL,
|
||||
Self::Disabled => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页展示名。
|
||||
#[must_use]
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiCompatible => "OpenAI 兼容",
|
||||
Self::Anthropic => "Anthropic 原生",
|
||||
Self::Ollama => "Ollama 原生",
|
||||
Self::Disabled => "已禁用",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 配置(设置 → AI 的一份快照)。
|
||||
///
|
||||
/// `api_key` 只在进程内存在,**任何日志/审计/事件都不得带它**——
|
||||
/// `Debug` 是手写的(掩码 key),要连形态一起看用 [`ProviderConfig::redacted_debug`]。
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
/// 四态。
|
||||
pub kind: ProviderKind,
|
||||
/// 端点;空串表示用 [`ProviderKind::default_base_url`]。
|
||||
pub base_url: String,
|
||||
/// 模型名;`Disabled` 时可为空。
|
||||
pub model: String,
|
||||
/// API key(本地推理可空)。
|
||||
pub api_key: Option<String>,
|
||||
/// 采样温度。
|
||||
pub temperature: Option<f32>,
|
||||
/// 单次回复 token 上限。
|
||||
pub max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for ProviderConfig {
|
||||
fn default() -> Self {
|
||||
Self::disabled()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderConfig {
|
||||
fn base(kind: ProviderKind, base_url: &str, model: &str) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
base_url: base_url.to_string(),
|
||||
model: model.to_string(),
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenAI 兼容端点(云端或自建)。
|
||||
#[must_use]
|
||||
pub fn openai_compatible(base_url: &str, model: &str) -> Self {
|
||||
Self::base(ProviderKind::OpenAiCompatible, base_url, model)
|
||||
}
|
||||
|
||||
/// 本机 llama.cpp server(用户就在跑 llama-server 的默认场景)。
|
||||
#[must_use]
|
||||
pub fn llamacpp_local(model: &str) -> Self {
|
||||
Self::base(
|
||||
ProviderKind::OpenAiCompatible,
|
||||
LLAMACPP_DEFAULT_BASE_URL,
|
||||
model,
|
||||
)
|
||||
}
|
||||
|
||||
/// Anthropic 原生。
|
||||
#[must_use]
|
||||
pub fn anthropic(model: &str) -> Self {
|
||||
Self::base(ProviderKind::Anthropic, ANTHROPIC_DEFAULT_BASE_URL, model)
|
||||
}
|
||||
|
||||
/// 本机 Ollama。
|
||||
#[must_use]
|
||||
pub fn ollama(model: &str) -> Self {
|
||||
Self::base(ProviderKind::Ollama, OLLAMA_DEFAULT_BASE_URL, model)
|
||||
}
|
||||
|
||||
/// 禁用 agent。
|
||||
#[must_use]
|
||||
pub fn disabled() -> Self {
|
||||
Self::base(ProviderKind::Disabled, "", "")
|
||||
}
|
||||
|
||||
/// 带上 API key。
|
||||
#[must_use]
|
||||
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
|
||||
self.api_key = Some(api_key.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 带上温度。
|
||||
#[must_use]
|
||||
pub fn with_temperature(mut self, temperature: f32) -> Self {
|
||||
self.temperature = Some(temperature);
|
||||
self
|
||||
}
|
||||
|
||||
/// agent 是否启用。
|
||||
#[must_use]
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.kind != ProviderKind::Disabled
|
||||
}
|
||||
|
||||
/// 实际使用的端点(空则回落默认值)。
|
||||
#[must_use]
|
||||
pub fn resolved_base_url(&self) -> &str {
|
||||
if self.base_url.trim().is_empty() {
|
||||
self.kind.default_base_url()
|
||||
} else {
|
||||
self.base_url.trim()
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置是否可用(装配期调用一次,错就进设置页提示而不是等到对话时炸)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
|
||||
pub fn validate(&self) -> Result<(), ProviderError> {
|
||||
if !self.is_enabled() {
|
||||
return Err(ProviderError::new(
|
||||
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
|
||||
));
|
||||
}
|
||||
if self.model.trim().is_empty() {
|
||||
return Err(ProviderError::new("没有配置模型名(model)"));
|
||||
}
|
||||
if self.resolved_base_url().trim().is_empty() {
|
||||
return Err(ProviderError::new(format!(
|
||||
"{} 需要填写端点(base_url)",
|
||||
self.kind.display_name()
|
||||
)));
|
||||
}
|
||||
if self.kind.requires_api_key()
|
||||
&& self
|
||||
.api_key
|
||||
.as_deref()
|
||||
.is_none_or(|key| key.trim().is_empty())
|
||||
{
|
||||
return Err(ProviderError::new(format!(
|
||||
"{} 需要 API key",
|
||||
self.kind.display_name()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 可打印的形态描述(**key 已掩码**,给日志/审计/设置页用)。
|
||||
#[must_use]
|
||||
pub fn redacted_debug(&self) -> String {
|
||||
let key = match self.api_key.as_deref() {
|
||||
None => "无".to_string(),
|
||||
Some(k) if k.trim().is_empty() => "无".to_string(),
|
||||
Some(_) => crate::redact::MASK.to_string(),
|
||||
};
|
||||
format!(
|
||||
"{} base_url={} model={} api_key={key}",
|
||||
self.kind.display_name(),
|
||||
self.resolved_base_url(),
|
||||
if self.model.is_empty() {
|
||||
"(未设置)"
|
||||
} else {
|
||||
&self.model
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ProviderConfig {
|
||||
/// 手写 Debug:**key 掩码**(`api_key` 是唯一不能出现在日志里的字段)。
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ProviderConfig")
|
||||
.field("kind", &self.kind)
|
||||
.field("base_url", &self.resolved_base_url())
|
||||
.field("model", &self.model)
|
||||
.field(
|
||||
"api_key",
|
||||
&self.api_key.as_deref().map(|_| crate::redact::MASK),
|
||||
)
|
||||
.field("temperature", &self.temperature)
|
||||
.field("max_tokens", &self.max_tokens)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ProviderError {
|
||||
/// 从可读文本构造。
|
||||
#[must_use]
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProviderError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProviderError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_defaults_match_research() {
|
||||
assert_eq!(
|
||||
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
|
||||
LLAMACPP_DEFAULT_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderConfig::ollama("llama3").resolved_base_url(),
|
||||
OLLAMA_DEFAULT_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
|
||||
ANTHROPIC_DEFAULT_BASE_URL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_requires_model_base_url_and_key() {
|
||||
assert!(!ProviderConfig::disabled().is_enabled());
|
||||
assert!(ProviderConfig::disabled().validate().is_err());
|
||||
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
|
||||
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
|
||||
// OpenAI 兼容必须显式给端点
|
||||
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(
|
||||
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(ProviderConfig::anthropic("claude").validate().is_err());
|
||||
assert!(ProviderConfig::anthropic("claude")
|
||||
.with_api_key("sk-ant")
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_debug_never_leaks_key() {
|
||||
let config =
|
||||
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||||
.with_api_key("sk-super-secret");
|
||||
let text = config.redacted_debug();
|
||||
assert!(!text.contains("sk-super-secret"), "{text}");
|
||||
assert!(text.contains(crate::redact::MASK));
|
||||
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
|
||||
let debug = format!("{config:?}");
|
||||
assert!(!debug.contains("sk-super-secret"), "{debug}");
|
||||
assert!(debug.contains(crate::redact::MASK), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_base_url_falls_back_to_default() {
|
||||
let mut config = ProviderConfig::ollama("llama3");
|
||||
config.base_url = String::new();
|
||||
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_states_serialize_as_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
|
||||
"\"open_ai_compatible\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
|
||||
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! rig 适配层:[`ChatProvider`] 的**唯一**实现,也是整个 crate 唯一允许 `use rig_*` 的文件。
|
||||
//!
|
||||
//! 依赖面收窄纪律(`DevDocs/PlanDoc/02-ai-agent-design.md` §0 + research/02 §7 R1):
|
||||
//! rig 是 pre-1.0、12 周 5 个 minor、CHANGELOG 满是 `[**breaking**]`(0.43 把 MCP 拆出 rig-agent、
|
||||
//! 0.45 升 rmcp 3.5 并改 owned connection)。所以把它关在这一个文件里:它变了,只有这里编译失败,
|
||||
//! [`super::ChatProvider`] 以上的循环/编排/审计一行不用动。
|
||||
//!
|
||||
//! ## 0.45 的接线(实读源码,非照文档猜)
|
||||
//!
|
||||
//! ```text
|
||||
//! ProviderConfig ─┬─ OpenAiCompatible → OpenAIConfig::new(key).with_base_url(url).client().chat(model)
|
||||
//! ├─ Anthropic → AnthropicConfig::new(key).with_base_url(url).client().completion(model)
|
||||
//! ├─ Ollama → OllamaConfig::new().with_base_url(url).client().completion(model)
|
||||
//! └─ Disabled → 不构造(Brain::run_turn 已提前返回)
|
||||
//! ↓ Model<W>.erase()
|
||||
//! DynModel<Completion>(wire + transport 擦除,本文件只持有这一个类型)
|
||||
//! ```
|
||||
//!
|
||||
//! 循环侧只要两件事:流式文本增量、以及折好的 [`ChatReply`](文本 + 工具调用)。
|
||||
//! 所以这里用 `model.stream(request)` 边转发 [`StreamEvent::Text`] 增量给 [`DeltaObserver`],
|
||||
//! 最后 `Streamed::finish()` 拿到完整 [`CompletionResponse`]——工具调用从响应里取,
|
||||
//! 不自己拼分片 JSON(拼分片是 rig 的 `ReplyFold` 已经做过的事)。
|
||||
//!
|
||||
//! ## 三个已确认的坑
|
||||
//!
|
||||
//! 1. **llama.cpp 默认端点就是 `http://localhost:8080/v1`**(`providers/llamacpp/mod.rs`),
|
||||
//! 与 [`LLAMACPP_DEFAULT_BASE_URL`] 一致。这里统一走 OpenAI 方言 + 显式 base_url:
|
||||
//! llama.cpp / LM Studio / DeepSeek 都是 OpenAI 兼容面,方言差异主要在默认端点与特有位
|
||||
//! (`chat_template_kwargs`、`num_ctx`)——真要下发这些,再换成 `providers::llamacpp` 方言或
|
||||
//! Ollama 的 `native_completion`。
|
||||
//! 2. **Ollama 原生方言的工具结果用 `tool_name` 配对(没有 id)**,OpenAI 方言用 `tool_call_id`。
|
||||
//! 这里走 rig 的 `Ollama::completion`(OpenAI 兼容 `/v1/chat/completions`),由 rig 负责回灌形状;
|
||||
//! 要下发 `num_ctx`(崩溃日志几千行,上下文必须可配)才需要 `native_completion`,留作 P1。
|
||||
//! 3. **取消**:`Streamed` 是普通 future/stream,丢弃即断开——P0 用「丢弃 future」实现停止生成,
|
||||
//! P1 接 UI 的「停止」按钮时按 rig 的取消语义(drop stream)对齐即可,不需要 unsafe。
|
||||
//!
|
||||
//! ## 逃生口(保留但不是 Plan A)
|
||||
//!
|
||||
//! rig 的破坏变更真伤到(或 Rust 1.92 MSRV 追不上 rig 的 1.97.1)时:换 `genai`
|
||||
//! (provider + function calling,无 agent 循环)或自己写 OpenAI 方言(约 300~500 行)。
|
||||
//! **代价只是实现一个新的 [`ChatProvider`]**——这正是把它关在一个文件里的意义。
|
||||
//!
|
||||
//! [`LLAMACPP_DEFAULT_BASE_URL`]: super::provider::LLAMACPP_DEFAULT_BASE_URL
|
||||
//! [`StreamEvent::Text`]: rig_core::streaming::StreamEvent::Text
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
|
||||
use rig_core::error::ProviderError as RigError;
|
||||
use rig_core::message::{
|
||||
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
|
||||
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
|
||||
ToolName as RigToolName, ToolResult, ToolResultContent, UserContent,
|
||||
};
|
||||
use rig_core::operation::Completion;
|
||||
use rig_core::providers::anthropic::AnthropicConfig;
|
||||
use rig_core::providers::ollama::OllamaConfig;
|
||||
use rig_core::providers::openai::OpenAIConfig;
|
||||
use rig_core::streaming::{Item, StreamEvent};
|
||||
use rig_core::DynModel;
|
||||
|
||||
use super::chat::{
|
||||
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
|
||||
ProviderFuture,
|
||||
};
|
||||
use super::provider::{ProviderConfig, ProviderError, ProviderKind};
|
||||
|
||||
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
|
||||
pub const RIG_VERSION_PIN: &str = "=0.45.0";
|
||||
|
||||
/// 擦掉 wire / transport 之后的补全模型——本文件对外只暴露这一个 rig 类型之外的东西。
|
||||
type ErasedModel = DynModel<Completion>;
|
||||
|
||||
/// rig 实现的 [`ChatProvider`]:四态配置 + 一个擦除后的模型。
|
||||
pub struct RigProvider {
|
||||
config: ProviderConfig,
|
||||
model: ErasedModel,
|
||||
}
|
||||
|
||||
impl RigProvider {
|
||||
/// 按配置造 provider。`Disabled`、缺模型名、缺 key 都在这里被拒(fail-fast)。
|
||||
///
|
||||
/// # Errors
|
||||
/// AI 被禁用、配置不完整、或 rig 端点构造失败时返回可读错误。
|
||||
pub fn new(config: ProviderConfig) -> Result<Self, ProviderError> {
|
||||
config.validate()?;
|
||||
let model = build_model(&config)?;
|
||||
Ok(Self { config, model })
|
||||
}
|
||||
|
||||
/// 装配层用的工厂:`ProviderFactory` 直接塞给 `crates/app`。
|
||||
#[must_use]
|
||||
pub fn factory() -> ProviderFactory {
|
||||
Arc::new(|config: &ProviderConfig| {
|
||||
Ok(Arc::new(Self::new(config.clone())?) as Arc<dyn ChatProvider>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatProvider for RigProvider {
|
||||
fn config(&self) -> &ProviderConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
fn complete<'a>(
|
||||
&'a self,
|
||||
request: &'a ChatRequest,
|
||||
observer: &'a dyn DeltaObserver,
|
||||
) -> ProviderFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let rig_request = build_request(request)?;
|
||||
let mut stream = self.model.stream(rig_request).map_err(map_error)?;
|
||||
|
||||
// 先转发增量,再拿折好的完整响应(工具调用只从响应取,不拼分片)。
|
||||
let mut text = String::new();
|
||||
while let Some(item) = stream.next().await {
|
||||
match item.map_err(map_error)? {
|
||||
Item::Event(StreamEvent::Text { text: fragment, .. }) => {
|
||||
text.push_str(&fragment);
|
||||
observer.on_delta(&fragment);
|
||||
}
|
||||
Item::Event(_) | Item::Unknown(_) => {}
|
||||
}
|
||||
}
|
||||
let response = stream.finish().await.map_err(map_error)?;
|
||||
to_reply(&response, text)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 四态 → rig 模型。
|
||||
fn build_model(config: &ProviderConfig) -> Result<ErasedModel, ProviderError> {
|
||||
let model = config.model.trim().to_string();
|
||||
let base_url = config.resolved_base_url().to_string();
|
||||
let api_key = config.api_key.clone().unwrap_or_default();
|
||||
let erased = match config.kind {
|
||||
ProviderKind::OpenAiCompatible => OpenAIConfig::new(api_key)
|
||||
.with_base_url(base_url)
|
||||
.client()
|
||||
.chat(model)
|
||||
.erase(),
|
||||
ProviderKind::Anthropic => AnthropicConfig::new(api_key)
|
||||
.with_base_url(base_url)
|
||||
.client()
|
||||
.completion(model)
|
||||
.erase(),
|
||||
ProviderKind::Ollama => OllamaConfig::new()
|
||||
.with_base_url(base_url)
|
||||
.client()
|
||||
.completion(model)
|
||||
.erase(),
|
||||
ProviderKind::Disabled => {
|
||||
return Err(ProviderError::new(
|
||||
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(erased)
|
||||
}
|
||||
|
||||
/// 本方消息 → rig 补全请求。
|
||||
fn build_request(request: &ChatRequest) -> Result<CompletionRequest, ProviderError> {
|
||||
let mut history = to_rig_messages(&request.messages)?;
|
||||
// rig 把历史最后一条当 prompt,其余是上下文;空历史没有可发的 prompt。
|
||||
let prompt = history
|
||||
.pop()
|
||||
.ok_or_else(|| ProviderError::new("对话历史为空:至少需要一条用户消息"))?;
|
||||
let prompt = Arc::unwrap_or_clone(prompt);
|
||||
let tools = request
|
||||
.tools
|
||||
.iter()
|
||||
.map(to_rig_tool)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(CompletionRequest::new(prompt)
|
||||
.messages(history)
|
||||
.tools(tools)
|
||||
.temperature(request.temperature.map(f64::from))
|
||||
.max_tokens(request.max_tokens.map(u64::from)))
|
||||
}
|
||||
|
||||
/// 本方工具定义 → rig 工具定义(入参都是裸 JSON Schema,这正是双投影的支点)。
|
||||
fn to_rig_tool(tool: &crate::registry::ToolDefinition) -> Result<RigToolDefinition, ProviderError> {
|
||||
let name = tool_name(&tool.name)?;
|
||||
Ok(RigToolDefinition::new(
|
||||
name,
|
||||
tool.description.clone(),
|
||||
tool.input_schema.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 本方消息 → rig 消息。
|
||||
fn to_rig_messages(messages: &[ChatMessage]) -> Result<Vec<Arc<RigMessage>>, ProviderError> {
|
||||
let mut out = Vec::with_capacity(messages.len());
|
||||
for message in messages {
|
||||
let rig = match message {
|
||||
ChatMessage::System { content } => RigMessage::system(content.clone()),
|
||||
ChatMessage::User { content } => RigMessage::user(content.clone()),
|
||||
ChatMessage::Assistant { text, tool_calls } => {
|
||||
let mut parts = Vec::with_capacity(tool_calls.len() + 1);
|
||||
if !text.trim().is_empty() {
|
||||
parts.push(RigAssistantContent::Text(RigText::new(text.clone())));
|
||||
}
|
||||
for call in tool_calls {
|
||||
parts.push(RigAssistantContent::ToolCall(RigToolCall::new(
|
||||
CallId::from_wire(call.id.clone()),
|
||||
ToolFunction::new(tool_name(&call.name)?, call.arguments.clone()),
|
||||
)));
|
||||
}
|
||||
RigMessage::Assistant(RigAssistantMessage::new(parts))
|
||||
}
|
||||
ChatMessage::Tool {
|
||||
call_id,
|
||||
name,
|
||||
content,
|
||||
} => RigMessage::User {
|
||||
content: vec![UserContent::ToolResult(ToolResult {
|
||||
call: CallId::from_wire(call_id.clone()),
|
||||
name: tool_name(name)?,
|
||||
content: vec![ToolResultContent::text(content.clone())],
|
||||
is_error: false,
|
||||
})],
|
||||
},
|
||||
};
|
||||
out.push(Arc::new(rig));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// rig 响应 → 本方回复(文本 + 工具调用 + 用量)。
|
||||
fn to_reply(
|
||||
response: &rig_core::completion::CompletionResponse,
|
||||
streamed: String,
|
||||
) -> Result<ChatReply, ProviderError> {
|
||||
let mut text = streamed;
|
||||
let mut tool_calls = Vec::new();
|
||||
for part in &response.choice {
|
||||
match part {
|
||||
RigAssistantContent::Text(chunk) => {
|
||||
if text.is_empty() {
|
||||
text.push_str(&chunk.text);
|
||||
}
|
||||
}
|
||||
RigAssistantContent::ToolCall(call) => {
|
||||
if let Some(raw) = call.function.invalid_arguments.as_deref() {
|
||||
return Err(ProviderError::new(format!(
|
||||
"模型返回的工具调用参数不是 JSON 对象(工具 `{}`):{raw}",
|
||||
call.function.name.as_str()
|
||||
)));
|
||||
}
|
||||
tool_calls.push(super::chat::ToolCall {
|
||||
id: call.id.to_string(),
|
||||
name: call.function.name.as_str().to_string(),
|
||||
arguments: serde_json::Value::Object(call.function.arguments.clone()),
|
||||
});
|
||||
}
|
||||
// 推理链/图片/未知块不进对话循环(P0 只消费文本 + 工具调用)。
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(ChatReply {
|
||||
text,
|
||||
tool_calls,
|
||||
usage: Some(super::chat::Usage {
|
||||
prompt_tokens: response
|
||||
.usage
|
||||
.input_tokens
|
||||
.and_then(|v| u32::try_from(v).ok()),
|
||||
completion_tokens: response
|
||||
.usage
|
||||
.output_tokens
|
||||
.and_then(|v| u32::try_from(v).ok()),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// rig 错误 → 本方错误(文案面向用户)。
|
||||
fn map_error(error: RigError) -> ProviderError {
|
||||
ProviderError::new(error.to_string())
|
||||
}
|
||||
|
||||
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
|
||||
fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
|
||||
RigToolName::new(name)
|
||||
.map_err(|_| ProviderError::new("工具名不能为空(ToolSpec 注册阶段应已拦截)"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
|
||||
super::super::chat::ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_config_is_rejected_before_touching_the_network() {
|
||||
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
|
||||
panic!("禁用态应在构造阶段被拒");
|
||||
};
|
||||
assert!(err.to_string().contains("禁用"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_without_key_is_rejected() {
|
||||
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
|
||||
assert!(RigProvider::new(
|
||||
ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_states_build_offline() {
|
||||
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
|
||||
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_maps_messages_and_tools() {
|
||||
let request = ChatRequest {
|
||||
model: "mock".to_string(),
|
||||
messages: vec![
|
||||
ChatMessage::system("你是助手"),
|
||||
ChatMessage::user("把主题换成浅色"),
|
||||
ChatMessage::Assistant {
|
||||
text: String::new(),
|
||||
tool_calls: vec![tool("call_1", "set_config")],
|
||||
},
|
||||
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
|
||||
],
|
||||
tools: vec![crate::registry::ToolDefinition {
|
||||
name: "set_config".to_string(),
|
||||
description: "改配置".to_string(),
|
||||
input_schema: serde_json::json!({ "type": "object" }),
|
||||
}],
|
||||
temperature: Some(0.5),
|
||||
max_tokens: Some(512),
|
||||
};
|
||||
let built = build_request(&request).expect("应能构造");
|
||||
assert_eq!(built.chat_history.len(), 4);
|
||||
assert_eq!(built.tools.len(), 1);
|
||||
assert_eq!(built.tools[0].name.as_str(), "set_config");
|
||||
assert_eq!(built.temperature, Some(0.5));
|
||||
assert_eq!(built.max_tokens, Some(512));
|
||||
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
|
||||
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
|
||||
assert!(json.contains("toolresult"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_is_a_readable_error() {
|
||||
let request = ChatRequest {
|
||||
model: "m".to_string(),
|
||||
messages: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
let err = build_request(&request).unwrap_err();
|
||||
assert!(err.to_string().contains("对话历史为空"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! 时间源:审计日志、事件流、工具耗时都要时间戳,但**测试不能依赖真实时钟**
|
||||
//! (否则断言不稳定)。所以照既有做法注入 [`Clock`]。
|
||||
//!
|
||||
//! 时间戳格式化自己实现([`format_unix_ms`]),不引 `chrono`:agent 的依赖面按
|
||||
//! `DevDocs/PlanDoc/02-ai-agent-design.md` §0 越窄越好,RFC3339 UTC 只需要一个
|
||||
//! 民用历换算(Howard Hinnant 的 `civil_from_days`)。
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 时间源。生产用 [`SystemClock`],测试用 [`FixedClock`]。
|
||||
pub trait Clock: Send + Sync {
|
||||
/// 当前 Unix 毫秒时间戳。
|
||||
fn now_unix_ms(&self) -> u64;
|
||||
}
|
||||
|
||||
/// 真实系统时钟(单调性不保证,审计只要"大致有序")。
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct SystemClock;
|
||||
|
||||
impl Clock for SystemClock {
|
||||
fn now_unix_ms(&self) -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// 可控时钟:测试里固定起点、必要时手动推进。
|
||||
#[derive(Debug)]
|
||||
pub struct FixedClock {
|
||||
ms: AtomicU64,
|
||||
step_ms: u64,
|
||||
}
|
||||
|
||||
impl FixedClock {
|
||||
/// 从固定时间戳开始,每次读走一步 `step_ms`(0 = 不自动推进)。
|
||||
#[must_use]
|
||||
pub fn new(start_ms: u64) -> Self {
|
||||
Self {
|
||||
ms: AtomicU64::new(start_ms),
|
||||
step_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 每次读取自动前进 `step_ms`,用来断言工具耗时字段。
|
||||
#[must_use]
|
||||
pub fn with_step(start_ms: u64, step_ms: u64) -> Self {
|
||||
Self {
|
||||
ms: AtomicU64::new(start_ms),
|
||||
step_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动推进。
|
||||
pub fn advance(&self, ms: u64) {
|
||||
self.ms.fetch_add(ms, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now_unix_ms(&self) -> u64 {
|
||||
if self.step_ms == 0 {
|
||||
return self.ms.load(Ordering::Relaxed);
|
||||
}
|
||||
self.ms.fetch_add(self.step_ms, Ordering::Relaxed) + self.step_ms
|
||||
}
|
||||
}
|
||||
|
||||
/// `Arc<dyn Clock>` 的简写。
|
||||
pub type SharedClock = Arc<dyn Clock>;
|
||||
|
||||
/// Unix 毫秒 → `YYYY-MM-DDTHH:MM:SS.mmmZ`(UTC)。
|
||||
#[must_use]
|
||||
pub fn format_unix_ms(ms: u64) -> String {
|
||||
let secs = (ms / 1000) as i64;
|
||||
let millis = ms % 1000;
|
||||
let days = secs.div_euclid(86_400);
|
||||
let sod = secs.rem_euclid(86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
let (h, mi, s) = (sod / 3600, (sod % 3600) / 60, sod % 60);
|
||||
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z")
|
||||
}
|
||||
|
||||
/// Unix 毫秒 → `YYYY-MM-DD`(UTC),审计落盘按天分文件用。
|
||||
#[must_use]
|
||||
pub fn date_of_unix_ms(ms: u64) -> String {
|
||||
let (y, m, d) = civil_from_days(((ms / 1000) as i64).div_euclid(86_400));
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
/// 1970-01-01 起的天数 → (年, 月, 日)。
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_epoch_and_known_instants() {
|
||||
assert_eq!(format_unix_ms(0), "1970-01-01T00:00:00.000Z");
|
||||
// 2026-10-29T20:12:33.123Z
|
||||
assert_eq!(
|
||||
format_unix_ms(1_793_304_753_123),
|
||||
"2026-10-29T20:12:33.123Z"
|
||||
);
|
||||
assert_eq!(date_of_unix_ms(1_793_304_753_123), "2026-10-29");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_leap_day() {
|
||||
// 2024-02-29T00:00:00Z
|
||||
assert_eq!(
|
||||
format_unix_ms(1_709_164_800_000),
|
||||
"2024-02-29T00:00:00.000Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_clock_can_step() {
|
||||
let clock = FixedClock::with_step(1_000, 5);
|
||||
assert_eq!(clock.now_unix_ms(), 1_005);
|
||||
assert_eq!(clock.now_unix_ms(), 1_010);
|
||||
clock.advance(10);
|
||||
assert_eq!(clock.now_unix_ms(), 1_025);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! 分级确认协议(02 文档 §3.1「分级确认」+ §4b「YOLO」)。
|
||||
//!
|
||||
//! 分工:
|
||||
//! - **工具**只声明「这次调用需要确认」并给出 diff([`ConfirmRequest`]),不自己做决定;
|
||||
//! - **编排层**([`crate::brain::exec`])拿 [`ConfirmPolicy`] 决定是问人、还是走 YOLO;
|
||||
//! - **UI** 在 P1 通过 [`ConfirmGate`] 接进来(P0 只有事件 + fail-closed 的默认实现)。
|
||||
//!
|
||||
//! fail-closed 是刻意的:P0 没有确认弹窗,敏感键的写入**一律拒绝**而不是静默放行
|
||||
//! (research/02 §7 R9「写操作的破坏半径」)。
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// 敏感等级。**与 M12 `setting::spec::Sensitivity` 逐字一致**
|
||||
/// (`DevDocs/SubAgentsTalk/setting-接口约定.md` §8):
|
||||
///
|
||||
/// | 等级 | 语义 |
|
||||
/// |------|------|
|
||||
/// | `Low` | 静默生效 |
|
||||
/// | `Medium` | 静默生效,审计里能查到(每次调用本来都审计,这里只是标记用途) |
|
||||
/// | `High` | 必须用户确认(YOLO 可跳过确认,但**仍受白名单约束**) |
|
||||
///
|
||||
/// 「AI 完全不能写」的键(`ai.api_key` 等凭据)由 M12 的 `writable=false` 表达,
|
||||
/// 它们**不进白名单**,agent 侧走「不在白名单」的拒绝分支,不需要第四级。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Sensitivity {
|
||||
/// 静默生效。
|
||||
#[default]
|
||||
Low,
|
||||
/// 静默生效但审计标记(下载源、语言、日志级别)。
|
||||
Medium,
|
||||
/// 必须用户确认(数据目录、Java 路径、代理、AI 端点/YOLO 开关)。
|
||||
High,
|
||||
}
|
||||
|
||||
/// 一个待确认的操作:给用户看的措辞 + 结构化细节(UI 画 diff 用)。
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ConfirmRequest {
|
||||
/// 请求 id(编排层生成,UI 回填决定时原样带回)。
|
||||
pub id: String,
|
||||
/// 工具名。
|
||||
pub tool: String,
|
||||
/// 一句话请求(「AI 请求把 `instance.java.path` 改为 `D:\jdk17`」)。
|
||||
pub summary: String,
|
||||
/// 结构化细节(`{"path":…, "old":…, "new":…}`)。
|
||||
pub details: Value,
|
||||
/// 敏感等级。
|
||||
pub sensitivity: Sensitivity,
|
||||
}
|
||||
|
||||
/// 用户决定。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfirmDecision {
|
||||
/// 允许这一次。
|
||||
Approved,
|
||||
/// 拒绝(工具调用返回「用户拒绝」给模型)。
|
||||
Denied,
|
||||
/// 允许,且本会话后续不再询问(UI 的「全部允许」)。
|
||||
///
|
||||
/// 编排层当前**只当普通放行**处理(`brain/exec.rs` 不区分两者);「据此临时开 YOLO」
|
||||
/// 是 P1 的行为,落地前不要按已实现来看。
|
||||
ApprovedAll,
|
||||
}
|
||||
|
||||
impl ConfirmDecision {
|
||||
/// 是否放行(`Approved` 与 `ApprovedAll` 都放行)。
|
||||
#[must_use]
|
||||
pub fn is_approved(self) -> bool {
|
||||
matches!(self, Self::Approved | Self::ApprovedAll)
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认闸门:P1 由 app 的 Slint 弹窗实现;测试用 [`ScriptedGate`]。
|
||||
pub trait ConfirmGate: Send + Sync {
|
||||
/// 发起确认并等待决定(UI 未接时由实现方立即给出 fail-closed 结果)。
|
||||
fn request(&self, request: ConfirmRequest) -> ConfirmFuture;
|
||||
}
|
||||
|
||||
/// 确认未来的箱子类型。
|
||||
pub type ConfirmFuture = Pin<Box<dyn Future<Output = ConfirmDecision> + Send + 'static>>;
|
||||
|
||||
/// 一律放行(YOLO 单测 / 未来"用户已信任该工具"场景)。
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct AutoApproveGate;
|
||||
|
||||
impl ConfirmGate for AutoApproveGate {
|
||||
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
|
||||
Box::pin(async { ConfirmDecision::Approved })
|
||||
}
|
||||
}
|
||||
|
||||
/// 一律拒绝(fail-closed 的极简形态)。
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct AutoDenyGate;
|
||||
|
||||
impl ConfirmGate for AutoDenyGate {
|
||||
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
|
||||
Box::pin(async { ConfirmDecision::Denied })
|
||||
}
|
||||
}
|
||||
|
||||
/// P0 默认闸门:**没有确认 UI** → 一律拒绝(fail-closed)。
|
||||
///
|
||||
/// 请求本身已经由编排层作为 [`crate::event::AgentEvent::ConfirmRequested`] 发出去了,
|
||||
/// 所以 P0 的行为是「敏感操作既不静默执行,也不卡住对话」:事件流里留下了
|
||||
/// 「AI 想改 X」的完整记录,P1 接上弹窗后同一个事件直接变成可点的确认框。
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoUiGate;
|
||||
|
||||
impl ConfirmGate for NoUiGate {
|
||||
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
|
||||
Box::pin(async { ConfirmDecision::Denied })
|
||||
}
|
||||
}
|
||||
|
||||
/// 脚本化闸门:按顺序返回预设决定,用完之后走 `default`(测试用)。
|
||||
pub struct ScriptedGate {
|
||||
decisions: Mutex<VecDeque<ConfirmDecision>>,
|
||||
default: ConfirmDecision,
|
||||
}
|
||||
|
||||
impl ScriptedGate {
|
||||
/// 依次给出 `decisions`,耗尽后返回 `default`。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
decisions: impl IntoIterator<Item = ConfirmDecision>,
|
||||
default: ConfirmDecision,
|
||||
) -> Self {
|
||||
Self {
|
||||
decisions: Mutex::new(decisions.into_iter().collect()),
|
||||
default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfirmGate for ScriptedGate {
|
||||
fn request(&self, _request: ConfirmRequest) -> ConfirmFuture {
|
||||
let decision = self
|
||||
.decisions
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut q| q.pop_front())
|
||||
.unwrap_or(self.default);
|
||||
Box::pin(async move { decision })
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认策略(编排层用)。YOLO 的真实开关在设置里,这里只是当次运行的快照。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct ConfirmPolicy {
|
||||
/// 自动批准全部工具调用(设置 → AI → YOLO,默认关)。
|
||||
pub yolo: bool,
|
||||
}
|
||||
|
||||
/// 拒绝时的引导口径:**内置对话**有弹窗可点,**外部 MCP** 没有 UI——这条差别只在
|
||||
/// 「拒绝文案」上,策略本身完全相同(同一条 `ConfirmPolicy`、同一份审计)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DenyGuidance {
|
||||
/// 内置对话:弹窗拒绝(或 P0 的 fail-closed)。
|
||||
InApp,
|
||||
/// 外部 MCP 客户端:引导用户去设置页确认,或打开 YOLO。
|
||||
External,
|
||||
}
|
||||
|
||||
impl DenyGuidance {
|
||||
/// 拒绝后回给模型/用户的一句话(区分「谁拒的」与「下一步怎么办」)。
|
||||
#[must_use]
|
||||
pub fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::InApp => "用户拒绝了这次操作,未做任何修改",
|
||||
Self::External => {
|
||||
"这次操作需要用户确认,但当前是外部 MCP 客户端(没有确认弹窗),已按默认策略拒绝。\
|
||||
请让用户在启动器设置页手动修改,或在设置 → AI 里打开 YOLO 模式后重试(审计照记)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfirmPolicy {
|
||||
/// 该等级是否需要**询问**用户:只有 `High` 会问;YOLO 下连 `High` 也不问
|
||||
/// (02 文档 §4b:YOLO 跳过分级确认,但**仍受白名单约束**——能改的键没变少)。
|
||||
#[must_use]
|
||||
pub fn requires_confirmation(self, sensitivity: Sensitivity) -> bool {
|
||||
!self.yolo && sensitivity == Sensitivity::High
|
||||
}
|
||||
|
||||
/// 该等级是否可以直接执行(不需要询问)。
|
||||
#[must_use]
|
||||
pub fn auto_approves(self, sensitivity: Sensitivity) -> bool {
|
||||
!self.requires_confirmation(sensitivity)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn low_and_medium_keys_never_ask() {
|
||||
let policy = ConfirmPolicy { yolo: false };
|
||||
assert!(!policy.requires_confirmation(Sensitivity::Low));
|
||||
assert!(!policy.requires_confirmation(Sensitivity::Medium));
|
||||
assert!(policy.auto_approves(Sensitivity::Low));
|
||||
assert!(policy.auto_approves(Sensitivity::Medium));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_keys_ask_unless_yolo() {
|
||||
assert!(ConfirmPolicy { yolo: false }.requires_confirmation(Sensitivity::High));
|
||||
assert!(!ConfirmPolicy { yolo: false }.auto_approves(Sensitivity::High));
|
||||
assert!(!ConfirmPolicy { yolo: true }.requires_confirmation(Sensitivity::High));
|
||||
assert!(ConfirmPolicy { yolo: true }.auto_approves(Sensitivity::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_matches_m12_levels() {
|
||||
// 序列化名必须与 M12 `setting::spec::Sensitivity` 一致
|
||||
assert_eq!(serde_json::to_string(&Sensitivity::Low).unwrap(), "\"low\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Sensitivity::Medium).unwrap(),
|
||||
"\"medium\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Sensitivity::High).unwrap(),
|
||||
"\"high\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! agent 事件流:**UI 与审计之外的观察者**都从这里接(02 文档 §1 的「event channel」)。
|
||||
//!
|
||||
//! P0 只做输出([`AgentEventSink`]),P1 的 Slint 聊天面板订阅同一路事件把
|
||||
//! 「正在读取日志…」「请求修改设置:下载并发 64→16」画出来,确认弹窗直接复用
|
||||
//! [`AgentEvent::ConfirmRequested`]。
|
||||
//!
|
||||
//! 实现方必须**非阻塞**:`emit` 在对话循环的同步路径上被调用。
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::confirm::ConfirmRequest;
|
||||
|
||||
/// 一条 agent 事件(全部可序列化:P1 走 IPC 给 app 时不写两套类型)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AgentEvent {
|
||||
/// 一轮对话开始。
|
||||
TurnStarted {
|
||||
/// agent 会话 id。
|
||||
session_id: String,
|
||||
/// 用户输入。
|
||||
user_text: String,
|
||||
},
|
||||
/// 流式文本增量(P1 聊天面板逐字渲染)。
|
||||
TextDelta {
|
||||
/// 增量文本。
|
||||
text: String,
|
||||
},
|
||||
/// 模型这一轮的非流式完整文本(流式之外的兜底,也是审计用的正文)。
|
||||
AssistantText {
|
||||
/// 文本。
|
||||
text: String,
|
||||
},
|
||||
/// 开始执行一个工具调用。
|
||||
ToolCallStarted {
|
||||
/// 工具名。
|
||||
tool: String,
|
||||
/// 模型给的参数。
|
||||
args: Value,
|
||||
/// 第几轮工具循环(从 1 开始)。
|
||||
step: usize,
|
||||
},
|
||||
/// 一个工具调用结束。
|
||||
ToolCallFinished {
|
||||
/// 工具名。
|
||||
tool: String,
|
||||
/// 是否成功。
|
||||
ok: bool,
|
||||
/// 一句话结果摘要(UI 直接显示)。
|
||||
summary: String,
|
||||
/// 这次是否触发了确认请求。
|
||||
requires_confirmation: bool,
|
||||
},
|
||||
/// 需要用户确认(P0 只发事件,P1 变成可点的弹窗)。
|
||||
ConfirmRequested {
|
||||
/// 待确认请求。
|
||||
request: Box<ConfirmRequest>,
|
||||
},
|
||||
/// 工具调用被拒绝(策略或用户)。
|
||||
ToolDenied {
|
||||
/// 工具名。
|
||||
tool: String,
|
||||
/// 拒绝原因。
|
||||
reason: String,
|
||||
},
|
||||
/// 一轮对话结束。
|
||||
TurnFinished {
|
||||
/// 实际用掉的最大工具轮次。
|
||||
steps: usize,
|
||||
/// 最终给用户看的文本。
|
||||
text: String,
|
||||
},
|
||||
/// 循环级错误(provider 挂了、AI 被禁用…)。
|
||||
Error {
|
||||
/// 人类可读原因。
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 事件出口。
|
||||
pub trait AgentEventSink: Send + Sync {
|
||||
/// 发一条事件。实现方不得阻塞。
|
||||
fn emit(&self, event: AgentEvent);
|
||||
}
|
||||
|
||||
/// 丢弃事件。
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopSink;
|
||||
|
||||
impl AgentEventSink for NoopSink {
|
||||
fn emit(&self, _event: AgentEvent) {}
|
||||
}
|
||||
|
||||
/// 内存事件流(测试断言 + P0 的「暂不接 UI」)。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MemoryEventSink {
|
||||
events: Mutex<Vec<AgentEvent>>,
|
||||
}
|
||||
|
||||
impl MemoryEventSink {
|
||||
/// 空 sink。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 取快照。
|
||||
#[must_use]
|
||||
pub fn events(&self) -> Vec<AgentEvent> {
|
||||
self.events.lock().map(|e| e.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 已发条数。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.lock().map(|e| e.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 是否还没发过事件。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// 拼接所有文本增量(测试断言「流式内容拼起来等于完整文本」)。
|
||||
#[must_use]
|
||||
pub fn streamed_text(&self) -> String {
|
||||
self.events()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
AgentEvent::TextDelta { text } => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentEventSink for MemoryEventSink {
|
||||
fn emit(&self, event: AgentEvent) {
|
||||
if let Ok(mut events) = self.events.lock() {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 标准库 channel 出口(P1 的 app 用一个线程收、喂给 Slint 的事件循环)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MpscSink {
|
||||
tx: Sender<AgentEvent>,
|
||||
}
|
||||
|
||||
impl MpscSink {
|
||||
/// 包一个 `std::sync::mpsc::Sender`。
|
||||
#[must_use]
|
||||
pub fn new(tx: Sender<AgentEvent>) -> Self {
|
||||
Self { tx }
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentEventSink for MpscSink {
|
||||
fn emit(&self, event: AgentEvent) {
|
||||
// 接收端已关闭说明 UI 没了,事件直接丢弃(不能拖累对话)。
|
||||
let _ = self.tx.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn memory_sink_collects_and_concatenates_deltas() {
|
||||
let sink = MemoryEventSink::new();
|
||||
sink.emit(AgentEvent::TextDelta {
|
||||
text: "正在".into(),
|
||||
});
|
||||
sink.emit(AgentEvent::TextDelta {
|
||||
text: "读取日志".into(),
|
||||
});
|
||||
assert_eq!(sink.streamed_text(), "正在读取日志");
|
||||
assert_eq!(sink.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpsc_sink_forwards() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let sink = MpscSink::new(tx);
|
||||
sink.emit(AgentEvent::Error {
|
||||
message: "boom".into(),
|
||||
});
|
||||
assert!(matches!(rx.recv(), Ok(AgentEvent::Error { .. })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Unlucky-Launcher AI Agent(`unlucky_agent`)。
|
||||
//!
|
||||
//! 形态定案(决策 D4):**内置 Agent 客户端 + 对外的 MCP server 双形态,共享同一个工具内核**。
|
||||
//! 设计定稿:`DevDocs/PlanDoc/02-ai-agent-design.md`。
|
||||
//!
|
||||
//! 三条不能破的边界:
|
||||
//! 1. 工具只定义一次([`registry`]),内置客户端与 MCP server 都把同一个 `ToolSpec` 投影出去;
|
||||
//! 2. AI 是 Port 不是 Service——LLM 不可用时规则引擎仍能诊断;
|
||||
//! 3. AI 永不接触账户凭据;写配置受 schema 白名单约束并留审计。
|
||||
//!
|
||||
//! 与 core 的关系:agent 依赖 core 的公共 API,core 不知道 agent 的存在。
|
||||
//! 落地方式是把需要 core 的能力收成[`port`]里的五个 trait(配置读写 / 会话日志 / 崩溃诊断 /
|
||||
//! 实例与 Java / 账户),由装配层用 core 的实现填充——这样 M12/M14/M15/M18/M31/M33 未合并时
|
||||
//! agent 也能独立编译、独立测试。装配处有两条:`crates/app`(GUI)与 `ull agent serve`(headless)。
|
||||
//!
|
||||
//! 当前状态:工具内核 + 对话循环 + 审计(P0 定稿)+ **对外 MCP server**(M39)已落地。
|
||||
//! 两个外部依赖都收口在单文件:[`brain::rig`] 对 rig、[`mcp`] 对 rmcp(3.8.x)——
|
||||
//! 它们升 breaking 时只有那两个模块编译失败,上层的循环/编排/审计一行不用动。
|
||||
//!
|
||||
//! 模块分工:
|
||||
//! - [`registry`] 工具注册表(唯一真相源 + 双投影载荷);
|
||||
//! - [`tools`] 工具 v1 实现(配置读写 / 会话日志 / 崩溃诊断);
|
||||
//! - [`brain`] Provider 抽象 + 对话循环 + 工具编排;
|
||||
//! - [`mcp`] 对外 MCP server(rmcp 双投影 + stdio / Streamable HTTP 传输);
|
||||
//! - [`confirm`] 分级确认协议与 YOLO 策略;
|
||||
//! - [`audit`] 每次工具调用的审计留痕;
|
||||
//! - [`event`] 面向 UI 的事件流;
|
||||
//! - [`port`] 与 core 的接触面(依赖倒置);
|
||||
//! - [`redact`] 密钥/主目录脱敏;
|
||||
//! - [`clock`] 可注入时间源(审计/事件用);
|
||||
//! - [`testing`] 五个端口的 mock 实现(供本 crate、app 与 MCP 契约测试复用)。
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod audit;
|
||||
pub mod brain;
|
||||
pub mod clock;
|
||||
pub mod confirm;
|
||||
pub mod event;
|
||||
pub mod mcp;
|
||||
pub mod port;
|
||||
pub mod redact;
|
||||
pub mod registry;
|
||||
pub mod testing;
|
||||
pub mod tools;
|
||||
@@ -0,0 +1,159 @@
|
||||
//! 对外 MCP server(02 文档 §1「双形态共享一个工具内核」的第二形态)。
|
||||
//!
|
||||
//! 传输定论(§0):**Streamable HTTP on 127.0.0.1 是主力**(外部 agent 起 stdio 会变成
|
||||
//! 双实例抢数据目录锁),**stdio 形态保留**给 llama.cpp server 的内置 MCP client。
|
||||
//!
|
||||
//! 与内置客户端的关系:两侧都只吃 [`crate::registry::ToolRegistry`] 的 `(name, description,
|
||||
//! input_schema)`,并且**都必须走** [`crate::brain::exec::ToolExecutor`]——schema 校验、
|
||||
//! 分级确认、YOLO、审计一个字都不少。所以「用户在聊天里让 AI 改配置」与「Claude Code
|
||||
//! 通过 MCP 改配置」是同一条路径(02 §1 的铁律)。
|
||||
//!
|
||||
//! 安全边界(MCP 场景的差别只有一条):
|
||||
//! - 敏感键(`Sensitivity::High`)默认**拒绝**而不是弹窗——外部客户端没有我们的 UI;
|
||||
//! 拒绝文案引导用户去设置页确认,或在设置里打开 `ai.yolo`(YOLO 下照记 `yolo=true` 审计);
|
||||
//! - 写工具默认不暴露(`agent.mcp.allow_write=false`):只读子集注册进注册表,
|
||||
//! 调写工具会得到「没有这个工具 + 可用工具清单」(并留一条 `unknown_tool` 审计)。
|
||||
//!
|
||||
//! 依赖面纪律:整个 crate 只有本模块允许 `use rmcp::`(与 `brain::rig` 对 rig 的规则同形)。
|
||||
|
||||
pub mod projection;
|
||||
pub mod sample;
|
||||
pub mod serve;
|
||||
pub mod server;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
pub use sample::{config_samples, ConfigSamples};
|
||||
pub use serve::{bind, serve_http, serve_http_on, serve_stdio};
|
||||
pub use server::RegistryServer;
|
||||
|
||||
/// 一次 MCP server 运行的配置(由设置键 `agent.mcp.*` + 命令行覆盖合成)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpConfig {
|
||||
/// 监听地址。**只允许回环地址**(默认 `127.0.0.1`):MCP 工具能改配置,不能对外开。
|
||||
pub host: String,
|
||||
/// Streamable HTTP 端口(默认 47823,设置键 `agent.mcp.port`)。
|
||||
pub port: u16,
|
||||
/// 访问令牌(`Authorization: Bearer <token>`);`None` = 不校验(仅限回环监听时允许)。
|
||||
pub token: Option<String>,
|
||||
/// 是否只暴露只读工具(默认 **true**;`agent.mcp.allow_write=true` 才放开写侧)。
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
impl Default for McpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 47823,
|
||||
token: None,
|
||||
read_only: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpConfig {
|
||||
/// `host:port` 形态的监听地址(IPv6 字面量自动加方括号,`::1` 不会拼成 `::1:47823`)。
|
||||
#[must_use]
|
||||
pub fn socket_addr(&self) -> String {
|
||||
if self.host.contains(':') {
|
||||
format!("[{}]:{}", self.host, self.port)
|
||||
} else {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP 端点 URL(客户端配置样例里用的那串)。
|
||||
#[must_use]
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://{}/mcp", self.socket_addr())
|
||||
}
|
||||
|
||||
/// 拒绝非回环监听——这是安全铁律的**代码化**,不是文档里的建议。
|
||||
///
|
||||
/// # Errors
|
||||
/// `host` 不是回环地址时返回 [`McpServeError::NotLoopback`]。
|
||||
pub fn ensure_loopback(&self) -> Result<(), McpServeError> {
|
||||
let host = self.host.trim();
|
||||
if host == "127.0.0.1" || host == "localhost" || host == "::1" {
|
||||
return Ok(());
|
||||
}
|
||||
Err(McpServeError::NotLoopback(host.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP server 启动/运行期错误(文案面向用户,可直接进 CLI 输出)。
|
||||
#[derive(Debug)]
|
||||
pub enum McpServeError {
|
||||
/// 监听地址不是回环地址。
|
||||
NotLoopback(String),
|
||||
/// 端口被占用(最常见:另一个 `ull agent serve` 已经在跑)。
|
||||
PortInUse {
|
||||
/// 监听地址(`127.0.0.1:47823`)。
|
||||
addr: String,
|
||||
},
|
||||
/// 其他 IO 失败(权限、端口不足、传输中断)。
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for McpServeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotLoopback(host) => write!(
|
||||
f,
|
||||
"拒绝在 `{host}` 上监听:MCP server 只允许回环地址(127.0.0.1 / localhost / ::1)"
|
||||
),
|
||||
Self::PortInUse { addr } => write!(
|
||||
f,
|
||||
"端口已被占用:{addr}。可能另一个 `ull agent serve` 正在运行;\
|
||||
用 `--port <端口>` 或设置键 `agent.mcp.port` 换一个"
|
||||
),
|
||||
Self::Io(message) => write!(f, "MCP server 传输失败:{message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for McpServeError {}
|
||||
|
||||
impl From<std::io::Error> for McpServeError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::Io(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_loopback_hosts_are_accepted() {
|
||||
let mut cfg = McpConfig::default();
|
||||
assert!(cfg.ensure_loopback().is_ok());
|
||||
cfg.host = "localhost".to_string();
|
||||
assert!(cfg.ensure_loopback().is_ok());
|
||||
cfg.host = "::1".to_string();
|
||||
assert_eq!(cfg.url(), "http://[::1]:47823/mcp");
|
||||
assert!(cfg.ensure_loopback().is_ok());
|
||||
cfg.host = "0.0.0.0".to_string();
|
||||
let err = cfg.ensure_loopback().unwrap_err();
|
||||
assert!(matches!(err, McpServeError::NotLoopback(_)));
|
||||
assert!(err.to_string().contains("回环地址"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_read_only_on_the_documented_port() {
|
||||
let cfg = McpConfig::default();
|
||||
assert!(cfg.read_only);
|
||||
assert_eq!(cfg.port, 47823);
|
||||
assert_eq!(cfg.url(), "http://127.0.0.1:47823/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_in_use_message_is_actionable() {
|
||||
let err = McpServeError::PortInUse {
|
||||
addr: "127.0.0.1:47823".to_string(),
|
||||
};
|
||||
let text = err.to_string();
|
||||
assert!(text.contains("--port"), "{text}");
|
||||
assert!(text.contains("agent.mcp.port"), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! 双投影的 **MCP 侧**:`ToolDefinition` → rmcp `Tool`(`ToolRoute::new_dyn` 的属性入参)。
|
||||
//!
|
||||
//! rig 侧在 [`crate::brain::rig`](`ToolDefinition → RigToolDefinition`)。两侧都只吃裸
|
||||
//! `serde_json::Value` schema,所以「一致性」是可断言的:`tests/mcp_contract.rs` 遍历注册表,
|
||||
//! 断言 MCP 侧的 `(name, description, input_schema)` 与注册表逐项相等——这就是
|
||||
//! research/02 §6.2 建议进 CI 的那条契约。
|
||||
//!
|
||||
//! 额外一位信息:`annotations.read_only_hint`。MCP 客户端拿它决定「这个工具能不能
|
||||
//! 静默调用」,我们用它把 [`Access`] 语义透出去(只读提示,不是权限——权限在编排层)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rmcp::model::{JsonObject, Tool, ToolAnnotations};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::registry::{Access, ToolDefinition, ToolRegistry};
|
||||
|
||||
/// 一个 `ToolDefinition` → rmcp `Tool`。
|
||||
///
|
||||
/// 注册表在注册阶段已保证 `input_schema` 根是 `object`,所以这里的兜底分支
|
||||
/// (非对象 → 空 schema)只是为了不 panic:真走到它说明注册校验被绕过了。
|
||||
#[must_use]
|
||||
pub fn tool_attr(definition: &ToolDefinition, access: Access) -> Tool {
|
||||
let schema: JsonObject = match &definition.input_schema {
|
||||
Value::Object(map) => map.clone(),
|
||||
_ => JsonObject::new(),
|
||||
};
|
||||
let mut attr = Tool::new(
|
||||
definition.name.clone(),
|
||||
definition.description.clone(),
|
||||
Arc::new(schema),
|
||||
);
|
||||
let mut annotations = ToolAnnotations::default();
|
||||
annotations.read_only_hint = Some(access == Access::ReadOnly);
|
||||
annotations.destructive_hint = Some(access == Access::Mutating);
|
||||
attr.annotations = Some(annotations);
|
||||
attr
|
||||
}
|
||||
|
||||
/// 注册表 → rmcp `Tool` 列表(顺序同注册顺序,便于两侧比对)。
|
||||
#[must_use]
|
||||
pub fn tool_attrs(registry: &ToolRegistry) -> Vec<Tool> {
|
||||
registry
|
||||
.specs()
|
||||
.iter()
|
||||
.map(|spec| tool_attr(&spec.definition(), spec.access))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::mock_ports;
|
||||
use crate::tools::build_v1_registry;
|
||||
use serde_json::json;
|
||||
|
||||
fn registry() -> ToolRegistry {
|
||||
let (ports, _settings) = mock_ports();
|
||||
build_v1_registry(&ports).expect("注册表应装配成功")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_keeps_name_description_and_schema() {
|
||||
let registry = registry();
|
||||
let attrs = tool_attrs(®istry);
|
||||
assert_eq!(attrs.len(), registry.len());
|
||||
|
||||
for (attr, spec) in attrs.iter().zip(registry.specs()) {
|
||||
let definition = spec.definition();
|
||||
assert_eq!(attr.name.as_ref(), definition.name);
|
||||
assert_eq!(
|
||||
attr.description.as_deref(),
|
||||
Some(definition.description.as_str()),
|
||||
"{} 的说明必须逐字相等",
|
||||
definition.name
|
||||
);
|
||||
let projected = Value::Object((*attr.input_schema).clone());
|
||||
assert_eq!(
|
||||
projected, definition.input_schema,
|
||||
"{} 的 JSON Schema 必须逐字相等",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_hint_matches_access() {
|
||||
let registry = registry();
|
||||
for (attr, spec) in tool_attrs(®istry).iter().zip(registry.specs()) {
|
||||
let hint = attr
|
||||
.annotations
|
||||
.as_ref()
|
||||
.and_then(|a| a.read_only_hint)
|
||||
.unwrap_or(false);
|
||||
assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_root_stays_an_object_on_the_wire() {
|
||||
for attr in tool_attrs(®istry()) {
|
||||
let value = Value::Object((*attr.input_schema).clone());
|
||||
assert_eq!(value["type"], json!("object"), "{}", attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! 对外客户端配置样例(用户把它们粘进 Claude Code / Cursor 就能连上)。
|
||||
//!
|
||||
//! 为什么要生成而不是写在文档里:端口与令牌是**用户本机**的值,手抄必错。
|
||||
//! 两个客户端的形状不同(02 §0):
|
||||
//! - **Claude Code**:`{"type":"http","url":…}`(`type` 必填,缺了会被当成 stdio)
|
||||
//! - **Cursor**:`{"url":…}`(`mcp.json` 里只认 url;多写 type 会被忽略但不报错)
|
||||
//!
|
||||
//! 令牌只在设置里非空时出现——回环 + 无令牌是允许的(研究里“至少一个 token”是建议不是铁律,
|
||||
//! 而我们用 `McpConfig::ensure_loopback` 硬性禁掉对外监听)。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 客户端配置样例(三件套:端点 URL + 两份可直接粘的 JSON)。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ConfigSamples {
|
||||
/// `http://127.0.0.1:<port>/mcp`。
|
||||
pub url: String,
|
||||
/// Claude Code(`claude mcp add` / `~/.claude.json` 的 `mcpServers` 片段)。
|
||||
pub claude_code: Value,
|
||||
/// Cursor(`.cursor/mcp.json` 的 `mcpServers` 片段)。
|
||||
pub cursor: Value,
|
||||
}
|
||||
|
||||
/// 生成样例。`token` 非空时补 `headers.Authorization`。
|
||||
#[must_use]
|
||||
pub fn config_samples(url: &str, token: Option<&str>) -> ConfigSamples {
|
||||
let headers = token
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.map(|t| json!({ "Authorization": format!("Bearer {t}") }));
|
||||
|
||||
let mut claude_entry = json!({ "type": "http", "url": url });
|
||||
let mut cursor_entry = json!({ "url": url });
|
||||
if let Some(headers) = &headers {
|
||||
claude_entry["headers"] = headers.clone();
|
||||
cursor_entry["headers"] = headers.clone();
|
||||
}
|
||||
|
||||
ConfigSamples {
|
||||
url: url.to_string(),
|
||||
claude_code: json!({ "mcpServers": { "unlucky-launcher": claude_entry } }),
|
||||
cursor: json!({ "mcpServers": { "unlucky-launcher": cursor_entry } }),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn claude_code_needs_type_http_and_cursor_does_not() {
|
||||
let samples = config_samples("http://127.0.0.1:47823/mcp", None);
|
||||
assert_eq!(
|
||||
samples.claude_code["mcpServers"]["unlucky-launcher"]["type"],
|
||||
json!("http")
|
||||
);
|
||||
assert_eq!(
|
||||
samples.claude_code["mcpServers"]["unlucky-launcher"]["url"],
|
||||
json!("http://127.0.0.1:47823/mcp")
|
||||
);
|
||||
assert!(samples.cursor["mcpServers"]["unlucky-launcher"]
|
||||
.get("type")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
samples.cursor["mcpServers"]["unlucky-launcher"]["url"],
|
||||
json!("http://127.0.0.1:47823/mcp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_becomes_bearer_header_and_empty_token_is_skipped() {
|
||||
let with = config_samples("http://127.0.0.1:1/mcp", Some("s3cret"));
|
||||
let header = &with.cursor["mcpServers"]["unlucky-launcher"]["headers"]["Authorization"];
|
||||
assert_eq!(header, &json!("Bearer s3cret"));
|
||||
|
||||
let without = config_samples("http://127.0.0.1:1/mcp", Some(" "));
|
||||
assert!(without.claude_code["mcpServers"]["unlucky-launcher"]
|
||||
.get("headers")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! 两种传输:stdio(llama.cpp 内置 MCP client 用)与 Streamable HTTP(主力)。
|
||||
//!
|
||||
//! 传输定论(02 §0):**外部 MCP 主力是 127.0.0.1 上的 Streamable HTTP**,
|
||||
//! 因为 stdio 形态要被外部 agent 拉起 = 同一份数据目录出现第二个实例,
|
||||
//! 迟早撞上单写者锁(M18 的会话 jsonl、M12 的 launcher.json 都是单写者)。
|
||||
//! stdio 保留给**已经常驻的**本地模型服务(llama.cpp server 的 MCP client 由它自己拉子进程)。
|
||||
//!
|
||||
//! 令牌:`Authorization: Bearer <agent.mcp.token>`。设置里为空 = 不校验(只允许回环监听,
|
||||
//! `McpConfig::ensure_loopback` 已经拦掉对外监听)。research/02 §4.4 的原话是
|
||||
//! 「至少要有 headers 里的一枚随机 token」——我们做成可配、默认留空但样例里带。
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::extract::Request;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
||||
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
|
||||
use rmcp::ServiceExt;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::mcp::server::RegistryServer;
|
||||
use crate::mcp::{McpConfig, McpServeError};
|
||||
|
||||
/// stdio 形态:读 stdin 写 stdout,直到对端关闭。
|
||||
///
|
||||
/// 注意:**stdout 只能有 JSON-RPC**。任何日志都要走 stderr(否则客户端解析失败)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 传输中断或初始化失败时返回 [`McpServeError`]。
|
||||
pub async fn serve_stdio(server: RegistryServer) -> Result<(), McpServeError> {
|
||||
let service = server
|
||||
.serve(rmcp::transport::stdio())
|
||||
.await
|
||||
.map_err(|error| McpServeError::Io(format!("stdio 初始化失败:{error}")))?;
|
||||
service
|
||||
.waiting()
|
||||
.await
|
||||
.map_err(|error| McpServeError::Io(format!("stdio 传输中断:{error}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 绑定 Streamable HTTP 监听(**只绑回环**),返回实际地址。
|
||||
///
|
||||
/// 拆出来是为了让调用方在进入 `axum::serve` 之前就能打印「监听在哪」——
|
||||
/// 端口被占用时也能拿到一条可操作的错误(tower 的提醒:撞端口要给出换端口的指引)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 非回环地址、端口被占用、或绑定失败时返回 [`McpServeError`]。
|
||||
pub async fn bind(cfg: &McpConfig) -> Result<(SocketAddr, TcpListener), McpServeError> {
|
||||
cfg.ensure_loopback()?;
|
||||
let addr = cfg.socket_addr();
|
||||
let listener = TcpListener::bind(&addr).await.map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::AddrInUse {
|
||||
McpServeError::PortInUse { addr: addr.clone() }
|
||||
} else {
|
||||
McpServeError::Io(format!("绑定 {addr} 失败:{error}"))
|
||||
}
|
||||
})?;
|
||||
let local = listener
|
||||
.local_addr()
|
||||
.map_err(|error| McpServeError::Io(error.to_string()))?;
|
||||
Ok((local, listener))
|
||||
}
|
||||
|
||||
/// Streamable HTTP 形态:`POST /mcp` 一条 JSON-RPC 请求 / 一条响应。
|
||||
///
|
||||
/// `json_response = true`:简单请求直接回 `application/json`(脚本与裸 JSON-RPC
|
||||
/// 客户端最省事;需要服务端推送时 rmcp 会自动回落到 SSE)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 绑定失败(见 [`bind`])或服务运行期致命错误时返回 [`McpServeError`]。
|
||||
pub async fn serve_http(server: RegistryServer, cfg: McpConfig) -> Result<(), McpServeError> {
|
||||
let (local, listener) = bind(&cfg).await?;
|
||||
serve_http_on(server, cfg, local, listener).await
|
||||
}
|
||||
|
||||
/// 在已绑定的监听上跑(测试用临时端口,避免抢 47823)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 服务运行期致命错误时返回 [`McpServeError`]。
|
||||
pub async fn serve_http_on(
|
||||
server: RegistryServer,
|
||||
cfg: McpConfig,
|
||||
local: SocketAddr,
|
||||
listener: TcpListener,
|
||||
) -> Result<(), McpServeError> {
|
||||
let http_config = StreamableHttpServerConfig::default()
|
||||
.with_json_response(true)
|
||||
.with_legacy_session_mode(false);
|
||||
let factory_server = server.clone();
|
||||
let service: StreamableHttpService<RegistryServer, LocalSessionManager> =
|
||||
StreamableHttpService::new(
|
||||
move || Ok(factory_server.clone()),
|
||||
std::sync::Arc::new(LocalSessionManager::default()),
|
||||
http_config,
|
||||
);
|
||||
|
||||
let token = cfg.token.clone();
|
||||
let app = axum::Router::new()
|
||||
.nest_service("/mcp", service)
|
||||
.layer(axum::middleware::from_fn(
|
||||
move |request: Request, next: Next| {
|
||||
let token = token.clone();
|
||||
async move { authorize(token, request, next).await }
|
||||
},
|
||||
));
|
||||
|
||||
eprintln!(
|
||||
"[unlucky-agent] MCP server 监听 http://{local}/mcp({} 个工具;令牌:{})",
|
||||
server.tool_count(),
|
||||
if cfg.token.is_some() {
|
||||
"已启用"
|
||||
} else {
|
||||
"未设置(仅回环监听)"
|
||||
}
|
||||
);
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.map_err(|error| McpServeError::Io(error.to_string()))
|
||||
}
|
||||
|
||||
/// Bearer 令牌闸门:未配置 = 放行;配置了则严格比对。
|
||||
async fn authorize(token: Option<String>, request: Request, next: Next) -> Response {
|
||||
let Some(expected) = token else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
let presented = request
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
if presented == format!("Bearer {expected}") {
|
||||
return next.run(request).await;
|
||||
}
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"缺少或错误的 Authorization: Bearer <agent.mcp.token>",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! rmcp `ServerHandler`:把同一份注册表挂成 MCP 工具路由。
|
||||
//!
|
||||
//! 每次 `tools/call` 都走 [`ToolExecutor`]——schema 校验、确认闸门、YOLO、审计、
|
||||
//! 事件**与内置客户端完全同一条路径**(02 §1 铁律)。工具级失败返回
|
||||
//! `CallToolResult::error`(客户端能看到文案),协议级错误只留给「路由不到」的情况
|
||||
//! (research/02 §3.2:`Err(McpError)` 会被大多数客户端吞成 "internal error")。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rmcp::handler::server::tool::{ToolCallContext, ToolRoute, ToolRouter};
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
|
||||
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
|
||||
};
|
||||
use rmcp::service::RequestContext;
|
||||
use rmcp::{ErrorData as McpError, RoleServer, ServerHandler};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::brain::chat::ToolCall;
|
||||
use crate::brain::exec::ToolExecutor;
|
||||
use crate::mcp::projection::tool_attr;
|
||||
use crate::registry::{Access, ToolRegistry};
|
||||
|
||||
/// MCP server 的 `ServerHandler` 实现。
|
||||
///
|
||||
/// `Clone` 是 Streamable HTTP 要的:每个会话/请求由工厂造一份(共享同一批 `Arc`,
|
||||
/// 因此审计与策略仍是同一份)。
|
||||
#[derive(Clone)]
|
||||
pub struct RegistryServer {
|
||||
router: ToolRouter<RegistryServer>,
|
||||
visible: Arc<Vec<Tool>>,
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RegistryServer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RegistryServer")
|
||||
.field("session_id", &self.session_id)
|
||||
.field("tools", &self.visible.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RegistryServer {
|
||||
/// 用注册表 + 执行器装配。
|
||||
///
|
||||
/// **`router_registry` 必须是完整注册表**(含写工具),`executor` 才决定「哪些工具真能跑」:
|
||||
/// - 只读模式:`-executor` 用 `read_only_subset()` 装配,`tools/list` 只列只读项,
|
||||
/// 硬调写工具会路由到执行器 → `unknown_tool`(**tool-level 错误 + 审计**),
|
||||
/// 而不是 rmcp 的协议级 `-32602 tool not found`(那个客户端只会显示 "internal error")。
|
||||
/// - 写模式:两者都是完整注册表。
|
||||
///
|
||||
/// `session_id` 进审计的 `agent_session_id` 字段(`mcp-http` / `mcp-stdio`),
|
||||
/// 让复盘时能区分「内置对话改的」和「外部 MCP 改的」。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
router_registry: Arc<ToolRegistry>,
|
||||
executor: Arc<ToolExecutor>,
|
||||
session_id: impl Into<String>,
|
||||
read_only: bool,
|
||||
) -> Self {
|
||||
let mut router = ToolRouter::new();
|
||||
for spec in router_registry.specs() {
|
||||
let attr = tool_attr(&spec.definition(), spec.access);
|
||||
router.add_route(route(attr, Arc::clone(&executor)));
|
||||
}
|
||||
let visible: Vec<Tool> = router_registry
|
||||
.specs()
|
||||
.iter()
|
||||
.filter(|spec| !read_only || spec.access == Access::ReadOnly)
|
||||
.map(|spec| tool_attr(&spec.definition(), spec.access))
|
||||
.collect();
|
||||
Self {
|
||||
router,
|
||||
visible: Arc::new(visible),
|
||||
session_id: session_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 本 server **对外可见**的工具(`tools/list` 的内容)。
|
||||
#[must_use]
|
||||
pub fn tools(&self) -> Vec<Tool> {
|
||||
(*self.visible).clone()
|
||||
}
|
||||
|
||||
/// 审计用的会话标签。
|
||||
#[must_use]
|
||||
pub fn session_id(&self) -> &str {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
/// 对外可见的工具数(启动日志/断言用)。
|
||||
#[must_use]
|
||||
pub fn tool_count(&self) -> usize {
|
||||
self.visible.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn route(attr: Tool, executor: Arc<ToolExecutor>) -> ToolRoute<RegistryServer> {
|
||||
ToolRoute::new_dyn(attr, move |context: ToolCallContext<'_, RegistryServer>| {
|
||||
let executor = Arc::clone(&executor);
|
||||
let session_id = context.service.session_id().to_string();
|
||||
let name = context.name().to_string();
|
||||
let arguments = context
|
||||
.arguments
|
||||
.clone()
|
||||
.map_or_else(|| Value::Object(serde_json::Map::new()), Value::Object);
|
||||
|
||||
Box::pin(async move {
|
||||
let call = ToolCall {
|
||||
id: format!("mcp-{name}"),
|
||||
name,
|
||||
arguments,
|
||||
};
|
||||
let outcome = executor.execute(&session_id, &call, 0).await;
|
||||
let text = outcome.to_model_content();
|
||||
let content = vec![ContentBlock::text(text)];
|
||||
let result = if outcome.ok {
|
||||
CallToolResult::structured(outcome.content)
|
||||
} else {
|
||||
CallToolResult::error(content)
|
||||
};
|
||||
Ok(result.into())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
impl ServerHandler for RegistryServer {
|
||||
fn get_info(&self) -> ServerConfig {
|
||||
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(Implementation::new(
|
||||
"unlucky-launcher",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
.with_instructions(
|
||||
"Unlucky-Launcher 的工具服务:读写启动器配置、看实例/版本/Java、读游戏日志、跑崩溃诊断。\
|
||||
账户凭据永不经由本服务出口;写配置受 schema 白名单与敏感键确认约束。",
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, McpError> {
|
||||
Ok(ListToolsResult::with_all_items(self.tools()))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResponse, McpError> {
|
||||
let call_context = ToolCallContext::new(self, request, context);
|
||||
self.router.call(call_context).await
|
||||
}
|
||||
|
||||
fn get_tool(&self, name: &str) -> Option<Tool> {
|
||||
self.router.get(name).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::audit::MemoryAudit;
|
||||
use crate::clock::{FixedClock, SharedClock};
|
||||
use crate::confirm::{AutoDenyGate, ConfirmPolicy};
|
||||
use crate::event::NoopSink;
|
||||
use crate::testing::mock_ports;
|
||||
use crate::tools::build_v1_registry;
|
||||
use serde_json::json;
|
||||
|
||||
fn server(registry: Arc<ToolRegistry>, read_only: bool) -> (RegistryServer, Arc<MemoryAudit>) {
|
||||
let audit = Arc::new(MemoryAudit::new());
|
||||
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
|
||||
let exposed = if read_only {
|
||||
Arc::new(registry.read_only_subset())
|
||||
} else {
|
||||
Arc::clone(®istry)
|
||||
};
|
||||
let executor = Arc::new(ToolExecutor::new(
|
||||
exposed,
|
||||
audit.clone(),
|
||||
Arc::new(AutoDenyGate),
|
||||
ConfirmPolicy::default(),
|
||||
clock,
|
||||
Arc::new(NoopSink),
|
||||
));
|
||||
(
|
||||
RegistryServer::new(registry, executor, "mcp-test", read_only),
|
||||
audit,
|
||||
)
|
||||
}
|
||||
|
||||
fn registry() -> Arc<ToolRegistry> {
|
||||
let (ports, _settings) = mock_ports();
|
||||
Arc::new(build_v1_registry(&ports).expect("注册表应装配成功"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_every_registered_tool_in_write_mode() {
|
||||
let registry = registry();
|
||||
let (server, _audit) = server(Arc::clone(®istry), false);
|
||||
let tools = server.tools();
|
||||
assert_eq!(tools.len(), registry.len());
|
||||
assert_eq!(server.tool_count(), registry.len());
|
||||
assert_eq!(server.session_id(), "mcp-test");
|
||||
assert!(tools.iter().any(|tool| tool.name == "set_config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_mode_hides_the_writing_tool_but_keeps_it_routable() {
|
||||
let (server, _audit) = server(registry(), true);
|
||||
let names: Vec<String> = server.tools().iter().map(|t| t.name.to_string()).collect();
|
||||
assert!(!names.iter().any(|n| n == "set_config"), "{names:?}");
|
||||
assert!(names.iter().any(|n| n == "get_config"));
|
||||
assert_eq!(names.len(), 9);
|
||||
// 路由仍然存在(否则 rmcp 会回协议级 -32602,客户端看到的是 "internal error"),
|
||||
// 真正拦下来的是只读子集执行器 → tool-level 错误 + 审计。
|
||||
assert!(server.router.get("set_config").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_advertises_tools_and_a_stable_identity() {
|
||||
let (server, _audit) = server(registry(), false);
|
||||
let info = server.get_info();
|
||||
assert!(info.capabilities.tools.is_some());
|
||||
assert_eq!(info.server_info.name, "unlucky-launcher");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_tool_returns_the_projected_attr() {
|
||||
let (server, _audit) = server(registry(), false);
|
||||
let attr = server.get_tool("get_config").expect("工具存在");
|
||||
assert_eq!(attr.name, "get_config");
|
||||
assert_eq!(
|
||||
Value::Object((*attr.input_schema).clone())["type"],
|
||||
json!("object")
|
||||
);
|
||||
assert!(server.get_tool("nope").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! 端口层(port):agent 与 `ull-core` 各模块之间**唯一**的接触面。
|
||||
//!
|
||||
//! 为什么不直接 `use ull_core::*`:M12(setting)/ M14(game)/ M15(diagnose)/ M18(launch)
|
||||
//! 与本 crate 并行开发,直接依赖会把 agent 的编译期绑死在别人的 API 日期上。这里用
|
||||
//! **依赖倒置**——agent 只声明自己需要的形状(本文件的 trait + 结构体),由 `crates/app`
|
||||
//! 在装配处用 `ull-core` 的实现去满足(`#![forbid(unsafe_code)]` 的纯适配层)。
|
||||
//!
|
||||
//! | 端口 | 提供的工具 | 对方模块 / 数据源 |
|
||||
//! |------|-----------|------------------|
|
||||
//! | [`SettingPort`] | `get_config` / `set_config` | M12 · `config/*.json` schema |
|
||||
//! | [`GameSessionPort`] | `list_game_sessions` / `read_game_log` | M18 · `agent/sessions/game/{index,<id>}.jsonl` |
|
||||
//! | [`DiagnosePort`] | `diagnose_crash` | M15 · 规则引擎 |
|
||||
//!
|
||||
//! 形状依据(以对方文件为准,改字段先改对方文件):
|
||||
//! - `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5(事件行 + 索引行)
|
||||
//! - `DevDocs/SubAgentsTalk/agent-ToolSpec约定.md`(本 crate 对外的对齐说明)
|
||||
//!
|
||||
//! 错误一律用 `Result<T, String>`:错误文本会**原样进工具结果**给模型与用户看
|
||||
//! (rmcp 的 `CallToolResult::error` 同理——协议级错误不给模型看,见 research/02 §3.2)。
|
||||
//! 因此端口实现写中文、面向用户、可执行(「配置键 `x` 不存在」而不是 `ENOENT`)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::confirm::Sensitivity;
|
||||
|
||||
pub mod game;
|
||||
|
||||
pub use game::{
|
||||
AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus,
|
||||
VersionSummary,
|
||||
};
|
||||
|
||||
/// 端口调用结果的错误文本(面向用户可读,直接进工具结果)。
|
||||
pub type PortResult<T> = Result<T, String>;
|
||||
|
||||
/// agent 需要的全部端口。装配处(`crates/app` / `ull agent serve`)用它一次性注入。
|
||||
#[derive(Clone)]
|
||||
pub struct Ports {
|
||||
/// 配置读写(M12)。
|
||||
pub setting: Arc<dyn SettingPort>,
|
||||
/// 游戏会话与日志(M18)。
|
||||
pub sessions: Arc<dyn GameSessionPort>,
|
||||
/// 崩溃规则引擎(M15)。
|
||||
pub diagnose: Arc<dyn DiagnosePort>,
|
||||
/// 实例 / 版本 / Java(M14 + M33)。
|
||||
pub game: Arc<dyn GamePort>,
|
||||
/// 账户元数据(M31,**只有公开字段**,凭据永不出口)。
|
||||
pub accounts: Arc<dyn AccountPort>,
|
||||
}
|
||||
|
||||
// ───────────────────────────── 配置(M12) ─────────────────────────────
|
||||
|
||||
/// AI 可写白名单里的一个键,带**敏感分级**(02 文档 §3.1 的「分级确认」依据)。
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct SettingKey {
|
||||
/// 点分路径,如 `download.concurrency`、`java.selected_path`(键名以 M12 的 schema 为准)。
|
||||
pub path: String,
|
||||
/// 敏感等级(M12 `Low/Medium/High`):只有 `High` 需要用户确认。
|
||||
pub sensitivity: Sensitivity,
|
||||
/// 值类型(`string` / `integer` / `boolean` / `enum` / `json`),用于错误提示。
|
||||
pub value_type: String,
|
||||
/// 一句话说明,同时进 `get_config` 概要与模型上下文。
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 一次成功的写入,带旧值即「diff」(research/02 §6.4 的 dry-run 落点)。
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct SettingChange {
|
||||
/// 被修改的键路径。
|
||||
pub path: String,
|
||||
/// 修改前的值;键此前不存在时为 `None`。
|
||||
pub old: Option<Value>,
|
||||
/// 写入后的值。
|
||||
pub new: Value,
|
||||
}
|
||||
|
||||
/// 配置端口(M12 实现,`ull-core::setting` 的 schema API 直译)。
|
||||
///
|
||||
/// agent 侧只做**白名单 + 敏感分级 + 确认编排**,值本身的范围/类型校验归 setting 模块所有
|
||||
/// (单一真相源:配置 schema 在 M12,agent 不复制一份)。
|
||||
pub trait SettingPort: Send + Sync {
|
||||
/// 全量概要(`get_config()` 无参时返回):键 → 当前值的扁平表,敏感键已脱敏。
|
||||
fn summary(&self) -> Value;
|
||||
/// 读单个键。键不存在时返回人类可读错误。
|
||||
///
|
||||
/// **凭据契约**:`writable=false` 的凭据类键(`ai.api_key` 等)必须**拒绝或掩码**,
|
||||
/// 不得返回明文——agent 侧还有一道 `redact::is_sensitive_key` 守卫,但端口是数据的
|
||||
/// 第一道闸(模型上下文与对话历史都会带上这里的返回值)。
|
||||
fn get(&self, path: &str) -> PortResult<Value>;
|
||||
/// 写单个键(值校验由实现方负责),返回 diff。
|
||||
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange>;
|
||||
/// **AI 可写白名单**(含敏感分级)。不在表里的键 agent 一律拒绝。
|
||||
fn writable_keys(&self) -> Vec<SettingKey>;
|
||||
/// 配置 schema 的给 AI 版本(`get_config_schema` 工具):只含 AI 可写键与
|
||||
/// `x-ai-sensitivity` 标注,凭据类键天然不在其中(M12 `SettingSchema::to_json_schema`)。
|
||||
fn schema(&self) -> Value;
|
||||
}
|
||||
|
||||
// ──────────────────────── 游戏会话与日志(M18) ────────────────────────
|
||||
|
||||
/// `index.jsonl` 一行 → 一个会话摘要(同 id 多行由实现方取最后一行)。
|
||||
///
|
||||
/// 字段对齐 `launch-接口约定.md` §5;对方新增字段时这里补 `Option`,不破坏旧实现。
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
|
||||
pub struct GameSessionSummary {
|
||||
/// 会话 id,格式 `YYYYMMDDTHHMMSS-<6 位 hex>`(UTC)。
|
||||
pub session_id: String,
|
||||
/// 实例 id。
|
||||
pub instance_id: Option<String>,
|
||||
/// 实例显示名。
|
||||
pub instance_name: Option<String>,
|
||||
/// MC 版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 启动时间(RFC3339 UTC)。
|
||||
pub started_at: Option<String>,
|
||||
/// 结束时间;会话未结束时为 `None`。
|
||||
pub ended_at: Option<String>,
|
||||
/// 时长毫秒。
|
||||
pub duration_ms: u64,
|
||||
/// 退出码;未正常结束时可能缺失。
|
||||
pub exit_code: Option<i32>,
|
||||
/// `exit_type`:`normal|application_error|jvm_error|sigkill|sigterm|killed|interrupted`。
|
||||
pub exit_type: Option<String>,
|
||||
/// exit 0 但疑似静默失败(D8 升级 1)。
|
||||
pub silent_failure: bool,
|
||||
/// 是否检测到游戏窗口。
|
||||
pub window_detected: bool,
|
||||
/// 事件条数。
|
||||
pub event_count: u64,
|
||||
/// 是否发现 crash-report / hs_err。
|
||||
pub crash_found: bool,
|
||||
/// 规则引擎首选结论 id(`diagnosis` 事件的 top candidate)。
|
||||
pub diagnosis_top: Option<String>,
|
||||
}
|
||||
|
||||
/// `read_game_log` 的查询条件:**按事件 seq 切片**,不是按行号
|
||||
/// (stdout/stderr 是一行一事件,但同一条日志行可能被切成多块,见 launch §1)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct LogQuery {
|
||||
/// 起始 `seq`(含)。
|
||||
pub from_seq: Option<u64>,
|
||||
/// 结束 `seq`(含)。
|
||||
pub to_seq: Option<u64>,
|
||||
/// 只要这些 `kind`(空 = 全部)。未知 kind 由实现方忽略而不是报错(前向兼容)。
|
||||
pub kinds: Vec<String>,
|
||||
/// 最多返回多少条事件(工具层已钳到 [`crate::tools::session::MAX_LOG_EVENTS`])。
|
||||
pub max_events: usize,
|
||||
}
|
||||
|
||||
/// 一条事件行(`{v,seq,ts,t_ms,kind,session_id,payload}`),落盘时已脱敏。
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct LogEvent {
|
||||
/// 会话内单调递增序号。
|
||||
pub seq: u64,
|
||||
/// RFC3339 UTC。
|
||||
pub ts: String,
|
||||
/// 距会话开始的毫秒数。
|
||||
pub t_ms: u64,
|
||||
/// 事件类型(`stdout|stderr|exit|crash_found|diagnosis|…`)。
|
||||
pub kind: String,
|
||||
/// 事件负载,原样透传(已脱敏)。
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
/// 一页日志事件 + 全量统计(模型拿到摘要就不会盲目翻页,防 token 爆炸)。
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
|
||||
pub struct LogPage {
|
||||
/// 会话 id。
|
||||
pub session_id: String,
|
||||
/// 文件内事件总数。
|
||||
pub total_events: u64,
|
||||
/// 命中过滤条件的事件数。
|
||||
pub matched_events: u64,
|
||||
/// 是否因 `max_events` 截断。
|
||||
pub truncated: bool,
|
||||
/// 全量 kind 计数(不受过滤影响)。
|
||||
pub kind_counts: BTreeMap<String, u64>,
|
||||
/// 首事件时间。
|
||||
pub first_ts: Option<String>,
|
||||
/// 末事件时间。
|
||||
pub last_ts: Option<String>,
|
||||
/// 本次返回的事件(已按 seq 升序)。
|
||||
pub events: Vec<LogEvent>,
|
||||
}
|
||||
|
||||
/// 会话端口(M18 实现,`ull-core::launch::session_index` + `session` 的读侧)。
|
||||
///
|
||||
/// 读取**必须容忍最后一行不完整**(会话被 kill 时 jsonl 尾部可能截断)——这是实现方义务,
|
||||
/// 由 `launch-接口约定.md` §1 规定,agent 只消费结果。
|
||||
pub trait GameSessionPort: Send + Sync {
|
||||
/// 按 `started_at` 倒序、按 `session_id` 去重后的会话列表;`instance_id` 非空时过滤。
|
||||
fn list_sessions(
|
||||
&self,
|
||||
limit: usize,
|
||||
instance_id: Option<&str>,
|
||||
) -> PortResult<Vec<GameSessionSummary>>;
|
||||
/// 按 [`LogQuery`] 切片读取一个会话的事件流。
|
||||
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage>;
|
||||
}
|
||||
|
||||
// ───────────────────────── 崩溃诊断(M15) ─────────────────────────
|
||||
|
||||
/// 六维修复动作(M15 `diagnose::rules::FixActionKind`,internally-tagged enum,
|
||||
/// YAML 里写 `kind:`)。序列化名与 M15 逐字相同:`change_java|adjust_memory|
|
||||
/// change_graphics|remove_mod|reinstall|seek_help`。
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FixActionKind {
|
||||
/// 换 Java(大版本上下界/指定实现)。
|
||||
ChangeJava,
|
||||
/// 调 Xmx/Xms、64 位 Java、系统虚拟内存。
|
||||
AdjustMemory,
|
||||
/// 驱动/渲染器/分辨率/覆盖层。
|
||||
ChangeGraphics,
|
||||
/// 删/禁/换 mod。
|
||||
RemoveMod,
|
||||
/// 重装/修组件(java|game_jar|loader|natives|libraries|assets|mods…)。
|
||||
Reinstall,
|
||||
/// 导出诊断包 / 需要更多信息。
|
||||
SeekHelp,
|
||||
}
|
||||
|
||||
/// 一条修复建议(M15 的 `MaterializedFixAction { kind, tool, args }` + 一句人话)。
|
||||
///
|
||||
/// `tool` 是 M15 给的**语义动作 id**(`java.set` / `memory.set` / …)。P0 没有这些细分工具,
|
||||
/// 所以 agent 侧按 [`FixActionKind`] 桥接到现有工具(见 [`crate::tools::diagnose`]),
|
||||
/// `tool` 只原样透传给模型与审计,不用于分发。
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct FixAction {
|
||||
/// 六维动作类别。
|
||||
pub kind: FixActionKind,
|
||||
/// 语义动作 id(如 `memory.set`);P1 落对应工具后可直接当工具名。
|
||||
pub tool: String,
|
||||
/// 一句话建议(可为空,agent 会按 `kind` 补兜底文案)。
|
||||
pub label: String,
|
||||
/// 已做模板替换与单位换算的参数(M15 `materialize()` 的产物)。
|
||||
pub args: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// 一条诊断候选(M15 `DiagnosisRecord::candidates[]`)。
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct DiagnosisCandidate {
|
||||
/// 规则 id(如 `jvm.32bit`)。
|
||||
pub id: String,
|
||||
/// `critical|error|warn|info`。
|
||||
pub severity: String,
|
||||
/// 分组(`jvm|memory|graphics|integrity|mod|…`)。
|
||||
pub category: String,
|
||||
/// 置信度 0.0~1.0。
|
||||
pub confidence: f32,
|
||||
/// i18n key(如 `diagnose.cause.jvm.32bit`)。
|
||||
pub message_key: String,
|
||||
/// **渲染后**的人类可读结论(M15 保证非空;agent 侧只在极旧数据上回落 `message_key`)。
|
||||
pub message: String,
|
||||
/// 更细的处理建议(M15 `detail`,可能为空)。
|
||||
pub detail: String,
|
||||
/// 证据行(已脱敏)。
|
||||
pub evidence: Vec<String>,
|
||||
/// 该候选的修复动作。
|
||||
pub fix_actions: Vec<FixAction>,
|
||||
}
|
||||
|
||||
/// 退出摘要(M15 `DiagnosisRecord::exit`,与 launch 的 `exit` 事件同源)。
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
|
||||
pub struct ExitSummary {
|
||||
/// 退出码。
|
||||
pub code: Option<i32>,
|
||||
/// `normal|application_error|jvm_error|sigkill|sigterm|killed|interrupted`。
|
||||
pub exit_type: Option<String>,
|
||||
/// exit 0 但疑似静默失败。
|
||||
pub silent_failure: bool,
|
||||
}
|
||||
|
||||
/// 规则引擎结论:**这就是给 LLM 的全部输入**(不是裸日志)。
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct CrashDiagnosis {
|
||||
/// 会话 id。
|
||||
pub session_id: String,
|
||||
/// `rules|ai`(谁产出的)。
|
||||
pub source: String,
|
||||
/// 规则拿不准、需要更多信息时置 true(此时才值得让 LLM 深挖)。
|
||||
pub needs_more_info: bool,
|
||||
/// 主因候选,`candidates[0]` 即首因(M15 已按 priority→confidence 排序)。
|
||||
pub candidates: Vec<DiagnosisCandidate>,
|
||||
/// 被抑制的次要候选。
|
||||
pub secondary: Vec<DiagnosisCandidate>,
|
||||
/// 会话级关键片段(`crash_found.excerpt` / `hs_err` 摘要,已脱敏)。
|
||||
pub evidence: Vec<String>,
|
||||
/// 退出摘要。
|
||||
pub exit: ExitSummary,
|
||||
}
|
||||
|
||||
impl CrashDiagnosis {
|
||||
/// 首选候选(主因)。
|
||||
#[must_use]
|
||||
pub fn top(&self) -> Option<&DiagnosisCandidate> {
|
||||
self.candidates.first()
|
||||
}
|
||||
|
||||
/// 最高置信度(没有候选时为 0)。
|
||||
#[must_use]
|
||||
pub fn top_confidence(&self) -> f32 {
|
||||
self.top().map_or(0.0, |c| c.confidence)
|
||||
}
|
||||
}
|
||||
|
||||
/// 诊断端口(M15 实现)。agent 只做「调规则引擎 → 把结论 + 片段交给 LLM」。
|
||||
pub trait DiagnosePort: Send + Sync {
|
||||
/// 对一个会话跑规则引擎。会话不存在/没有日志时返回人类可读错误。
|
||||
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis>;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 实例 / 版本 / Java / 账户四个读侧端口(M39 的只读工具集用)。
|
||||
//!
|
||||
//! 与 [`super`] 的既有三个端口同形:trait 由 agent 声明,实现在装配层用 `unlucky-core` 填充。
|
||||
//! 拆分出来的原因是 `port.rs` 挨着 400 行硬上限(`guards.yml` 的 file-size 门禁)——
|
||||
//! 「会话与日志」「崩溃诊断」留在父模块,「游戏与账户」在这里,语义上也是一刀干净的分界。
|
||||
//!
|
||||
//! **凭据红线**(D9 / 02 文档 §3.1):账户端口只露 [`AccountSummary`] 的公开字段。
|
||||
//! `Credentials`(access token / 刷新令牌 / authlib 密码)在 core 的 Secret 体系里,
|
||||
//! 根本不出现在本文件的类型里,因此不可能被工具结果带出去。
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::PortResult;
|
||||
|
||||
// ───────────────────────── 实例 / 版本(M14) ─────────────────────────
|
||||
|
||||
/// 一个实例(`profiles.json` 一行)的摘要:模型只需要「能定位、能判断载荷在不在」。
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct InstanceSummary {
|
||||
/// 实例 id。
|
||||
pub id: String,
|
||||
/// 显示名。
|
||||
pub name: String,
|
||||
/// 版本 id(如 `1.21.1` / `fabric-loader-0.16.9-1.21.1`)。
|
||||
pub version_id: String,
|
||||
/// 该版本的载荷是否真的在 `versions/` 下(`false` = 装了档案但载荷缺失)。
|
||||
pub version_present: bool,
|
||||
/// 独立游戏目录(未覆盖时为 `None`)。
|
||||
pub game_dir: Option<String>,
|
||||
/// 实例级 Java 覆盖路径(未覆盖时为 `None`)。
|
||||
pub java_path: Option<String>,
|
||||
/// 上次启动时刻(Unix 毫秒;从未启动为 `None`)。
|
||||
pub last_played_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// 已安装版本的摘要(`versions/<id>/` 的存在性 + 可读的元信息)。
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct VersionSummary {
|
||||
/// 版本 id。
|
||||
pub id: String,
|
||||
/// 版本 JSON 里声明的 `type`(`release` / `snapshot` / 加载器自报),读不到为 `None`。
|
||||
pub kind: Option<String>,
|
||||
/// 被几个实例引用。
|
||||
pub instance_count: usize,
|
||||
}
|
||||
|
||||
// ──────────────────────────── Java(M33) ────────────────────────────
|
||||
|
||||
/// 一个探测到的 Java 运行时。
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct JavaRuntimeSummary {
|
||||
/// `java` 可执行文件路径。
|
||||
pub binary: String,
|
||||
/// 完整版本串(`21.0.2` / `1.8.0_402`)。
|
||||
pub version: Option<String>,
|
||||
/// 主版本号(8 / 17 / 21)。
|
||||
pub major: Option<u32>,
|
||||
/// 发行方(temurin / oracle / microsoft / zulu …)。
|
||||
pub vendor: Option<String>,
|
||||
/// 来源类别(`instance` / `managed` / `env` / `system`)。
|
||||
pub origin: String,
|
||||
}
|
||||
|
||||
/// Java 现状(`get_java_status`)。
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct JavaStatus {
|
||||
/// 默认堆内存(MB,`java.default_memory_mb`)。
|
||||
pub default_memory_mb: Option<u64>,
|
||||
/// 托管 JDK 的扫描根。
|
||||
pub managed_roots: Vec<String>,
|
||||
/// 探测到的候选(已去重,优先级从高到低)。
|
||||
pub candidates: Vec<JavaRuntimeSummary>,
|
||||
}
|
||||
|
||||
// ──────────────────────────── 账户(M31) ────────────────────────────
|
||||
|
||||
/// 账户的**公开字段**摘要。凭据类字段刻意不存在(凭据红线)。
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct AccountSummary {
|
||||
/// 条目 id。
|
||||
pub id: String,
|
||||
/// 轨道:`offline` / `microsoft` / `authlib`。
|
||||
pub kind: String,
|
||||
/// Minecraft 档案 UUID(32 位无连字符)。
|
||||
pub profile_id: String,
|
||||
/// 玩家名(可能为空串 = 有档案但名字待刷新)。
|
||||
pub profile_name: String,
|
||||
/// 登录名(仅 authlib 轨有值)。
|
||||
pub login_name: Option<String>,
|
||||
/// 创建时刻(Unix 毫秒)。
|
||||
pub created_at_ms: u64,
|
||||
}
|
||||
|
||||
/// 游戏资产端口(M14 实例/版本 + M33 Java 探测)。
|
||||
pub trait GamePort: Send + Sync {
|
||||
/// 全部实例(按上次启动时间倒序)。
|
||||
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>>;
|
||||
/// 已安装版本清单。
|
||||
fn list_versions(&self) -> PortResult<Vec<VersionSummary>>;
|
||||
/// Java 现状(候选 + 托管根 + 默认内存)。
|
||||
fn java_status(&self) -> PortResult<JavaStatus>;
|
||||
}
|
||||
|
||||
/// 账户端口(M31)。实现方只允许读 `AccountStore::accounts()`(`AccountMeta`),
|
||||
/// **不得**调用 `credentials()`——那是凭据出口,agent 侧没有对应字段可以承接。
|
||||
pub trait AccountPort: Send + Sync {
|
||||
/// 账户清单(公开字段)。
|
||||
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn account_summary_has_no_credential_fields() {
|
||||
// 把摘要序列化成 JSON:任何 token / password / secret 形态的键都不许出现。
|
||||
// 这是凭据红线的**可执行断言**——将来有人往结构体里加字段,这条测试先炸。
|
||||
let summary = AccountSummary {
|
||||
id: "a1".to_string(),
|
||||
kind: "offline".to_string(),
|
||||
profile_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
profile_name: "Steve".to_string(),
|
||||
login_name: Some("steve@example.com".to_string()),
|
||||
created_at_ms: 1_793_304_753_123,
|
||||
};
|
||||
let json = serde_json::to_string(&summary).expect("可序列化");
|
||||
for forbidden in ["token", "password", "secret", "credential", "refresh"] {
|
||||
assert!(
|
||||
!json.to_lowercase().contains(forbidden),
|
||||
"账户摘要不得包含 `{forbidden}`:{json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_status_round_trips() {
|
||||
let status = JavaStatus {
|
||||
default_memory_mb: Some(2048),
|
||||
managed_roots: vec!["/data/java-managed".to_string()],
|
||||
candidates: vec![JavaRuntimeSummary {
|
||||
binary: "/jdk/bin/java".to_string(),
|
||||
version: Some("21.0.2".to_string()),
|
||||
major: Some(21),
|
||||
vendor: Some("temurin".to_string()),
|
||||
origin: "managed".to_string(),
|
||||
}],
|
||||
};
|
||||
assert_eq!(status.candidates.len(), 1);
|
||||
assert_eq!(status.candidates[0].major, Some(21));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! 脱敏:审计日志的参数、以及日志工具返回文本的**第二道**防线。
|
||||
//!
|
||||
//! 第一道在 launch 落盘时(`launch-接口约定.md` §4:`<redacted:token>` / `<home>` /
|
||||
//! `<redacted:opaque>`),所以这里不重复实现那套;本模块只兜两件事:
|
||||
//! 1. 审计日志里的参数可能含 API key(`set_config` 能改 AI 端点)→ 按键名掩码;
|
||||
//! 2. 日志正文万一含用户主目录绝对路径(旧会话、第三方 mod 输出)→ 掩码用户名段。
|
||||
//!
|
||||
//! 幂等:对已脱敏文本再跑一遍结果不变(`<home>` / `***` 原样保留)。
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// 掩码占位。
|
||||
pub const MASK: &str = "***";
|
||||
|
||||
/// 键名是否敏感(不区分大小写,含子串即算)。
|
||||
///
|
||||
/// 覆盖:`api_key` / `apikey` / `accessToken` / `session_token` / `password` /
|
||||
/// `secret` / `authorization` / `credential`。
|
||||
#[must_use]
|
||||
pub fn is_sensitive_key(key: &str) -> bool {
|
||||
const NEEDLES: [&str; 8] = [
|
||||
"api_key",
|
||||
"apikey",
|
||||
"token",
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"authorization",
|
||||
"credential",
|
||||
];
|
||||
let lower = key.to_ascii_lowercase();
|
||||
NEEDLES.iter().any(|n| lower.contains(n))
|
||||
}
|
||||
|
||||
/// 审计用:递归把敏感键的值换成 [`MASK`](保留结构,读审计的人仍能看出改的是哪个键)。
|
||||
///
|
||||
/// 除了「键名本身敏感」之外,还认 `set_config {path, value}` 这种**值随路径敏感**的形状:
|
||||
/// 当同级的 `path` 是敏感键名(`ai.api_key`)时,`value`/`old`/`new` 一律掩码——
|
||||
/// 否则审计日志会把 API key 原文写进磁盘。
|
||||
#[must_use]
|
||||
pub fn redact_args(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let sensitive_path = map
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(is_sensitive_key);
|
||||
let mut out = Map::with_capacity(map.len());
|
||||
for (k, v) in map {
|
||||
let mask = is_sensitive_key(k)
|
||||
|| (sensitive_path && matches!(k.as_str(), "value" | "old" | "new"));
|
||||
let masked = if mask {
|
||||
Value::String(MASK.to_string())
|
||||
} else {
|
||||
redact_args(v)
|
||||
};
|
||||
out.insert(k.clone(), masked);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(items) => Value::Array(items.iter().map(redact_args).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本脱敏:用户主目录段 + `Bearer` 令牌。已脱敏文本原样返回(幂等)。
|
||||
#[must_use]
|
||||
pub fn redact_text(text: &str) -> String {
|
||||
let masked = mask_user_dirs(text);
|
||||
mask_bearer(&masked)
|
||||
}
|
||||
|
||||
/// 任意 JSON 的每个字符串都过 [`redact_text`](工具结果出口的第二道防线)。
|
||||
///
|
||||
/// 与 [`redact_args`] 的分工:那个按键名掩码(审计参数),这个按**内容**掩码
|
||||
/// (日志正文、账户展示名之类可能夹带主目录/Bearer 的字符串)。
|
||||
#[must_use]
|
||||
pub fn redact_texts(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::String(text) => Value::String(redact_text(text)),
|
||||
Value::Array(items) => Value::Array(items.iter().map(redact_texts).collect()),
|
||||
Value::Object(map) => Value::Object(
|
||||
map.iter()
|
||||
.map(|(key, child)| (key.clone(), redact_texts(child)))
|
||||
.collect(),
|
||||
),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `C:\Users\<名>` / `/home/<名>` / `/Users/<名>` → 用户名段替换为 [`MASK`]。
|
||||
fn mask_user_dirs(text: &str) -> String {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut cursor = 0usize;
|
||||
while cursor < text.len() {
|
||||
let Some((start, marker_len)) = find_marker(&lower, cursor) else {
|
||||
out.push_str(&text[cursor..]);
|
||||
break;
|
||||
};
|
||||
out.push_str(&text[cursor..start + marker_len]);
|
||||
let name_start = start + marker_len;
|
||||
let name_end = segment_end(text, name_start);
|
||||
let segment = &text[name_start..name_end];
|
||||
if segment.is_empty() || segment == MASK || segment.eq_ignore_ascii_case("<home>") {
|
||||
out.push_str(segment);
|
||||
} else {
|
||||
out.push_str(MASK);
|
||||
}
|
||||
cursor = name_end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 找最靠前的用户目录标记,返回 `(字节位置, 标记长度)`。标记须与 `lower` 同源。
|
||||
fn find_marker(lower: &str, from: usize) -> Option<(usize, usize)> {
|
||||
const MARKERS: [&str; 3] = ["c:\\users\\", "/home/", "/users/"];
|
||||
MARKERS
|
||||
.iter()
|
||||
.filter_map(|m| lower[from..].find(m).map(|p| (from + p, m.len())))
|
||||
.min_by_key(|(start, _)| *start)
|
||||
}
|
||||
|
||||
/// 从 `from` 起取到下一个路径分隔符/空白/引号为止。
|
||||
fn segment_end(text: &str, from: usize) -> usize {
|
||||
const STOPS: [char; 9] = ['\\', '/', '"', '\'', ' ', '\t', '\n', '\r', ','];
|
||||
text[from..]
|
||||
.find(STOPS)
|
||||
.map_or(text.len(), |offset| from + offset)
|
||||
}
|
||||
|
||||
/// `Bearer <token>` → `Bearer ***`。
|
||||
fn mask_bearer(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut cursor = 0usize;
|
||||
while let Some(pos) = text[cursor..].find("Bearer ") {
|
||||
let token_start = cursor + pos + "Bearer ".len();
|
||||
let token_end = segment_end(text, token_start);
|
||||
out.push_str(&text[cursor..token_start]);
|
||||
if token_end > token_start {
|
||||
out.push_str(MASK);
|
||||
}
|
||||
cursor = token_end;
|
||||
}
|
||||
out.push_str(&text[cursor..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn masks_sensitive_arg_keys_only() {
|
||||
let args = json!({
|
||||
"path": "launcher.theme",
|
||||
"value": "light",
|
||||
"nested": { "accessToken": "abc", "keep": 1 },
|
||||
"list": [{ "password": "p" }]
|
||||
});
|
||||
let red = redact_args(&args);
|
||||
// 非敏感路径:值原样保留(审计要能看出改成了什么)
|
||||
assert_eq!(red["value"], json!("light"));
|
||||
assert_eq!(red["path"], json!("launcher.theme"));
|
||||
// 键名敏感:值掩码
|
||||
assert_eq!(red["nested"]["accessToken"], json!(MASK));
|
||||
assert_eq!(red["nested"]["keep"], json!(1));
|
||||
assert_eq!(red["list"][0]["password"], json!(MASK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_value_when_path_itself_is_sensitive() {
|
||||
// `set_config {path: "ai.api_key", value: "sk-…"}` 是最容易漏的泄漏点
|
||||
let args = json!({ "path": "ai.api_key", "value": "sk-super-secret" });
|
||||
let red = redact_args(&args);
|
||||
assert_eq!(red["value"], json!(MASK));
|
||||
let rendered = red.to_string();
|
||||
assert!(!rendered.contains("sk-super-secret"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_windows_and_posix_home() {
|
||||
assert_eq!(
|
||||
redact_text(r"C:\Users\aska\.minecraft\logs\latest.log"),
|
||||
r"C:\Users\***\.minecraft\logs\latest.log"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_text("/home/aska/.minecraft/logs"),
|
||||
"/home/***/.minecraft/logs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_already_redacted_and_is_idempotent() {
|
||||
let once = redact_text(r"C:\Users\aska\game\log.txt");
|
||||
assert_eq!(once, r"C:\Users\***\game\log.txt");
|
||||
assert_eq!(redact_text(&once), once);
|
||||
assert_eq!(redact_text("<home>/.minecraft"), "<home>/.minecraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_bearer_token() {
|
||||
assert_eq!(
|
||||
redact_text("Authorization: Bearer eyJhbGciOi.payload.sig"),
|
||||
"Authorization: Bearer ***"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_sensitive_key_names() {
|
||||
assert!(is_sensitive_key("ai.api_key"));
|
||||
assert!(is_sensitive_key("AccessToken"));
|
||||
assert!(is_sensitive_key("proxy.password"));
|
||||
assert!(!is_sensitive_key("download.concurrency"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 工具注册表:整个 agent 的**唯一真相源**。
|
||||
//!
|
||||
//! 一个 `ToolSpec`(名称 + 描述 + serde JSON Schema)投影两份:
|
||||
//! - 内置客户端的 rig `DynamicTool` 视图;
|
||||
//! - 对外 MCP server 的 rmcp `ToolRouter` 视图。
|
||||
//!
|
||||
//! 两份投影必须一致,用契约测试固定(同 name / 同 description / 同 schema)。
|
||||
//! 设计依据:`DevDocs/PlanDoc/02-ai-agent-design.md` §1。
|
||||
//!
|
||||
//! 工具 v1 清单(M16 起,M39 扩到十个):`get_config` / `set_config` / `get_config_schema` /
|
||||
//! `list_instances` / `list_versions` / `get_java_status` / `list_accounts` /
|
||||
//! `list_game_sessions` / `read_game_log` / `diagnose_crash`。
|
||||
//!
|
||||
//! 安全策略随工具走:敏感键分级确认、YOLO 模式标记、审计留痕(02 §3.1 / §4b)。
|
||||
//!
|
||||
//! ## 落地形态
|
||||
//!
|
||||
//! ```text
|
||||
//! registry(Vec<ToolSpec>)
|
||||
//! ├─ definitions() → rig `DynamicTool::new` (内置 agent,`brain::rig`)
|
||||
//! └─ definitions() → rmcp `ToolRoute::new_dyn` (外部 MCP,`mcp::projection`)
|
||||
//! ```
|
||||
//!
|
||||
//! 防漂移的关键一招(research/02 §6.2 建议进 CI):**契约测试**遍历注册表,断言两侧
|
||||
//! 投影拿到的 `(name, description, schema)` 逐字节相等。因为两侧都只吃
|
||||
//! [`ToolDefinition`],这条约束在本 crate 里天然成立——测试只需要锁住
|
||||
//! 「definition 是纯投影」+「schema 根必须是 object」+「名字不重复」。
|
||||
//!
|
||||
//! 注册表**不持有任何端口**:工具闭包在构造时就捕获了 `Arc<dyn …Port>`,注册表只是索引。
|
||||
|
||||
pub mod schema;
|
||||
pub mod spec;
|
||||
pub mod validate;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use spec::{
|
||||
Access, Confirmation, ToolCtx, ToolDefinition, ToolError, ToolFn, ToolOutput, ToolSpec,
|
||||
};
|
||||
|
||||
/// 注册失败原因。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RegistryError {
|
||||
/// 工具名为空或重复。
|
||||
DuplicateName(String),
|
||||
/// schema 不合法(根必须是 `type: object`,否则 MCP 侧直接拒收)。
|
||||
InvalidSchema {
|
||||
/// 工具名。
|
||||
tool: String,
|
||||
/// 原因。
|
||||
reason: String,
|
||||
},
|
||||
/// 说明为空(模型靠 description 选工具,空说明等于没有这个工具)。
|
||||
EmptyDescription(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for RegistryError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::DuplicateName(name) => write!(f, "工具名重复或为空:`{name}`"),
|
||||
Self::InvalidSchema { tool, reason } => {
|
||||
write!(f, "工具 `{tool}` 的 schema 不合法:{reason}")
|
||||
}
|
||||
Self::EmptyDescription(name) => write!(f, "工具 `{name}` 缺少说明"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RegistryError {}
|
||||
|
||||
/// 工具注册表。
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ToolRegistry {
|
||||
specs: Vec<Arc<ToolSpec>>,
|
||||
index: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
/// 空注册表。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 注册一个工具。注册即校验名/schema/说明,**运行时不再做二次判断**。
|
||||
///
|
||||
/// # Errors
|
||||
/// 名字重复或为空、schema 根不是 object、说明为空时返回 [`RegistryError`]。
|
||||
pub fn register(&mut self, spec: ToolSpec) -> Result<(), RegistryError> {
|
||||
let name = spec.name.trim();
|
||||
if name.is_empty() || self.index.contains_key(name) {
|
||||
return Err(RegistryError::DuplicateName(name.to_string()));
|
||||
}
|
||||
if spec.description.trim().is_empty() {
|
||||
return Err(RegistryError::EmptyDescription(name.to_string()));
|
||||
}
|
||||
if spec.input_schema.get("type").and_then(|t| t.as_str()) != Some("object") {
|
||||
return Err(RegistryError::InvalidSchema {
|
||||
tool: name.to_string(),
|
||||
reason: "根类型必须是 object(rig 与 rmcp 的入参都是对象 schema)".to_string(),
|
||||
});
|
||||
}
|
||||
self.index.insert(name.to_string(), self.specs.len());
|
||||
self.specs.push(Arc::new(spec));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按名查找。
|
||||
#[must_use]
|
||||
pub fn get(&self, name: &str) -> Option<&Arc<ToolSpec>> {
|
||||
self.index.get(name).and_then(|i| self.specs.get(*i))
|
||||
}
|
||||
|
||||
/// 全部工具(按注册顺序,模型看到的顺序稳定)。
|
||||
#[must_use]
|
||||
pub fn specs(&self) -> &[Arc<ToolSpec>] {
|
||||
&self.specs
|
||||
}
|
||||
|
||||
/// 工具名列表(错误提示/审计用)。
|
||||
#[must_use]
|
||||
pub fn names(&self) -> Vec<&str> {
|
||||
self.specs.iter().map(|s| s.name).collect()
|
||||
}
|
||||
|
||||
/// 工具数量。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.specs.len()
|
||||
}
|
||||
|
||||
/// 是否没有工具。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.specs.is_empty()
|
||||
}
|
||||
|
||||
/// **投影 1/2 的公共载荷**:`(name, description, input_schema)`。
|
||||
#[must_use]
|
||||
pub fn definitions(&self) -> Vec<ToolDefinition> {
|
||||
self.specs.iter().map(|s| s.definition()).collect()
|
||||
}
|
||||
|
||||
/// 只读子集(P1 的 `--readonly` 外部 MCP 模式用)。
|
||||
#[must_use]
|
||||
pub fn read_only_definitions(&self) -> Vec<ToolDefinition> {
|
||||
self.specs
|
||||
.iter()
|
||||
.filter(|s| s.access == Access::ReadOnly)
|
||||
.map(|s| s.definition())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **只读模式**的注册表副本(外部 MCP 默认形态)。
|
||||
///
|
||||
/// 返回的是「物理上不含写工具」的新注册表:`tools/list` 里看不到 `set_config`,
|
||||
/// 硬调它也会在编排层得到 `unknown_tool`(并留一条审计),而不是静默执行。
|
||||
/// 这比「列表过滤 + 调用拦截」两套逻辑更不容易漏——只有一份子集,两侧天然一致。
|
||||
#[must_use]
|
||||
pub fn read_only_subset(&self) -> Self {
|
||||
let mut subset = Self::new();
|
||||
for spec in self.specs.iter().filter(|s| s.access == Access::ReadOnly) {
|
||||
let name = spec.name.to_string();
|
||||
if subset.index.insert(name, subset.specs.len()).is_none() {
|
||||
subset.specs.push(Arc::clone(spec));
|
||||
}
|
||||
}
|
||||
subset
|
||||
}
|
||||
|
||||
/// 名 → access(装配处检查白名单/只读模式)。
|
||||
#[must_use]
|
||||
pub fn access_of(&self, name: &str) -> Option<Access> {
|
||||
self.get(name).map(|s| s.access)
|
||||
}
|
||||
|
||||
/// 去重后的名字集合(契约测试用)。
|
||||
#[must_use]
|
||||
pub fn unique_names(&self) -> BTreeSet<&str> {
|
||||
self.specs.iter().map(|s| s.name).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::registry::schema::{non_empty_string, object};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn echo(name: &'static str) -> ToolSpec {
|
||||
ToolSpec::read_only(
|
||||
name,
|
||||
"回显",
|
||||
object(&[("text", non_empty_string("文本"))], &["text"]),
|
||||
Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo"))),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_lookup() {
|
||||
let mut reg = ToolRegistry::new();
|
||||
reg.register(echo("echo")).unwrap();
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert_eq!(reg.names(), vec!["echo"]);
|
||||
assert!(reg.get("echo").is_some());
|
||||
assert!(reg.get("nope").is_none());
|
||||
assert_eq!(reg.access_of("echo"), Some(Access::ReadOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_empty_and_non_object_schema() {
|
||||
let mut reg = ToolRegistry::new();
|
||||
reg.register(echo("echo")).unwrap();
|
||||
assert_eq!(
|
||||
reg.register(echo("echo")),
|
||||
Err(RegistryError::DuplicateName("echo".into()))
|
||||
);
|
||||
|
||||
let bad = ToolSpec::read_only(
|
||||
"bad",
|
||||
"x",
|
||||
json!({ "type": "array" }),
|
||||
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))),
|
||||
);
|
||||
assert!(matches!(
|
||||
reg.register(bad),
|
||||
Err(RegistryError::InvalidSchema { .. })
|
||||
));
|
||||
|
||||
let no_desc = ToolSpec::read_only(
|
||||
"nodesc",
|
||||
" ",
|
||||
json!({ "type": "object" }),
|
||||
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))),
|
||||
);
|
||||
assert!(matches!(
|
||||
reg.register(no_desc),
|
||||
Err(RegistryError::EmptyDescription(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definitions_are_stable_and_filterable() {
|
||||
let mut reg = ToolRegistry::new();
|
||||
reg.register(echo("a")).unwrap();
|
||||
reg.register(ToolSpec::mutating(
|
||||
"b",
|
||||
"写",
|
||||
json!({ "type": "object" }),
|
||||
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "b"))),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(reg.definitions().len(), 2);
|
||||
assert_eq!(reg.read_only_definitions().len(), 1);
|
||||
assert_eq!(reg.definitions()[0].name, "a");
|
||||
assert_eq!(reg.unique_names().len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! JSON Schema 构造helper。
|
||||
//!
|
||||
//! 工具参数 schema 一律**手写 JSON**(research/02 §6.2:`rig` 的 `DynamicTool::new` 与
|
||||
//! rmcp 的 `ToolRoute::new_dyn` 吃的都是裸 `serde_json::Value`),所以不需要 schemars 派生。
|
||||
//! 手写的好处是同一份 schema 直接喂两方,坏处是啰嗦——这个模块把啰嗦收敛到几个函数。
|
||||
//!
|
||||
//! 生成的 schema 一律按 **JSON Schema 2020-12** 的写法(rmcp 支持该版本;
|
||||
//! `additionalProperties: false` 让模型少给野参数,[`super::validate`] 会执行它)。
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
/// 对象 schema。`properties` 按给定顺序写入(JSON 对象保序,UI 展示稳定)。
|
||||
#[must_use]
|
||||
pub fn object(properties: &[(&str, Value)], required: &[&str]) -> Value {
|
||||
let mut props = Map::new();
|
||||
for (name, schema) in properties {
|
||||
props.insert((*name).to_string(), schema.clone());
|
||||
}
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": Value::Object(props),
|
||||
"required": required,
|
||||
"additionalProperties": false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 任意 JSON 值(不校验类型)。
|
||||
#[must_use]
|
||||
pub fn any() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// 字符串。
|
||||
#[must_use]
|
||||
pub fn string(description: &str) -> Value {
|
||||
json!({ "type": "string", "description": description })
|
||||
}
|
||||
|
||||
/// 非空字符串(`minLength: 1`,省掉一堆「路径不能为空」的运行时判断)。
|
||||
#[must_use]
|
||||
pub fn non_empty_string(description: &str) -> Value {
|
||||
json!({ "type": "string", "description": description, "minLength": 1 })
|
||||
}
|
||||
|
||||
/// 枚举字符串。
|
||||
#[must_use]
|
||||
pub fn string_enum(values: &[&str], description: &str) -> Value {
|
||||
json!({ "type": "string", "description": description, "enum": values })
|
||||
}
|
||||
|
||||
/// 布尔。
|
||||
#[must_use]
|
||||
pub fn boolean(description: &str) -> Value {
|
||||
json!({ "type": "boolean", "description": description })
|
||||
}
|
||||
|
||||
/// 带范围约束的整数。
|
||||
#[must_use]
|
||||
pub fn integer(description: &str, minimum: i64, maximum: i64) -> Value {
|
||||
json!({
|
||||
"type": "integer",
|
||||
"description": description,
|
||||
"minimum": minimum,
|
||||
"maximum": maximum,
|
||||
})
|
||||
}
|
||||
|
||||
/// 字符串数组(可选条数上限)。
|
||||
#[must_use]
|
||||
pub fn string_array(description: &str, max_items: u64) -> Value {
|
||||
json!({
|
||||
"type": "array",
|
||||
"description": description,
|
||||
"items": { "type": "string" },
|
||||
"maxItems": max_items,
|
||||
})
|
||||
}
|
||||
|
||||
/// 枚举字符串数组(如 `kinds: ["stderr","exit"]`)。
|
||||
#[must_use]
|
||||
pub fn enum_string_array(values: &[&str], description: &str, max_items: u64) -> Value {
|
||||
json!({
|
||||
"type": "array",
|
||||
"description": description,
|
||||
"items": { "type": "string", "enum": values },
|
||||
"maxItems": max_items,
|
||||
"uniqueItems": true,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::registry::validate::validate;
|
||||
|
||||
#[test]
|
||||
fn object_records_required_and_forbids_extras() {
|
||||
let schema = object(
|
||||
&[("path", non_empty_string("键路径")), ("value", any())],
|
||||
&["path", "value"],
|
||||
);
|
||||
assert_eq!(schema["type"], json!("object"));
|
||||
assert_eq!(schema["additionalProperties"], json!(false));
|
||||
assert_eq!(schema["required"], json!(["path", "value"]));
|
||||
assert!(validate(&schema, &json!({ "path": "a", "value": 1 })).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_builder_carries_bounds() {
|
||||
let schema = integer("条数", 1, 500);
|
||||
assert_eq!(schema["minimum"], json!(1));
|
||||
assert_eq!(schema["maximum"], json!(500));
|
||||
assert!(validate(&schema, &json!(0)).is_err());
|
||||
assert!(validate(&schema, &json!(500)).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! 工具的**唯一定义类型**([`ToolSpec`])与它的两份投影载荷([`ToolDefinition`])。
|
||||
//!
|
||||
//! 双投影的支点在 research/02 §6.2:`rig::tool::DynamicTool::new(name, description,
|
||||
//! parameters: serde_json::Value, ..)` 与 `rmcp::ToolRoute::new_dyn(name, description,
|
||||
//! schema, handler)` 的入参**都是裸 JSON Schema**。所以本 crate 只维护一份
|
||||
//! [`ToolSpec`],由 [`ToolSpec::definition`] 生成两侧共用的 `(name, description, schema)`。
|
||||
//!
|
||||
//! 工具实现是**同步**的:端口调用全是本地文件/内存,没有网络 IO;异步只存在于
|
||||
//! provider 边界([`crate::brain::provider::ChatProvider`])。P1 若出现网络类工具
|
||||
//! (mod 搜索/版本安装)再升成 `Fn -> BoxFuture`,不影响调用方语义。
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::confirm::Sensitivity;
|
||||
|
||||
/// 工具的副作用等级(外部 MCP 只读模式、P1 的 `--readonly` 都按它筛)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Access {
|
||||
/// 只读:可以给外部 agent、可以在无确认的情况下调用。
|
||||
ReadOnly,
|
||||
/// 会改用户数据(写配置),受白名单 + 分级确认约束。
|
||||
Mutating,
|
||||
}
|
||||
|
||||
/// 工具定义的三元组:**双投影就投这个**。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
/// 工具名(模型可见,`snake_case`)。
|
||||
pub name: String,
|
||||
/// 一句话说明(模型可见)。
|
||||
pub description: String,
|
||||
/// 参数 JSON Schema(2020-12,根类型必须 `object`)。
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
/// 工具调用上下文:编排层注入,工具只读。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolCtx {
|
||||
/// agent 对话会话 id(审计用;与游戏 session_id 不是一回事)。
|
||||
pub agent_session_id: String,
|
||||
/// 是否已获确认。**只有编排层能置 true**(YOLO 或用户点了允许),
|
||||
/// 工具据此决定「先报价再执行」还是「直接执行」。
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
/// 工具的返回。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolOutput {
|
||||
/// 给模型/审计的结构化结果(编排层序列化成 JSON 字符串回灌)。
|
||||
pub content: Value,
|
||||
/// 一句话摘要(UI 展示 + 审计 summary)。
|
||||
pub summary: String,
|
||||
/// 非空表示「这次没执行,先要确认」([`crate::confirm::ConfirmRequest`] 的原料)。
|
||||
pub confirmation: Option<Confirmation>,
|
||||
}
|
||||
|
||||
/// 待确认的操作详情(dry-run 的产物)。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Confirmation {
|
||||
/// 一句话请求(「AI 请求把 X 从 A 改为 B」)。
|
||||
pub summary: String,
|
||||
/// 结构化 diff(`{"path":…,"old":…,"new":…}`)。
|
||||
pub details: Value,
|
||||
/// 敏感等级(决定 YOLO 能不能跳过)。
|
||||
pub sensitivity: Sensitivity,
|
||||
}
|
||||
|
||||
impl ToolOutput {
|
||||
/// 成功返回。
|
||||
#[must_use]
|
||||
pub fn ok(content: Value, summary: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content,
|
||||
summary: summary.into(),
|
||||
confirmation: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要确认(本次**没有**产生副作用)。
|
||||
#[must_use]
|
||||
pub fn needs_confirmation(
|
||||
summary: impl Into<String>,
|
||||
details: Value,
|
||||
sensitivity: Sensitivity,
|
||||
) -> Self {
|
||||
let summary = summary.into();
|
||||
Self {
|
||||
content: serde_json::json!({
|
||||
"status": "needs_confirmation",
|
||||
"summary": summary,
|
||||
}),
|
||||
confirmation: Some(Confirmation {
|
||||
summary: summary.clone(),
|
||||
details,
|
||||
sensitivity,
|
||||
}),
|
||||
summary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具错误。**全部面向用户可读**——它们会作为工具结果回到模型,
|
||||
/// 由模型决定重试还是换方案(rmcp 的 `Ok(CallToolResult::error(..))` 同义,
|
||||
/// 见 research/02 §3.2:协议级 `Err` 会被客户端吞掉,不能用来报业务错误)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ToolError {
|
||||
/// 参数语义不合法(schema 校验之外的规则,如路径不在白名单)。
|
||||
NotAllowed(String),
|
||||
/// 找不到对象(会话/键/文件)。
|
||||
NotFound(String),
|
||||
/// 端口(M12/M15/M18)报错,文本原样透传。
|
||||
Port(String),
|
||||
/// 工具自身内部错误(不该发生)。
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl ToolError {
|
||||
/// 一句话文案(写日志/审计用)。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::NotAllowed(m) | Self::NotFound(m) | Self::Port(m) | Self::Internal(m) => m,
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误码(审计与 UI 分类用)。
|
||||
#[must_use]
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NotAllowed(_) => "not_allowed",
|
||||
Self::NotFound(_) => "not_found",
|
||||
Self::Port(_) => "port_error",
|
||||
Self::Internal(_) => "internal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ToolError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ToolError {}
|
||||
|
||||
/// 工具处理函数。
|
||||
pub type ToolFn = Arc<dyn Fn(&ToolCtx, &Value) -> Result<ToolOutput, ToolError> + Send + Sync>;
|
||||
|
||||
/// 一个工具的完整定义 + 实现。
|
||||
#[derive(Clone)]
|
||||
pub struct ToolSpec {
|
||||
/// 工具名(`snake_case`,同时是 MCP 侧的工具名)。
|
||||
pub name: &'static str,
|
||||
/// 说明。
|
||||
pub description: &'static str,
|
||||
/// 参数 schema(根必须 object)。
|
||||
pub input_schema: Value,
|
||||
/// 副作用等级。
|
||||
pub access: Access,
|
||||
/// 实现。
|
||||
pub handler: ToolFn,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ToolSpec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ToolSpec")
|
||||
.field("name", &self.name)
|
||||
.field("access", &self.access)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolSpec {
|
||||
/// 建一个只读工具。
|
||||
#[must_use]
|
||||
pub fn read_only(
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
input_schema: Value,
|
||||
handler: ToolFn,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
access: Access::ReadOnly,
|
||||
handler,
|
||||
}
|
||||
}
|
||||
|
||||
/// 建一个会改数据的工具。
|
||||
#[must_use]
|
||||
pub fn mutating(
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
input_schema: Value,
|
||||
handler: ToolFn,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
access: Access::Mutating,
|
||||
handler,
|
||||
}
|
||||
}
|
||||
|
||||
/// 投影成两侧共用的 `(name, description, schema)`。
|
||||
#[must_use]
|
||||
pub fn definition(&self) -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
name: self.name.to_string(),
|
||||
description: self.description.to_string(),
|
||||
input_schema: self.input_schema.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::registry::schema::{non_empty_string, object};
|
||||
use serde_json::json;
|
||||
|
||||
fn echo_handler() -> ToolFn {
|
||||
Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_is_a_pure_projection() {
|
||||
let spec = ToolSpec::read_only(
|
||||
"echo",
|
||||
"回显",
|
||||
object(&[("text", non_empty_string("文本"))], &["text"]),
|
||||
echo_handler(),
|
||||
);
|
||||
let def = spec.definition();
|
||||
assert_eq!(def.name, "echo");
|
||||
assert_eq!(def.description, "回显");
|
||||
assert_eq!(def.input_schema, spec.input_schema);
|
||||
assert_eq!(spec.access, Access::ReadOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_carries_details() {
|
||||
let out = ToolOutput::needs_confirmation(
|
||||
"AI 请求修改 x",
|
||||
json!({ "path": "x" }),
|
||||
Sensitivity::High,
|
||||
);
|
||||
assert_eq!(out.content["status"], json!("needs_confirmation"));
|
||||
let confirmation = out.confirmation.expect("必须有待确认详情");
|
||||
assert_eq!(confirmation.sensitivity, Sensitivity::High);
|
||||
assert_eq!(confirmation.summary, out.summary);
|
||||
assert_eq!(confirmation.details, json!({ "path": "x" }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! 参数校验:JSON Schema 2020-12 的**最小子集**实现。
|
||||
//!
|
||||
//! 为什么不引 `jsonschema` crate:agent 的 schema 全是自己手写的([`super::schema`]),
|
||||
//! 用到的关键字只有 `type/required/properties/additionalProperties/enum/minimum/maximum/
|
||||
//! minLength/minItems/maxItems/uniqueItems/items`。三百行的校验器换掉一棵依赖树,
|
||||
//! 对 Win7 单文件形态(D12)值得。
|
||||
//!
|
||||
//! 校验失败的文案是给**模型**看的(它会据此改正参数重试),所以要具体:
|
||||
//! 「参数 `limit` 必须是 1~500 的整数,实际是 2000」,不是 `schema validation failed`。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// 校验入口。返回全部错误(一次给全,省模型来回试错)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 参数不符合 schema 时返回人类可读的错误列表。
|
||||
pub fn validate(schema: &Value, args: &Value) -> Result<(), Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
validate_at(schema, args, "", &mut errors);
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/// 只取第一条错误(日志/审计摘要用)。
|
||||
#[must_use]
|
||||
pub fn first_error(schema: &Value, args: &Value) -> Option<String> {
|
||||
validate(schema, args)
|
||||
.err()
|
||||
.and_then(|errors| errors.into_iter().next())
|
||||
}
|
||||
|
||||
fn validate_at(schema: &Value, value: &Value, path: &str, errors: &mut Vec<String>) {
|
||||
let Some(schema) = schema.as_object() else {
|
||||
return; // `{}` = 任意值
|
||||
};
|
||||
|
||||
if let Some(expected) = schema.get("type").and_then(Value::as_str) {
|
||||
if !type_matches(expected, value) {
|
||||
errors.push(format!(
|
||||
"参数 {} 应为 {},实际是 {}",
|
||||
label(path),
|
||||
expected,
|
||||
type_name(value)
|
||||
));
|
||||
return; // 类型都不对,后面的范围检查没有意义
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(allowed) = schema.get("enum").and_then(Value::as_array) {
|
||||
if !allowed.iter().any(|v| v == value) {
|
||||
let list: Vec<String> = allowed.iter().map(render).collect();
|
||||
errors.push(format!(
|
||||
"参数 {} 只能取 [{}],实际是 {}",
|
||||
label(path),
|
||||
list.join(", "),
|
||||
render(value)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
check_bounds(schema, value, path, errors);
|
||||
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let required = schema.get("required").and_then(Value::as_array);
|
||||
for name in required.into_iter().flatten().filter_map(Value::as_str) {
|
||||
if !map.contains_key(name) {
|
||||
errors.push(format!("缺少必填参数 {}", label(&join(path, name))));
|
||||
}
|
||||
}
|
||||
let properties = schema.get("properties").and_then(Value::as_object);
|
||||
for (key, entry) in map {
|
||||
match properties.and_then(|p| p.get(key)) {
|
||||
Some(sub) => validate_at(sub, entry, &join(path, key), errors),
|
||||
None => {
|
||||
if schema.get("additionalProperties").and_then(Value::as_bool)
|
||||
== Some(false)
|
||||
{
|
||||
let known: Vec<&str> = properties
|
||||
.map(|p| p.keys().map(String::as_str).collect())
|
||||
.unwrap_or_default();
|
||||
errors.push(format!(
|
||||
"参数 {} 不是可接受的参数名(可用:{})",
|
||||
label(&join(path, key)),
|
||||
if known.is_empty() {
|
||||
"无".to_string()
|
||||
} else {
|
||||
known.join(", ")
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
if let Some(sub) = schema.get("items") {
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
validate_at(sub, item, &format!("{path}[{i}]"), errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_bounds(
|
||||
schema: &serde_json::Map<String, Value>,
|
||||
value: &Value,
|
||||
path: &str,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
let number = value.as_f64();
|
||||
let minimum = schema.get("minimum").and_then(Value::as_f64);
|
||||
let maximum = schema.get("maximum").and_then(Value::as_f64);
|
||||
if let (Some(n), Some(min), Some(max)) = (number, minimum, maximum) {
|
||||
if n < min || n > max {
|
||||
errors.push(format!(
|
||||
"参数 {} 必须在 {}~{} 之间,实际是 {}",
|
||||
label(path),
|
||||
render_number(min),
|
||||
render_number(max),
|
||||
render_number(n)
|
||||
));
|
||||
return;
|
||||
}
|
||||
} else if let (Some(n), Some(min)) = (number, minimum) {
|
||||
if n < min {
|
||||
errors.push(format!(
|
||||
"参数 {} 不能小于 {},实际是 {}",
|
||||
label(path),
|
||||
render_number(min),
|
||||
render_number(n)
|
||||
));
|
||||
}
|
||||
} else if let (Some(n), Some(max)) = (number, maximum) {
|
||||
if n > max {
|
||||
errors.push(format!(
|
||||
"参数 {} 不能大于 {},实际是 {}",
|
||||
label(path),
|
||||
render_number(max),
|
||||
render_number(n)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(text) = value.as_str() {
|
||||
if let Some(min_len) = schema.get("minLength").and_then(Value::as_u64) {
|
||||
if (text.chars().count() as u64) < min_len {
|
||||
errors.push(format!(
|
||||
"参数 {} 不能为空(最少 {} 个字符)",
|
||||
label(path),
|
||||
min_len
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(items) = value.as_array() {
|
||||
if let Some(max_items) = schema.get("maxItems").and_then(Value::as_u64) {
|
||||
if items.len() as u64 > max_items {
|
||||
errors.push(format!(
|
||||
"参数 {} 最多 {} 项,实际 {} 项",
|
||||
label(path),
|
||||
max_items,
|
||||
items.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64) {
|
||||
if (items.len() as u64) < min_items {
|
||||
errors.push(format!(
|
||||
"参数 {} 至少 {} 项,实际 {} 项",
|
||||
label(path),
|
||||
min_items,
|
||||
items.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
if schema.get("uniqueItems").and_then(Value::as_bool) == Some(true) {
|
||||
if let Some(duplicate) = first_duplicate(items) {
|
||||
errors.push(format!(
|
||||
"参数 {} 不能有重复项({} 出现了多次)",
|
||||
label(path),
|
||||
render(duplicate)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 数组里第一个重复出现的元素(`uniqueItems` 用;`null` 也算一个值)。
|
||||
fn first_duplicate(items: &[Value]) -> Option<&Value> {
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(index, item)| items.iter().take(*index).any(|prev| prev == *item))
|
||||
.map(|(_, item)| item)
|
||||
}
|
||||
|
||||
fn type_matches(expected: &str, value: &Value) -> bool {
|
||||
match expected {
|
||||
"object" => value.is_object(),
|
||||
"array" => value.is_array(),
|
||||
"string" => value.is_string(),
|
||||
"boolean" => value.is_boolean(),
|
||||
"null" => value.is_null(),
|
||||
"integer" => value.as_f64().is_some_and(|n| n.fract() == 0.0),
|
||||
"number" => value.is_number(),
|
||||
_ => true, // 未知关键字不拦(前向兼容)
|
||||
}
|
||||
}
|
||||
|
||||
fn type_name(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(n) if n.as_f64().is_some_and(|f| f.fract() == 0.0) => "integer",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(path: &str) -> String {
|
||||
if path.is_empty() {
|
||||
"(根)".to_string()
|
||||
} else {
|
||||
format!("`{path}`")
|
||||
}
|
||||
}
|
||||
|
||||
fn join(path: &str, key: &str) -> String {
|
||||
if path.is_empty() {
|
||||
key.to_string()
|
||||
} else {
|
||||
format!("{path}.{key}")
|
||||
}
|
||||
}
|
||||
|
||||
fn render(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => format!("\"{s}\""),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::registry::schema::{
|
||||
enum_string_array, integer, non_empty_string, object, string_array, string_enum,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn errors(schema: &Value, args: &Value) -> Vec<String> {
|
||||
validate(schema, args).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_and_reports_missing_required() {
|
||||
let schema = object(
|
||||
&[("session_id", non_empty_string("会话 id"))],
|
||||
&["session_id"],
|
||||
);
|
||||
assert!(validate(&schema, &json!({ "session_id": "20261029T124501-9f3c2a" })).is_ok());
|
||||
let errs = errors(&schema, &json!({}));
|
||||
assert_eq!(errs.len(), 1);
|
||||
assert!(errs[0].contains("缺少必填参数 `session_id`"), "{errs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_string_and_wrong_type() {
|
||||
let schema = object(
|
||||
&[("session_id", non_empty_string("会话 id"))],
|
||||
&["session_id"],
|
||||
);
|
||||
assert!(errors(&schema, &json!({ "session_id": "" }))[0].contains("不能为空"));
|
||||
assert!(errors(&schema, &json!({ "session_id": 1 }))[0].contains("应为 string"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_and_unknown_keys() {
|
||||
let schema = object(
|
||||
&[
|
||||
("limit", integer("条数", 1, 500)),
|
||||
("kinds", string_array("类型", 8)),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
let errs = errors(&schema, &json!({ "limit": 2000 }));
|
||||
assert!(errs[0].contains("1~500"), "{errs:?}");
|
||||
let errs = errors(&schema, &json!({ "limit": 1, "evil": true }));
|
||||
assert!(errs[0].contains("不是可接受的参数名"), "{errs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_enum_and_nested_objects() {
|
||||
let schema = object(&[("level", string_enum(&["error", "warn"], "级别"))], &[]);
|
||||
assert!(validate(&schema, &json!({ "level": "error" })).is_ok());
|
||||
assert!(errors(&schema, &json!({ "level": "fatal" }))[0].contains("只能取"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_items_when_unique() {
|
||||
let schema = object(
|
||||
&[("kinds", enum_string_array(&["stderr", "exit"], "类型", 4))],
|
||||
&[],
|
||||
);
|
||||
let errs = errors(&schema, &json!({ "kinds": ["stderr", "exit", "stderr"] }));
|
||||
assert!(errs[0].contains("不能有重复项"), "{errs:?}");
|
||||
assert!(validate(&schema, &json!({ "kinds": ["stderr", "exit"] })).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_type_rejects_fraction() {
|
||||
assert!(validate(&json!({ "type": "integer" }), &json!(1.5)).is_err());
|
||||
assert!(validate(&json!({ "type": "integer" }), &json!(2.0)).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! 测试替身:五个端口的 **mock 实现**(无盘、无网络、无时间依赖)。
|
||||
//!
|
||||
//! 放在库内(而不是只在 `#[cfg(test)]`)是刻意的:`crates/app` 装配时也要能给
|
||||
//! 自己的单测造一套「无盘」环境,且 P1/M39 的 MCP 契约测试要复用同一批假数据。
|
||||
//!
|
||||
//! 按端口拆文件(`port.rs` 挨 400 行上限的同款理由):`settings` 配置、`sessions`
|
||||
//! 会话日志在下面,`game` 实例/Java/账户与 `diagnose` 诊断各一个子模块。
|
||||
//! 对外名字不变,`unlucky_agent::testing::MockSettings` 之类照旧可用(走 re-export)。
|
||||
|
||||
pub mod diagnose;
|
||||
pub mod game;
|
||||
pub mod settings;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::port::{GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult};
|
||||
|
||||
pub use diagnose::MockDiagnose;
|
||||
pub use game::{MockAccounts, MockGame};
|
||||
pub use settings::MockSettings;
|
||||
|
||||
/// 内存会话端口。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MockSessions {
|
||||
sessions: Vec<GameSessionSummary>,
|
||||
events: BTreeMap<String, Vec<LogEvent>>,
|
||||
}
|
||||
|
||||
impl MockSessions {
|
||||
/// 空数据。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 加一个会话及其事件流。
|
||||
pub fn add(&mut self, summary: GameSessionSummary, events: Vec<LogEvent>) {
|
||||
self.events.insert(summary.session_id.clone(), events);
|
||||
self.sessions.push(summary);
|
||||
}
|
||||
|
||||
/// 便捷:造一个崩溃会话(`exit_code=1` + stderr + crash_found)。
|
||||
#[must_use]
|
||||
pub fn crashing_session(session_id: &str) -> (GameSessionSummary, Vec<LogEvent>) {
|
||||
let summary = GameSessionSummary {
|
||||
session_id: session_id.to_string(),
|
||||
instance_id: Some("inst-1".to_string()),
|
||||
instance_name: Some("1.20.1 Forge".to_string()),
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
started_at: Some("2026-10-29T12:45:01.123Z".to_string()),
|
||||
ended_at: Some("2026-10-29T12:46:31.123Z".to_string()),
|
||||
duration_ms: 90_000,
|
||||
exit_code: Some(1),
|
||||
exit_type: Some("application_error".to_string()),
|
||||
silent_failure: false,
|
||||
window_detected: true,
|
||||
event_count: 5,
|
||||
crash_found: true,
|
||||
diagnosis_top: Some("forge_missing_library".to_string()),
|
||||
};
|
||||
let events = vec![
|
||||
event(
|
||||
0,
|
||||
"stdout",
|
||||
json!({ "line": "Starting Minecraft 1.20.1", "level": "info" }),
|
||||
),
|
||||
event(
|
||||
1,
|
||||
"stderr",
|
||||
json!({ "line": "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher", "level": "error" }),
|
||||
),
|
||||
event(
|
||||
2,
|
||||
"crash_found",
|
||||
json!({ "source": "crash_report", "path": r"C:\Users\aska\.minecraft\crash-reports\crash-1.txt", "excerpt": "Description: Mod loading failure" }),
|
||||
),
|
||||
event(
|
||||
3,
|
||||
"exit",
|
||||
json!({ "code": 1, "exit_type": "application_error", "silent_failure": false }),
|
||||
),
|
||||
event(
|
||||
4,
|
||||
"session_end",
|
||||
json!({ "reason": "exit", "duration_ms": 90000 }),
|
||||
),
|
||||
];
|
||||
(summary, events)
|
||||
}
|
||||
}
|
||||
|
||||
fn event(seq: u64, kind: &str, payload: Value) -> LogEvent {
|
||||
LogEvent {
|
||||
seq,
|
||||
ts: format!("2026-10-29T12:45:0{seq}.000Z"),
|
||||
t_ms: seq * 1_000,
|
||||
kind: kind.to_string(),
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
impl GameSessionPort for MockSessions {
|
||||
fn list_sessions(
|
||||
&self,
|
||||
limit: usize,
|
||||
instance_id: Option<&str>,
|
||||
) -> PortResult<Vec<GameSessionSummary>> {
|
||||
let mut out: Vec<GameSessionSummary> = self
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|s| instance_id.is_none_or(|id| s.instance_id.as_deref() == Some(id)))
|
||||
.cloned()
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
out.truncate(limit);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage> {
|
||||
let events = self
|
||||
.events
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("会话 `{session_id}` 不存在"))?;
|
||||
let mut kind_counts = BTreeMap::new();
|
||||
for event in events {
|
||||
*kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1;
|
||||
}
|
||||
let matched: Vec<&LogEvent> = events
|
||||
.iter()
|
||||
.filter(|e| query.from_seq.is_none_or(|from| e.seq >= from))
|
||||
.filter(|e| query.to_seq.is_none_or(|to| e.seq <= to))
|
||||
.filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind))
|
||||
.collect();
|
||||
let max = if query.max_events == 0 {
|
||||
matched.len()
|
||||
} else {
|
||||
query.max_events
|
||||
};
|
||||
let truncated = matched.len() > max;
|
||||
Ok(LogPage {
|
||||
session_id: session_id.to_string(),
|
||||
total_events: events.len() as u64,
|
||||
matched_events: matched.len() as u64,
|
||||
truncated,
|
||||
kind_counts,
|
||||
first_ts: events.first().map(|e| e.ts.clone()),
|
||||
last_ts: events.last().map(|e| e.ts.clone()),
|
||||
events: matched.into_iter().take(max).cloned().collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次性造好全部端口的 [`crate::port::Ports`](含一个崩溃会话,够跑通对话与日志工具)。
|
||||
#[must_use]
|
||||
pub fn mock_ports() -> (crate::port::Ports, std::sync::Arc<MockSettings>) {
|
||||
use std::sync::Arc;
|
||||
let settings = Arc::new(MockSettings::new());
|
||||
let (summary, events) = MockSessions::crashing_session("20261029T124501-9f3c2a");
|
||||
let mut sessions = MockSessions::new();
|
||||
sessions.add(summary, events);
|
||||
let ports = crate::port::Ports {
|
||||
setting: settings.clone(),
|
||||
sessions: Arc::new(sessions),
|
||||
diagnose: Arc::new(MockDiagnose::new()),
|
||||
game: Arc::new(MockGame::new()),
|
||||
accounts: Arc::new(MockAccounts::new()),
|
||||
};
|
||||
(ports, settings)
|
||||
}
|
||||
|
||||
/// 测试里常用的「全部工具名」集合(契约测试与 MCP 投影比对用)。
|
||||
#[must_use]
|
||||
pub fn expected_v1_tool_names() -> BTreeSet<&'static str> {
|
||||
[
|
||||
"get_config",
|
||||
"get_config_schema",
|
||||
"set_config",
|
||||
"list_game_sessions",
|
||||
"read_game_log",
|
||||
"diagnose_crash",
|
||||
"list_instances",
|
||||
"list_versions",
|
||||
"get_java_status",
|
||||
"list_accounts",
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 内存诊断端口(`DiagnosePort` 的测试替身)。
|
||||
|
||||
use crate::port::{
|
||||
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
|
||||
PortResult,
|
||||
};
|
||||
|
||||
/// 内存诊断端口(固定返回一份规则结论)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockDiagnose {
|
||||
report: Option<CrashDiagnosis>,
|
||||
}
|
||||
|
||||
impl Default for MockDiagnose {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
report: Some(Self::forge_missing_library()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MockDiagnose {
|
||||
/// 构造使用默认结论的端口。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 让端口对所有会话返回「会话不存在」。
|
||||
#[must_use]
|
||||
pub fn unavailable() -> Self {
|
||||
Self { report: None }
|
||||
}
|
||||
|
||||
/// 默认结论:Forge 缺库(对应 analyse/02 的高发场景)。
|
||||
#[must_use]
|
||||
pub fn forge_missing_library() -> CrashDiagnosis {
|
||||
CrashDiagnosis {
|
||||
session_id: String::new(),
|
||||
source: "rules".to_string(),
|
||||
needs_more_info: false,
|
||||
candidates: vec![DiagnosisCandidate {
|
||||
id: "integrity.forge_missing_library".to_string(),
|
||||
severity: "error".to_string(),
|
||||
category: "integrity".to_string(),
|
||||
confidence: 0.86,
|
||||
message_key: "diagnose.cause.integrity.forge_missing_library".to_string(),
|
||||
message: "Forge 启动缺少 bootstraplauncher 库,通常是版本组件未下载完整"
|
||||
.to_string(),
|
||||
detail: "在实例上重装 Forge 组件(或先用官方启动器补全一次再回来)".to_string(),
|
||||
evidence: vec![
|
||||
"java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher"
|
||||
.to_string(),
|
||||
],
|
||||
fix_actions: vec![FixAction {
|
||||
kind: FixActionKind::Reinstall,
|
||||
tool: "component.reinstall".to_string(),
|
||||
label: "重装该版本的 Forge 组件".to_string(),
|
||||
args: [("component".to_string(), "forge".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}],
|
||||
}],
|
||||
secondary: Vec::new(),
|
||||
evidence: vec!["Description: Mod loading failure".to_string()],
|
||||
exit: ExitSummary {
|
||||
code: Some(1),
|
||||
exit_type: Some("application_error".to_string()),
|
||||
silent_failure: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagnosePort for MockDiagnose {
|
||||
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
|
||||
self.report.clone().map_or_else(
|
||||
|| Err(format!("会话 `{session_id}` 不存在")),
|
||||
|report| {
|
||||
Ok(CrashDiagnosis {
|
||||
session_id: session_id.to_string(),
|
||||
..report
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 实例 / 版本 / Java / 账户四个只读端口的测试替身。
|
||||
|
||||
use crate::port::game::{
|
||||
AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus,
|
||||
VersionSummary,
|
||||
};
|
||||
use crate::port::PortResult;
|
||||
|
||||
/// 内存游戏端口:一个 1.20.1 Forge 实例 + 两个已安装版本 + 两个 Java 候选。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockGame {
|
||||
instances: Vec<InstanceSummary>,
|
||||
versions: Vec<VersionSummary>,
|
||||
java: JavaStatus,
|
||||
fail: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MockGame {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MockGame {
|
||||
/// 默认假数据(够跑通 `list_instances` / `list_versions` / `get_java_status`)。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
instances: vec![InstanceSummary {
|
||||
id: "inst-1".to_string(),
|
||||
name: "1.20.1 Forge".to_string(),
|
||||
version_id: "1.20.1-forge-47.2.20".to_string(),
|
||||
version_present: true,
|
||||
game_dir: None,
|
||||
java_path: Some(r"D:\jdk17\bin\java.exe".to_string()),
|
||||
last_played_at: Some(1_793_304_753_123),
|
||||
}],
|
||||
versions: vec![
|
||||
VersionSummary {
|
||||
id: "1.20.1-forge-47.2.20".to_string(),
|
||||
kind: Some("release".to_string()),
|
||||
instance_count: 1,
|
||||
},
|
||||
VersionSummary {
|
||||
id: "1.21.1".to_string(),
|
||||
kind: Some("release".to_string()),
|
||||
instance_count: 0,
|
||||
},
|
||||
],
|
||||
java: JavaStatus {
|
||||
default_memory_mb: Some(2048),
|
||||
managed_roots: vec![r"D:\ULL\.ULL\data\java-managed".to_string()],
|
||||
candidates: vec![
|
||||
JavaRuntimeSummary {
|
||||
binary: r"D:\ULL\.ULL\data\java-managed\jdk-17-temurin\bin\java.exe"
|
||||
.to_string(),
|
||||
version: Some("17.0.13".to_string()),
|
||||
major: Some(17),
|
||||
vendor: Some("temurin".to_string()),
|
||||
origin: "managed".to_string(),
|
||||
},
|
||||
JavaRuntimeSummary {
|
||||
binary: "/usr/bin/java".to_string(),
|
||||
version: Some("21.0.2".to_string()),
|
||||
major: Some(21),
|
||||
vendor: None,
|
||||
origin: "PATH".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
fail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 让三个查询全部返回同一个错误(测端口错误透传)。
|
||||
#[must_use]
|
||||
pub fn failing(message: &str) -> Self {
|
||||
Self {
|
||||
fail: Some(message.to_string()),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&self) -> Option<String> {
|
||||
self.fail.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl GamePort for MockGame {
|
||||
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>> {
|
||||
self.fail().map_or_else(|| Ok(self.instances.clone()), Err)
|
||||
}
|
||||
|
||||
fn list_versions(&self) -> PortResult<Vec<VersionSummary>> {
|
||||
self.fail().map_or_else(|| Ok(self.versions.clone()), Err)
|
||||
}
|
||||
|
||||
fn java_status(&self) -> PortResult<JavaStatus> {
|
||||
self.fail().map_or_else(|| Ok(self.java.clone()), Err)
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存账户端口:一个离线账户。**只有公开字段**,凭据无处可放。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockAccounts {
|
||||
accounts: Vec<AccountSummary>,
|
||||
fail: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MockAccounts {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MockAccounts {
|
||||
/// 一个离线账户(Steve)。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
accounts: vec![AccountSummary {
|
||||
id: "acc-1".to_string(),
|
||||
kind: "offline".to_string(),
|
||||
profile_id: "8667ba71b85a4004af54457a9734eed7".to_string(),
|
||||
profile_name: "Steve".to_string(),
|
||||
login_name: None,
|
||||
created_at_ms: 1_793_304_753_123,
|
||||
}],
|
||||
fail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 空账户列表(测「没有账户」的提示文案)。
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
accounts: Vec::new(),
|
||||
fail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 让查询失败(测端口错误透传)。
|
||||
#[must_use]
|
||||
pub fn failing(message: &str) -> Self {
|
||||
Self {
|
||||
fail: Some(message.to_string()),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountPort for MockAccounts {
|
||||
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>> {
|
||||
self.fail
|
||||
.clone()
|
||||
.map_or_else(|| Ok(self.accounts.clone()), Err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 内存配置端口(`SettingPort` 的测试替身)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::confirm::Sensitivity;
|
||||
use crate::port::{PortResult, SettingChange, SettingKey, SettingPort};
|
||||
|
||||
/// 内存配置端口。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MockSettings {
|
||||
keys: Vec<SettingKey>,
|
||||
values: Mutex<BTreeMap<String, Value>>,
|
||||
fail_set: Mutex<Option<String>>,
|
||||
set_calls: Mutex<usize>,
|
||||
}
|
||||
|
||||
/// 与 `PlanDoc/04` §3 / M12 `setting-接口约定.md` §8 的键表一致的默认白名单
|
||||
/// (覆盖三档敏感度;`ai.api_key` 这类凭据是 `writable=false`,**故意不在这里**)。
|
||||
fn default_keys() -> Vec<SettingKey> {
|
||||
fn key(
|
||||
path: &str,
|
||||
sensitivity: Sensitivity,
|
||||
value_type: &str,
|
||||
description: &str,
|
||||
) -> SettingKey {
|
||||
SettingKey {
|
||||
path: path.to_string(),
|
||||
sensitivity,
|
||||
value_type: value_type.to_string(),
|
||||
description: description.to_string(),
|
||||
}
|
||||
}
|
||||
vec![
|
||||
key(
|
||||
"appearance.theme_mode",
|
||||
Sensitivity::Low,
|
||||
"enum",
|
||||
"界面主题",
|
||||
),
|
||||
key(
|
||||
"download.concurrency",
|
||||
Sensitivity::Low,
|
||||
"integer",
|
||||
"下载并发(1~256)",
|
||||
),
|
||||
key(
|
||||
"java.default_memory_mb",
|
||||
Sensitivity::Low,
|
||||
"integer",
|
||||
"最大内存(512~65536 MB)",
|
||||
),
|
||||
key("download.region", Sensitivity::Medium, "enum", "下载源区域"),
|
||||
key("ai.model", Sensitivity::Medium, "text", "AI 模型名"),
|
||||
key(
|
||||
"java.selected_path",
|
||||
Sensitivity::High,
|
||||
"path",
|
||||
"Java 可执行文件路径",
|
||||
),
|
||||
key("download.proxy_url", Sensitivity::High, "text", "HTTP 代理"),
|
||||
key("ai.base_url", Sensitivity::High, "text", "AI 端点"),
|
||||
key(
|
||||
"core.data_mode",
|
||||
Sensitivity::High,
|
||||
"enum",
|
||||
"数据目录模式(portable/standard/custom)",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
impl MockSettings {
|
||||
/// 用默认白名单 + 一批初值造一个。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
let mut values = BTreeMap::new();
|
||||
values.insert("appearance.theme_mode".to_string(), json!("metro_dark"));
|
||||
values.insert("download.concurrency".to_string(), json!(8));
|
||||
values.insert("java.default_memory_mb".to_string(), json!(2048));
|
||||
Self {
|
||||
keys: default_keys(),
|
||||
values: Mutex::new(values),
|
||||
fail_set: Mutex::new(None),
|
||||
set_calls: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 让接下来的 `set` 全部失败(测错误透传)。
|
||||
pub fn fail_set_with(&self, message: &str) {
|
||||
if let Ok(mut slot) = self.fail_set.lock() {
|
||||
*slot = Some(message.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前值(断言「未确认时没有写进去」)。
|
||||
#[must_use]
|
||||
pub fn value_of(&self, path: &str) -> Option<Value> {
|
||||
self.values.lock().ok().and_then(|v| v.get(path).cloned())
|
||||
}
|
||||
|
||||
/// 直接塞一个值,模拟 M12 真实实现里存在的**任意键**(含凭据键)。
|
||||
///
|
||||
/// 白名单以外的键也能塞——测「`get_config` 不把凭据读给模型」需要它
|
||||
/// (`ai.api_key` 是 `writable=false`,故意不在白名单里)。
|
||||
pub fn seed_value(&self, path: &str, value: Value) {
|
||||
if let Ok(mut values) = self.values.lock() {
|
||||
values.insert(path.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功写入次数。
|
||||
#[must_use]
|
||||
pub fn set_calls(&self) -> usize {
|
||||
self.set_calls.lock().map(|n| *n).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingPort for MockSettings {
|
||||
fn summary(&self) -> Value {
|
||||
let values = self.values.lock().map(|v| v.clone()).unwrap_or_default();
|
||||
let mut map = serde_json::Map::new();
|
||||
for key in &self.keys {
|
||||
let value = values.get(&key.path).cloned().unwrap_or(Value::Null);
|
||||
map.insert(key.path.clone(), value);
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn get(&self, path: &str) -> PortResult<Value> {
|
||||
self.values
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|v| v.get(path).cloned())
|
||||
.ok_or_else(|| format!("配置键 `{path}` 没有值"))
|
||||
}
|
||||
|
||||
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange> {
|
||||
if let Ok(slot) = self.fail_set.lock() {
|
||||
if let Some(message) = slot.as_ref() {
|
||||
return Err(message.clone());
|
||||
}
|
||||
}
|
||||
let old = self.get(path).ok();
|
||||
if let Ok(mut values) = self.values.lock() {
|
||||
values.insert(path.to_string(), value.clone());
|
||||
}
|
||||
if let Ok(mut calls) = self.set_calls.lock() {
|
||||
*calls += 1;
|
||||
}
|
||||
Ok(SettingChange {
|
||||
path: path.to_string(),
|
||||
old,
|
||||
new: value.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn writable_keys(&self) -> Vec<SettingKey> {
|
||||
self.keys.clone()
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
// 与 `core::setting::catalog::SettingSchema::to_json_schema` 同形:
|
||||
// 只含白名单键 + `x-ai-sensitivity`,凭据键天然缺席。
|
||||
let mut properties = Map::new();
|
||||
for key in &self.keys {
|
||||
properties.insert(
|
||||
key.path.clone(),
|
||||
json!({
|
||||
"type": key.value_type,
|
||||
"description": key.description,
|
||||
"x-ai-sensitivity": key.sensitivity,
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "ull.launcher",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": properties,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schema_excludes_credential_keys() {
|
||||
let schema = MockSettings::new().schema();
|
||||
let json = schema.to_string();
|
||||
assert!(
|
||||
!json.contains("ai.api_key"),
|
||||
"schema 不得包含凭据键:{json}"
|
||||
);
|
||||
assert!(json.contains("download.concurrency"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! `list_accounts`:账户清单(**凭据永不出口**)。
|
||||
//!
|
||||
//! 这个工具存在的理由很具体:用户常问「我有哪几个号 / 这局用的是哪个号」。
|
||||
//! 但 D4/D9 的凭据红线是硬的——工具结果会回灌模型、也可能被发往远端 LLM,
|
||||
//! 所以:
|
||||
//! 1. 端口只回 [`crate::port::AccountSummary`](`AccountMeta` 的公开字段);
|
||||
//! 2. 本文件里**没有任何**能表达 token / 密码的类型(连字段名都不出现);
|
||||
//! 3. 工具结果再过一遍 [`crate::redact`],兜住实现方把凭据塞进 `profile_name` 的意外。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::port::AccountPort;
|
||||
use crate::registry::schema::object;
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 工具名。
|
||||
pub const LIST_ACCOUNTS: &str = "list_accounts";
|
||||
|
||||
/// 注册 `list_accounts`。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(
|
||||
registry: &mut ToolRegistry,
|
||||
port: Arc<dyn AccountPort>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
|
||||
let accounts = port.list_accounts().map_err(ToolError::Port)?;
|
||||
let count = accounts.len();
|
||||
let display: Vec<String> = accounts
|
||||
.iter()
|
||||
.map(|a| {
|
||||
if a.profile_name.trim().is_empty() {
|
||||
format!("{}({})", a.id, a.kind)
|
||||
} else {
|
||||
format!("{}({})", a.profile_name, a.kind)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut content = json!({
|
||||
"count": count,
|
||||
"accounts": accounts,
|
||||
"credential_policy": "账户凭据(access token / 刷新令牌 / authlib 密码)永不经由 AI 工具出口",
|
||||
});
|
||||
// 兜底脱敏:端口实现若把敏感串塞进了公开字段,这里也不会带出去。
|
||||
content = crate::redact::redact_texts(&content);
|
||||
Ok(ToolOutput::ok(
|
||||
content,
|
||||
if count == 0 {
|
||||
"还没有任何账户".to_string()
|
||||
} else {
|
||||
format!("账户 {count} 个:{}", display.join("、"))
|
||||
},
|
||||
))
|
||||
});
|
||||
|
||||
registry.register(ToolSpec::read_only(
|
||||
LIST_ACCOUNTS,
|
||||
"列出启动器里已登录的账户(展示名 / 轨道 offline|microsoft|authlib / 档案 UUID / 创建时间)。**永不返回任何凭据**:令牌与密码不在工具结果的类型里,需要登录态请让用户在启动器界面查看。",
|
||||
object(&[], &[]),
|
||||
handler,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockAccounts;
|
||||
|
||||
fn registry(port: Arc<dyn AccountPort>) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
register(&mut registry, port).expect("注册应成功");
|
||||
registry
|
||||
}
|
||||
|
||||
fn call(registry: &ToolRegistry) -> Value {
|
||||
let spec = registry.get(LIST_ACCOUNTS).expect("工具存在").clone();
|
||||
let ctx = ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
};
|
||||
(spec.handler)(&ctx, &json!({})).expect("应成功").content
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_accounts_without_credentials() {
|
||||
let content = call(®istry(Arc::new(MockAccounts::new())));
|
||||
assert_eq!(content["count"], json!(1));
|
||||
assert_eq!(content["accounts"][0]["profile_name"], json!("Steve"));
|
||||
// 只扫账户数据本身:`credential_policy` 那句说明文字里本来就写着 "token"。
|
||||
let fields = content["accounts"].to_string().to_lowercase();
|
||||
for forbidden in ["token", "password", "refresh", "secret"] {
|
||||
assert!(
|
||||
!fields.contains(forbidden),
|
||||
"账户字段含 `{forbidden}`:{fields}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_list_is_not_an_error() {
|
||||
let content = call(®istry(Arc::new(MockAccounts::empty())));
|
||||
assert_eq!(content["count"], json!(0));
|
||||
assert_eq!(content["accounts"], json!([]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! `get_config` / `set_config`(02 文档 §3.1「有权限修改 ULL 各项配置」)。
|
||||
//!
|
||||
//! 安全边界都落在这里,但**分级确认的"要不要问"不在这里**:
|
||||
//! 工具只负责「白名单过滤 → 生成 diff → 需要确认时只报价不执行」,
|
||||
//! 问不问由编排层按 [`crate::confirm::ConfirmPolicy`] 决定(YOLO 也只改那一处)。
|
||||
//!
|
||||
//! 值校验(类型/范围/schema)归 M12 的配置 schema 所有:agent 不复制一份,
|
||||
//! 否则两处规则迟早漂移(02 文档 §3.1「schema 强校验」)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::confirm::Sensitivity;
|
||||
use crate::port::{SettingKey, SettingPort};
|
||||
use crate::registry::schema::{any, non_empty_string, object, string};
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 读配置。
|
||||
pub const GET_CONFIG: &str = "get_config";
|
||||
/// 写配置。
|
||||
pub const SET_CONFIG: &str = "set_config";
|
||||
/// 错误提示里最多列多少个可写键(防"把白名单灌给模型")。
|
||||
pub const MAX_LISTED_KEYS: usize = 16;
|
||||
/// 摘要里值的最大展示长度。
|
||||
const BRIEF_CHARS: usize = 60;
|
||||
|
||||
/// 注册两个配置工具。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(
|
||||
registry: &mut ToolRegistry,
|
||||
port: Arc<dyn SettingPort>,
|
||||
) -> Result<(), RegistryError> {
|
||||
registry.register(get_config_spec(Arc::clone(&port)))?;
|
||||
registry.register(set_config_spec(port))
|
||||
}
|
||||
|
||||
fn get_config_spec(port: Arc<dyn SettingPort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty());
|
||||
match path {
|
||||
None => {
|
||||
let summary = port.summary();
|
||||
let writable = port.writable_keys();
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"scope": "all",
|
||||
"values": summary,
|
||||
"writable_keys": writable.iter().map(describe_key).collect::<Vec<_>>(),
|
||||
}),
|
||||
format!(
|
||||
"读取全部配置概要({} 个键,其中 {} 个 AI 可写)",
|
||||
summary.as_object().map_or(0, serde_json::Map::len),
|
||||
writable.len()
|
||||
),
|
||||
))
|
||||
}
|
||||
Some(path) => {
|
||||
// 凭据类键一律不读:值会进 ToolOutput.content → 回灌模型 → 发往远端 LLM,
|
||||
// 违反本 crate 的硬边界(AI 永不接触账户凭据,见 `lib.rs`)。
|
||||
// 这是 agent 侧的第二道防线:第一道是 M12 不把凭据放白名单/概要。
|
||||
if crate::redact::is_sensitive_key(path) {
|
||||
return Err(ToolError::NotAllowed(format!(
|
||||
"`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看。"
|
||||
)));
|
||||
}
|
||||
let value = port.get(path).map_err(ToolError::Port)?;
|
||||
Ok(ToolOutput::ok(
|
||||
json!({ "scope": "key", "path": path, "value": value }),
|
||||
format!("读取配置 `{path}` = {}", brief(&value)),
|
||||
))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
GET_CONFIG,
|
||||
"读取启动器配置。不带 path 返回全部配置概要(含 AI 可写键及其敏感等级);带 path 返回单个键的值。",
|
||||
object(
|
||||
&[(
|
||||
"path",
|
||||
string("配置键点分路径,如 `download.concurrency`;省略则返回全部概要"),
|
||||
)],
|
||||
&[],
|
||||
),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
fn set_config_spec(port: Arc<dyn SettingPort>) -> ToolSpec {
|
||||
let handler: ToolFn =
|
||||
Arc::new(move |ctx: &ToolCtx, args: &Value| apply_set_config(port.as_ref(), ctx, args));
|
||||
|
||||
ToolSpec::mutating(
|
||||
SET_CONFIG,
|
||||
"修改启动器配置(仅限白名单内的键)。普通键立即生效;敏感键(Java 路径/数据目录/代理/AI 端点)会先返回 needs_confirmation,等用户确认后才会真正写入。值必须符合该键的类型与范围。",
|
||||
object(
|
||||
&[
|
||||
("path", non_empty_string("配置键点分路径,必须是 get_config 列出的可写键")),
|
||||
("value", any()),
|
||||
],
|
||||
&["path", "value"],
|
||||
),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
/// `set_config` 的实现(独立成函数便于单测直接调用)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 键不在白名单、值缺失、端口写入失败时返回 [`ToolError`]。
|
||||
pub fn apply_set_config(
|
||||
port: &dyn SettingPort,
|
||||
ctx: &ToolCtx,
|
||||
args: &Value,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty())
|
||||
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `path`".to_string()))?
|
||||
.to_string();
|
||||
let value = args
|
||||
.get("value")
|
||||
.cloned()
|
||||
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `value`".to_string()))?;
|
||||
|
||||
let keys = port.writable_keys();
|
||||
let key = keys
|
||||
.iter()
|
||||
.find(|k| k.path == path)
|
||||
.ok_or_else(|| ToolError::NotAllowed(not_writable_message(&keys, &path)))?;
|
||||
|
||||
// 旧值读不到不影响写(首次设置),只是 diff 少一半。
|
||||
let old = port.get(&path).ok();
|
||||
|
||||
if !ctx.confirmed && key.sensitivity == Sensitivity::High {
|
||||
let diff = json!({
|
||||
"path": path,
|
||||
"old": old,
|
||||
"new": value,
|
||||
"sensitivity": key.sensitivity,
|
||||
"description": key.description,
|
||||
});
|
||||
return Ok(ToolOutput::needs_confirmation(
|
||||
format!(
|
||||
"AI 请求把 `{path}` 从 {} 改为 {}({})",
|
||||
brief_opt(&old),
|
||||
brief(&value),
|
||||
sensitivity_label(key.sensitivity)
|
||||
),
|
||||
diff,
|
||||
key.sensitivity,
|
||||
));
|
||||
}
|
||||
|
||||
let change = port.set(&path, &value).map_err(ToolError::Port)?;
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"status": "written",
|
||||
"path": change.path,
|
||||
"old": change.old,
|
||||
"new": change.new,
|
||||
"sensitivity": key.sensitivity,
|
||||
}),
|
||||
format!(
|
||||
"已把 `{}` 从 {} 改为 {}",
|
||||
change.path,
|
||||
brief_opt(&change.old),
|
||||
brief(&change.new)
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// 白名单拒绝的文案:说清"不能写"并给出可写键(模型会据此改用正确路径)。
|
||||
fn not_writable_message(keys: &[SettingKey], path: &str) -> String {
|
||||
let listed: Vec<&str> = keys
|
||||
.iter()
|
||||
.take(MAX_LISTED_KEYS)
|
||||
.map(|k| k.path.as_str())
|
||||
.collect();
|
||||
let rest = keys.len().saturating_sub(listed.len());
|
||||
let tail = if rest > 0 {
|
||||
format!(",另有 {rest} 个键见 get_config 概要")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"配置键 `{path}` 不在 AI 可写白名单内。可写键({} 个):{}{tail}",
|
||||
keys.len(),
|
||||
listed.join("、")
|
||||
)
|
||||
}
|
||||
|
||||
fn describe_key(key: &SettingKey) -> Value {
|
||||
json!({
|
||||
"path": key.path,
|
||||
"sensitivity": key.sensitivity,
|
||||
"value_type": key.value_type,
|
||||
"description": key.description,
|
||||
})
|
||||
}
|
||||
|
||||
fn sensitivity_label(sensitivity: Sensitivity) -> &'static str {
|
||||
match sensitivity {
|
||||
Sensitivity::Low => "普通",
|
||||
Sensitivity::Medium => "中等(静默但审计)",
|
||||
Sensitivity::High => "敏感,需确认",
|
||||
}
|
||||
}
|
||||
|
||||
fn brief(value: &Value) -> String {
|
||||
let rendered = match value {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
truncate(&rendered)
|
||||
}
|
||||
|
||||
fn brief_opt(value: &Option<Value>) -> String {
|
||||
value
|
||||
.as_ref()
|
||||
.map_or_else(|| "(未设置)".to_string(), brief)
|
||||
}
|
||||
|
||||
fn truncate(text: &str) -> String {
|
||||
if text.chars().count() <= BRIEF_CHARS {
|
||||
return text.to_string();
|
||||
}
|
||||
let head: String = text.chars().take(BRIEF_CHARS).collect();
|
||||
format!("{head}…")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockSettings;
|
||||
|
||||
fn ctx(confirmed: bool) -> ToolCtx {
|
||||
ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_keys_outside_whitelist() {
|
||||
let port = MockSettings::new();
|
||||
let err = apply_set_config(
|
||||
&port,
|
||||
&ctx(true),
|
||||
&json!({ "path": "java.random.thing", "value": 1 }),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::NotAllowed(_)));
|
||||
assert!(err.message().contains("不在 AI 可写白名单内"));
|
||||
assert!(err.message().contains("download.concurrency"));
|
||||
assert_eq!(port.set_calls(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_key_writes_without_confirmation() {
|
||||
let port = MockSettings::new();
|
||||
let out = apply_set_config(
|
||||
&port,
|
||||
&ctx(false),
|
||||
&json!({ "path": "download.concurrency", "value": 16 }),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.confirmation.is_none());
|
||||
assert_eq!(out.content["status"], json!("written"));
|
||||
assert_eq!(port.value_of("download.concurrency"), Some(json!(16)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_key_writes_silently_too() {
|
||||
// M12:Medium = 静默生效但审计标记,不弹窗
|
||||
let port = MockSettings::new();
|
||||
let out = apply_set_config(
|
||||
&port,
|
||||
&ctx(false),
|
||||
&json!({ "path": "download.region", "value": "cn" }),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.confirmation.is_none());
|
||||
assert_eq!(port.set_calls(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_key_quotes_first_then_writes_after_confirm() {
|
||||
let port = MockSettings::new();
|
||||
let args = json!({ "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" });
|
||||
|
||||
let quoted = apply_set_config(&port, &ctx(false), &args).unwrap();
|
||||
let confirmation = quoted.confirmation.expect("High 键应要求确认");
|
||||
assert_eq!(confirmation.sensitivity, Sensitivity::High);
|
||||
assert_eq!(confirmation.details["new"], json!("D:/jdk17/bin/java.exe"));
|
||||
assert_eq!(port.set_calls(), 0, "未确认前不得写入");
|
||||
|
||||
let written = apply_set_config(&port, &ctx(true), &args).unwrap();
|
||||
assert!(written.confirmation.is_none());
|
||||
assert_eq!(port.set_calls(), 1);
|
||||
assert_eq!(
|
||||
port.value_of("java.selected_path"),
|
||||
Some(json!("D:/jdk17/bin/java.exe"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_credential_key_is_not_writable() {
|
||||
// M12:ai.api_key 是 writable=false,不在白名单里 → 任何情况下都拒绝
|
||||
let port = MockSettings::new();
|
||||
let err = apply_set_config(
|
||||
&port,
|
||||
&ctx(true),
|
||||
&json!({ "path": "ai.api_key", "value": "sk-x" }),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::NotAllowed(_)));
|
||||
assert_eq!(port.set_calls(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_error_is_passed_through() {
|
||||
let port = MockSettings::new();
|
||||
port.fail_set_with("磁盘只读");
|
||||
let err = apply_set_config(
|
||||
&port,
|
||||
&ctx(true),
|
||||
&json!({ "path": "appearance.theme_mode", "value": "metro_light" }),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "port_error");
|
||||
assert!(err.message().contains("磁盘只读"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! `diagnose_crash`(02 文档 §3.2 第三条)。
|
||||
//!
|
||||
//! 流水线是「**规则在前、模型补位**」(analyse/02 §8.3):本工具只跑 M15 的规则引擎,
|
||||
//! 把**结构化结论 + 关键片段**交给模型,绝不让模型读裸日志。规则未命中
|
||||
//! (`candidates` 空 / `needs_more_info`)时,模型再去用 `read_game_log` 自己找证据。
|
||||
//!
|
||||
//! P0 与 M15 的桥:M15 的修复动作指向 `java.set` / `memory.set` 这些 **P1 才有的**语义工具 id。
|
||||
//! P0 只有 `set_config`,所以这里按 [`FixActionKind`] 生成 `p0_next_steps`
|
||||
//! (`adjust_memory` → `set_config {"path":"java.default_memory_mb"}`,`change_java` →
|
||||
//! `set_config {"path":"java.selected_path"}`,见 [`MEMORY_KEY`] / [`JAVA_KEY`]),
|
||||
//! 剩下的动作明确标成「P0 无工具,解释给用户」。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::port::{CrashDiagnosis, DiagnosePort, DiagnosisCandidate, FixAction, FixActionKind};
|
||||
use crate::redact::redact_text;
|
||||
use crate::registry::schema::{integer, non_empty_string, object};
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 诊断工具名。
|
||||
pub const DIAGNOSE_CRASH: &str = "diagnose_crash";
|
||||
/// 单次返回的证据行上限(防 token 爆炸)。
|
||||
pub const MAX_EVIDENCE_LINES: usize = 40;
|
||||
/// 证据行默认条数。
|
||||
pub const DEFAULT_EVIDENCE_LINES: usize = 20;
|
||||
/// 单条证据最大字符数。
|
||||
pub const MAX_EVIDENCE_CHARS: usize = 400;
|
||||
/// 内存建议的配置键(M12 白名单里的键名,见 `setting-接口约定.md` §8)。
|
||||
pub const MEMORY_KEY: &str = "java.default_memory_mb";
|
||||
/// Java 路径的配置键。
|
||||
pub const JAVA_KEY: &str = "java.selected_path";
|
||||
|
||||
/// 注册诊断工具。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(
|
||||
registry: &mut ToolRegistry,
|
||||
port: Arc<dyn DiagnosePort>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
|
||||
let session_id = args
|
||||
.get("session_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))?;
|
||||
let max_lines = args
|
||||
.get("max_evidence_lines")
|
||||
.and_then(Value::as_u64)
|
||||
.map_or(DEFAULT_EVIDENCE_LINES, |n| n as usize)
|
||||
.clamp(1, MAX_EVIDENCE_LINES);
|
||||
let report = port.diagnose(session_id).map_err(ToolError::Port)?;
|
||||
Ok(render(&report, max_lines))
|
||||
});
|
||||
|
||||
registry.register(ToolSpec::read_only(
|
||||
DIAGNOSE_CRASH,
|
||||
"对某次游戏会话跑规则引擎(59 条崩溃规则)并返回结构化结论:主因、置信度、证据行、修复动作。规则未命中时会提示需要更多信息——此时再用 read_game_log 取 stderr/exit 片段。",
|
||||
object(
|
||||
&[
|
||||
("session_id", non_empty_string("会话 id,来自 list_game_sessions")),
|
||||
(
|
||||
"max_evidence_lines",
|
||||
integer("返回多少条证据行", 1, MAX_EVIDENCE_LINES as i64),
|
||||
),
|
||||
],
|
||||
&["session_id"],
|
||||
),
|
||||
handler,
|
||||
))
|
||||
}
|
||||
|
||||
/// 把规则引擎结论压成工具结果。
|
||||
fn render(report: &CrashDiagnosis, max_evidence_lines: usize) -> ToolOutput {
|
||||
let candidates: Vec<Value> = report
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|c| render_candidate(c, max_evidence_lines))
|
||||
.collect();
|
||||
let secondary: Vec<Value> = report
|
||||
.secondary
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id,
|
||||
"severity": c.severity,
|
||||
"category": c.category,
|
||||
"confidence": c.confidence,
|
||||
"message": message_of(c),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let next_steps: Vec<Value> = report
|
||||
.candidates
|
||||
.iter()
|
||||
.flat_map(|c| c.fix_actions.iter())
|
||||
.map(bridge_to_p0)
|
||||
.collect();
|
||||
|
||||
let content = json!({
|
||||
"session_id": report.session_id,
|
||||
"source": report.source,
|
||||
"needs_more_info": report.needs_more_info,
|
||||
"exit": report.exit,
|
||||
"top_confidence": report.top_confidence(),
|
||||
"candidates": candidates,
|
||||
"secondary": secondary,
|
||||
"evidence": cap_lines(&report.evidence, max_evidence_lines),
|
||||
"p0_next_steps": next_steps,
|
||||
});
|
||||
|
||||
ToolOutput::ok(content, summarize(report))
|
||||
}
|
||||
|
||||
fn render_candidate(candidate: &DiagnosisCandidate, max_evidence_lines: usize) -> Value {
|
||||
json!({
|
||||
"id": candidate.id,
|
||||
"severity": candidate.severity,
|
||||
"category": candidate.category,
|
||||
"confidence": candidate.confidence,
|
||||
"message": message_of(candidate),
|
||||
"detail": candidate.detail,
|
||||
"evidence": cap_lines(&candidate.evidence, max_evidence_lines),
|
||||
"fix_actions": candidate.fix_actions,
|
||||
})
|
||||
}
|
||||
|
||||
/// `message` 为空时回落显示 i18n key(装配层没渲染时至少不丢信息)。
|
||||
fn message_of(candidate: &DiagnosisCandidate) -> String {
|
||||
let text = candidate.message.trim();
|
||||
if text.is_empty() {
|
||||
candidate.message_key.clone()
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize(report: &CrashDiagnosis) -> String {
|
||||
match report.top() {
|
||||
Some(top) => {
|
||||
let label = top
|
||||
.fix_actions
|
||||
.first()
|
||||
.map(action_label)
|
||||
.unwrap_or_else(|| "没有可执行的修复动作".to_string());
|
||||
format!(
|
||||
"规则引擎命中 {} 条候选,主因 `{}`(置信度 {:.2});建议:{label}",
|
||||
report.candidates.len(),
|
||||
top.id,
|
||||
top.confidence
|
||||
)
|
||||
}
|
||||
None if report.needs_more_info => {
|
||||
"规则引擎未命中,且需要更多信息(用 read_game_log 取 stderr/exit 片段后再判断)"
|
||||
.to_string()
|
||||
}
|
||||
None => "规则引擎未命中(59 条规则都不匹配),需要结合日志片段人工/AI 判断".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// M15 的语义动作 → P0 现有工具。返回给模型看的"下一步"。
|
||||
fn bridge_to_p0(action: &FixAction) -> Value {
|
||||
let label = action_label(action);
|
||||
match action.kind {
|
||||
FixActionKind::AdjustMemory => {
|
||||
let value = pick_memory_mb(action).map_or(Value::Null, |mb| json!(mb));
|
||||
json!({
|
||||
"action": action.tool,
|
||||
"kind": action.kind,
|
||||
"label": label,
|
||||
"suggested_tool": "set_config",
|
||||
"suggested_args": { "path": MEMORY_KEY, "value": value },
|
||||
})
|
||||
}
|
||||
FixActionKind::ChangeJava => {
|
||||
let value = pick_arg(action, &["path", "java_path", "binary", "value"])
|
||||
.map_or(Value::Null, |p| json!(p));
|
||||
json!({
|
||||
"action": action.tool,
|
||||
"kind": action.kind,
|
||||
"label": label,
|
||||
"suggested_tool": "set_config",
|
||||
"suggested_args": { "path": JAVA_KEY, "value": value },
|
||||
})
|
||||
}
|
||||
other => json!({
|
||||
"action": action.tool,
|
||||
"kind": other,
|
||||
"label": label,
|
||||
"suggested_tool": Value::Null,
|
||||
"unavailable_reason": "P0 没有对应工具(P1 落地),请把这个建议解释给用户",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 动作的人话:优先 M15 给的 `label`,否则按 kind 兜底。
|
||||
fn action_label(action: &FixAction) -> String {
|
||||
let label = action.label.trim();
|
||||
if !label.is_empty() {
|
||||
return label.to_string();
|
||||
}
|
||||
match action.kind {
|
||||
FixActionKind::ChangeJava => "换一个匹配的 Java 版本".to_string(),
|
||||
FixActionKind::AdjustMemory => "调整最大内存".to_string(),
|
||||
FixActionKind::ChangeGraphics => "调整图形设置/驱动".to_string(),
|
||||
FixActionKind::RemoveMod => "移除或替换冲突的 Mod".to_string(),
|
||||
FixActionKind::Reinstall => "重装/修复对应组件".to_string(),
|
||||
FixActionKind::SeekHelp => "导出诊断包并反馈".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从动作参数里挑内存上限:先看常见键,再退化成"第一个带数字的值"。
|
||||
fn pick_memory_mb(action: &FixAction) -> Option<i64> {
|
||||
if let Some(value) = pick_arg(
|
||||
action,
|
||||
&["value", "suggest", "mb", "max_mb", "xmx", "xmx_mb"],
|
||||
) {
|
||||
if let Some(mb) = parse_mb(&value) {
|
||||
return Some(mb);
|
||||
}
|
||||
}
|
||||
action.args.values().find_map(|v| parse_mb(v))
|
||||
}
|
||||
|
||||
fn pick_arg(action: &FixAction, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| action.args.get(*key).cloned())
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// `"4096"` / `"4G"` / `"6144M"` → MB。
|
||||
fn parse_mb(raw: &str) -> Option<i64> {
|
||||
let text = raw.trim().to_ascii_lowercase();
|
||||
let (number, scale) = if let Some(rest) = text.strip_suffix('g') {
|
||||
(rest, 1024)
|
||||
} else if let Some(rest) = text.strip_suffix("gb") {
|
||||
(rest, 1024)
|
||||
} else if let Some(rest) = text.strip_suffix('m') {
|
||||
(rest, 1)
|
||||
} else if let Some(rest) = text.strip_suffix("mb") {
|
||||
(rest, 1)
|
||||
} else {
|
||||
(text.as_str(), 1)
|
||||
};
|
||||
number.trim().parse::<i64>().ok().map(|n| n * scale)
|
||||
}
|
||||
|
||||
fn cap_lines(lines: &[String], max_lines: usize) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.take(max_lines)
|
||||
.map(|line| {
|
||||
let masked = redact_text(line);
|
||||
if masked.chars().count() <= MAX_EVIDENCE_CHARS {
|
||||
masked
|
||||
} else {
|
||||
let head: String = masked.chars().take(MAX_EVIDENCE_CHARS).collect();
|
||||
format!("{head}…")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockDiagnose;
|
||||
|
||||
fn call(port: &MockDiagnose, session_id: &str) -> ToolOutput {
|
||||
let report = port.diagnose(session_id).expect("mock 应返回结论");
|
||||
render(&report, DEFAULT_EVIDENCE_LINES)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_candidates_with_p0_bridge_for_reinstall() {
|
||||
let out = call(&MockDiagnose::new(), "s-1");
|
||||
assert_eq!(out.content["candidates"].as_array().unwrap().len(), 1);
|
||||
let confidence = out.content["top_confidence"].as_f64().unwrap();
|
||||
assert!((confidence - 0.86).abs() < 1e-6, "{confidence}");
|
||||
let steps = out.content["p0_next_steps"].as_array().unwrap();
|
||||
assert_eq!(steps[0]["kind"], json!("reinstall"));
|
||||
assert_eq!(steps[0]["suggested_tool"], Value::Null);
|
||||
assert!(out.summary.contains("建议:重装该版本的 Forge 组件"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridges_memory_action_to_set_config() {
|
||||
let mut report = MockDiagnose::forge_missing_library();
|
||||
if let Some(candidate) = report.candidates.first_mut() {
|
||||
candidate.fix_actions = vec![FixAction {
|
||||
kind: FixActionKind::AdjustMemory,
|
||||
tool: "memory.set".to_string(),
|
||||
label: String::new(),
|
||||
args: [("suggest".to_string(), "4G".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}];
|
||||
}
|
||||
let out = render(&report, 5);
|
||||
let steps = out.content["p0_next_steps"].as_array().unwrap();
|
||||
assert_eq!(steps[0]["suggested_tool"], json!("set_config"));
|
||||
assert_eq!(steps[0]["suggested_args"]["path"], json!(MEMORY_KEY));
|
||||
assert_eq!(steps[0]["suggested_args"]["value"], json!(4096));
|
||||
assert_eq!(steps[0]["label"], json!("调整最大内存"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_memory_units() {
|
||||
assert_eq!(parse_mb("4096"), Some(4096));
|
||||
assert_eq!(parse_mb("4G"), Some(4096));
|
||||
assert_eq!(parse_mb("6144mb"), Some(6144));
|
||||
assert_eq!(parse_mb("abc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_candidates_says_need_more_info() {
|
||||
let report = CrashDiagnosis {
|
||||
session_id: "s-2".into(),
|
||||
source: "rules".into(),
|
||||
needs_more_info: true,
|
||||
candidates: vec![],
|
||||
secondary: vec![],
|
||||
evidence: vec![],
|
||||
exit: crate::port::ExitSummary::default(),
|
||||
};
|
||||
let out = render(&report, 5);
|
||||
assert!(out.summary.contains("需要更多信息"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! 实例 / 版本 / Java 三个只读工具(M39 任务 3 的读侧)。
|
||||
//!
|
||||
//! 全部只读、全部走 [`crate::port::GamePort`]:agent 不自己去翻 `versions/` 目录,
|
||||
//! 也不自己扫 JDK——那两件事的真相源在 M14(game)与 M33(java),重复一份必然漂。
|
||||
//!
|
||||
//! 输出形状刻意做成「摘要 + 上限」:实例可能几十个、Java 候选可能十几个,
|
||||
//! 一次全塞给模型等于烧 token(02 §3.2 的「单次返回上限防 token 爆炸」同理)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::port::GamePort;
|
||||
use crate::registry::schema::{integer, object};
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 实例列表工具名。
|
||||
pub const LIST_INSTANCES: &str = "list_instances";
|
||||
/// 版本列表工具名。
|
||||
pub const LIST_VERSIONS: &str = "list_versions";
|
||||
/// Java 现状工具名。
|
||||
pub const GET_JAVA_STATUS: &str = "get_java_status";
|
||||
/// 单次最多返回多少个实例/版本。
|
||||
pub const MAX_ROWS: usize = 50;
|
||||
|
||||
/// 注册三个只读工具。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(registry: &mut ToolRegistry, port: Arc<dyn GamePort>) -> Result<(), RegistryError> {
|
||||
registry.register(list_instances_spec(Arc::clone(&port)))?;
|
||||
registry.register(list_versions_spec(Arc::clone(&port)))?;
|
||||
registry.register(java_status_spec(port))
|
||||
}
|
||||
|
||||
fn limit_arg(args: &Value) -> usize {
|
||||
args.get("limit")
|
||||
.and_then(Value::as_u64)
|
||||
.map_or(MAX_ROWS, |n| usize::try_from(n).unwrap_or(MAX_ROWS))
|
||||
.clamp(1, MAX_ROWS)
|
||||
}
|
||||
|
||||
fn limit_schema() -> Value {
|
||||
integer(
|
||||
&format!("最多返回多少条(1~{MAX_ROWS},默认 {MAX_ROWS})"),
|
||||
1,
|
||||
MAX_ROWS as i64,
|
||||
)
|
||||
}
|
||||
|
||||
fn list_instances_spec(port: Arc<dyn GamePort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
|
||||
let limit = limit_arg(args);
|
||||
let mut instances = port.list_instances().map_err(ToolError::Port)?;
|
||||
let total = instances.len();
|
||||
instances.truncate(limit);
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"total": total,
|
||||
"shown": instances.len(),
|
||||
"truncated": total > instances.len(),
|
||||
"instances": instances,
|
||||
}),
|
||||
format!("列出 {}/{} 个实例", instances.len(), total),
|
||||
))
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
LIST_INSTANCES,
|
||||
"列出启动器里的游戏实例(id / 名称 / 版本 id / 载荷是否已安装 / 独立游戏目录 / 实例级 Java)。要看某个实例的日志先用 list_game_sessions。",
|
||||
object(&[("limit", limit_schema())], &[]),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
fn list_versions_spec(port: Arc<dyn GamePort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
|
||||
let limit = limit_arg(args);
|
||||
let mut versions = port.list_versions().map_err(ToolError::Port)?;
|
||||
let total = versions.len();
|
||||
versions.truncate(limit);
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"total": total,
|
||||
"shown": versions.len(),
|
||||
"truncated": total > versions.len(),
|
||||
"versions": versions,
|
||||
}),
|
||||
format!("列出 {}/{} 个已安装版本", versions.len(), total),
|
||||
))
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
LIST_VERSIONS,
|
||||
"列出已安装的游戏版本(`versions/<id>/` 下真实存在的载荷),带类型与被几个实例引用。判断「实例的载荷缺没缺」时配合 list_instances 的 version_present 一起看。",
|
||||
object(&[("limit", limit_schema())], &[]),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
fn java_status_spec(port: Arc<dyn GamePort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
|
||||
let status = port.java_status().map_err(ToolError::Port)?;
|
||||
let majors: Vec<String> = status
|
||||
.candidates
|
||||
.iter()
|
||||
.filter_map(|c| c.major.map(|m| m.to_string()))
|
||||
.collect();
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"default_memory_mb": status.default_memory_mb,
|
||||
"managed_roots": status.managed_roots,
|
||||
"candidate_count": status.candidates.len(),
|
||||
"majors": majors,
|
||||
"candidates": status.candidates,
|
||||
}),
|
||||
format!(
|
||||
"Java 候选 {} 个(主版本 {})",
|
||||
status.candidates.len(),
|
||||
if majors.is_empty() {
|
||||
"无".to_string()
|
||||
} else {
|
||||
majors.join("/")
|
||||
}
|
||||
),
|
||||
))
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
GET_JAVA_STATUS,
|
||||
"读取 Java 现状:托管扫描根、默认堆内存、探测到的全部运行时(路径 / 版本 / 主版本 / 发行方 / 来源)。启动报「不支持的 class 版本」「找不到 Java」时先看这里。",
|
||||
object(&[], &[]),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockGame;
|
||||
|
||||
fn registry() -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
register(&mut registry, Arc::new(MockGame::new())).expect("注册应成功");
|
||||
registry
|
||||
}
|
||||
|
||||
fn call(registry: &ToolRegistry, name: &str, args: Value) -> Value {
|
||||
let spec = registry.get(name).expect("工具存在").clone();
|
||||
let ctx = ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
};
|
||||
(spec.handler)(&ctx, &args).expect("应成功").content
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_instances_with_limit() {
|
||||
let content = call(®istry(), LIST_INSTANCES, json!({ "limit": 1 }));
|
||||
assert_eq!(content["total"], json!(1));
|
||||
assert_eq!(content["instances"][0]["id"], json!("inst-1"));
|
||||
assert_eq!(content["truncated"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_versions() {
|
||||
let content = call(®istry(), LIST_VERSIONS, json!({}));
|
||||
assert_eq!(content["total"], json!(2));
|
||||
assert_eq!(content["versions"][0]["id"], json!("1.20.1-forge-47.2.20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_status_summarises_majors() {
|
||||
let content = call(®istry(), GET_JAVA_STATUS, json!({}));
|
||||
assert_eq!(content["candidate_count"], json!(2));
|
||||
assert_eq!(content["majors"], json!(["17", "21"]));
|
||||
assert_eq!(content["default_memory_mb"], json!(2048));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_errors_are_passed_through() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
register(
|
||||
&mut registry,
|
||||
Arc::new(MockGame::failing("versions 目录不可读")),
|
||||
)
|
||||
.expect("注册应成功");
|
||||
let spec = registry.get(LIST_VERSIONS).expect("工具存在").clone();
|
||||
let ctx = ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
};
|
||||
let err = (spec.handler)(&ctx, &json!({})).unwrap_err();
|
||||
assert_eq!(err.code(), "port_error");
|
||||
assert!(err.message().contains("versions 目录不可读"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! 工具集 v1(02 文档 §3):配置读写 / 会话与日志 / 崩溃诊断 / 实例与 Java / 账户。
|
||||
//!
|
||||
//! 分工边界(重要,评审时按这条看):
|
||||
//! - **工具**:参数语义 → 端口调用 → 结构化结果;需要确认时**只报价不执行**。
|
||||
//! - **编排层**([`crate::brain::exec`]):schema 校验、确认闸门、YOLO 判定、审计、事件。
|
||||
//! - **端口**([`crate::port`]):真正的 IO 与领域校验(M12/M14/M15/M18/M31/M33 拥有)。
|
||||
//!
|
||||
//! 这样「YOLO」只在一个地方(编排层),工具的测试不需要关心 policy。
|
||||
//!
|
||||
//! 双投影:这里的注册表是**唯一真相源**,内置客户端投成 rig `DynamicTool`,
|
||||
//! 对外 MCP server 投成 rmcp `ToolRoute`([`crate::mcp`])。工具数量与顺序对外一致。
|
||||
|
||||
pub mod accounts;
|
||||
pub mod config;
|
||||
pub mod diagnose;
|
||||
pub mod game;
|
||||
pub mod schema_tool;
|
||||
pub mod session;
|
||||
|
||||
use crate::port::Ports;
|
||||
use crate::registry::{RegistryError, ToolRegistry};
|
||||
|
||||
/// 组装 v1 的全部工具。注册顺序 = 模型看到的顺序(只读在前,写在后)。
|
||||
///
|
||||
/// 顺序不是随便排的:模型在长工具表里更容易选中靠前的项,所以「先看再改」
|
||||
/// (`get_config*` 在 `set_config` 之前)是刻意的提示。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复 / schema 非法时返回 [`RegistryError`](属于编码错误,装配期就该炸)。
|
||||
pub fn build_v1_registry(ports: &Ports) -> Result<ToolRegistry, RegistryError> {
|
||||
let mut registry = ToolRegistry::new();
|
||||
config::register(&mut registry, ports.setting.clone())?;
|
||||
schema_tool::register(&mut registry, ports.setting.clone())?;
|
||||
game::register(&mut registry, ports.game.clone())?;
|
||||
accounts::register(&mut registry, ports.accounts.clone())?;
|
||||
session::register(&mut registry, ports.sessions.clone())?;
|
||||
diagnose::register(&mut registry, ports.diagnose.clone())?;
|
||||
Ok(registry)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! `get_config_schema`:把 M12 的配置 schema(**只含 AI 可写键**)投给模型。
|
||||
//!
|
||||
//! 为什么单独一个工具而不是塞进 `get_config`:模型要能「先看有哪些键、类型是什么、
|
||||
//! 哪些是敏感键」,再决定要不要写——这是一次性的静态信息,和读当前值不是同一件事。
|
||||
//! 拆开还能让 token 预算更可控(schema 比概要大一个量级)。
|
||||
//!
|
||||
//! 凭据边界:schema 由 M12 的 `SettingSchema::to_json_schema()` 生成,只遍历
|
||||
//! `ai.writable && !deprecated` 的键,`ai.api_key`(`locked`)天然不在其中。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::port::SettingPort;
|
||||
use crate::registry::schema::object;
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 工具名。
|
||||
pub const GET_CONFIG_SCHEMA: &str = "get_config_schema";
|
||||
|
||||
/// 注册 `get_config_schema`。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(
|
||||
registry: &mut ToolRegistry,
|
||||
port: Arc<dyn SettingPort>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
|
||||
let schema = port.schema();
|
||||
if schema.get("properties").is_none() {
|
||||
return Err(ToolError::Port(
|
||||
"配置 schema 不可用:装配层没有提供 SettingPort::schema()".to_string(),
|
||||
));
|
||||
}
|
||||
let writable = schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.map_or(0, serde_json::Map::len);
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"scope": "ai_writable_schema",
|
||||
"writable_key_count": writable,
|
||||
"schema": schema,
|
||||
}),
|
||||
format!("读取配置 schema({writable} 个 AI 可写键)"),
|
||||
))
|
||||
});
|
||||
|
||||
registry.register(ToolSpec::read_only(
|
||||
GET_CONFIG_SCHEMA,
|
||||
"读取启动器配置的 JSON Schema(只含 AI 可写键)。每个键带类型、取值范围、说明与 `x-ai-sensitivity` 敏感等级;凭据类键不会出现。写配置前先看它。",
|
||||
object(&[], &[]),
|
||||
handler,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockSettings;
|
||||
|
||||
fn ctx() -> ToolCtx {
|
||||
ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_schema_without_credentials() {
|
||||
let port: Arc<dyn SettingPort> = Arc::new(MockSettings::new());
|
||||
let mut registry = ToolRegistry::new();
|
||||
register(&mut registry, port).expect("注册应成功");
|
||||
let spec = registry.get(GET_CONFIG_SCHEMA).expect("工具存在").clone();
|
||||
let out = (spec.handler)(&ctx(), &json!({})).expect("应返回 schema");
|
||||
let text = out.content.to_string();
|
||||
assert!(out.content["writable_key_count"].as_u64().unwrap_or(0) > 0);
|
||||
assert!(text.contains("x-ai-sensitivity"));
|
||||
assert!(!text.contains("ai.api_key"), "凭据键不得进 schema:{text}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
//! `list_game_sessions` / `read_game_log`(02 文档 §3.2「能读取某次游戏崩溃日志」)。
|
||||
//!
|
||||
//! 数据源是 M18 的会话事件流(`DevDocs/SubAgentsTalk/launch-接口约定.md`):
|
||||
//! - 列表读 `index.jsonl`(每会话一行摘要,同 id 取最后一行);
|
||||
//! - 日志读 `<session-id>.jsonl`,**按 `seq` 切片 + 按 `kind` 过滤**,不是按行号。
|
||||
//!
|
||||
//! 两条防 token 爆炸的硬约束:
|
||||
//! 1. 单次最多 [`MAX_LOG_EVENTS`] 条事件(默认 [`DEFAULT_LOG_EVENTS`]);
|
||||
//! 2. 单条 payload 里的字符串超过 [`MAX_PAYLOAD_CHARS`] 截断。
|
||||
//!
|
||||
//! 脱敏:launch 落盘前已经做过一遍(`<home>` / `<redacted:token>`),本工具再跑一遍
|
||||
//! [`crate::redact::redact_text`] 作为第二道防线(幂等,旧会话/第三方 mod 输出可能漏网)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::port::{GameSessionPort, LogEvent, LogPage, LogQuery};
|
||||
use crate::redact::redact_text;
|
||||
use crate::registry::schema::{enum_string_array, integer, non_empty_string, object, string};
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 列出游戏会话。
|
||||
pub const LIST_GAME_SESSIONS: &str = "list_game_sessions";
|
||||
/// 读一个会话的日志。
|
||||
pub const READ_GAME_LOG: &str = "read_game_log";
|
||||
|
||||
/// 单次返回的事件上限(防 token 爆炸的硬闸)。
|
||||
pub const MAX_LOG_EVENTS: usize = 500;
|
||||
/// 默认返回条数。
|
||||
pub const DEFAULT_LOG_EVENTS: usize = 100;
|
||||
/// 单条 payload 字符串的展示上限(字符)。
|
||||
pub const MAX_PAYLOAD_CHARS: usize = 800;
|
||||
/// 列表默认/最大条数。
|
||||
pub const DEFAULT_SESSION_LIMIT: usize = 10;
|
||||
/// 列表最大条数。
|
||||
pub const MAX_SESSION_LIMIT: usize = 50;
|
||||
|
||||
/// `launch-接口约定.md` §3 的事件类型(schema 里给模型做枚举;未知 kind 仍可透传)。
|
||||
pub const KNOWN_KINDS: [&str; 13] = [
|
||||
"session_start",
|
||||
"java_selected",
|
||||
"cmd_built",
|
||||
"natives_extracted",
|
||||
"proc_start",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"window_detected",
|
||||
"exit",
|
||||
"crash_found",
|
||||
"diagnosis",
|
||||
"killed",
|
||||
"session_end",
|
||||
];
|
||||
|
||||
/// 注册两个日志工具。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(
|
||||
registry: &mut ToolRegistry,
|
||||
port: Arc<dyn GameSessionPort>,
|
||||
) -> Result<(), RegistryError> {
|
||||
registry.register(list_sessions_spec(Arc::clone(&port)))?;
|
||||
registry.register(read_log_spec(port))
|
||||
}
|
||||
|
||||
fn list_sessions_spec(port: Arc<dyn GameSessionPort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(Value::as_u64)
|
||||
.map_or(DEFAULT_SESSION_LIMIT, |n| n as usize)
|
||||
.clamp(1, MAX_SESSION_LIMIT);
|
||||
let instance_id = args.get("instance_id").and_then(Value::as_str);
|
||||
let sessions = port
|
||||
.list_sessions(limit, instance_id)
|
||||
.map_err(ToolError::Port)?;
|
||||
|
||||
if sessions.is_empty() {
|
||||
return Ok(ToolOutput::ok(
|
||||
json!({ "sessions": [], "count": 0 }),
|
||||
"没有游戏会话记录(还没启动过游戏,或数据目录不对)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let crashed = sessions
|
||||
.iter()
|
||||
.filter(|s| s.crash_found || s.silent_failure || s.exit_code.is_some_and(|c| c != 0))
|
||||
.count();
|
||||
let summary = format!(
|
||||
"最近 {} 个游戏会话,其中 {crashed} 个崩溃/异常退出;最近一次 {}({})",
|
||||
sessions.len(),
|
||||
sessions[0].session_id,
|
||||
sessions[0].started_at.as_deref().unwrap_or("启动时间未知")
|
||||
);
|
||||
Ok(ToolOutput::ok(
|
||||
json!({ "sessions": sessions, "count": sessions.len(), "problem_count": crashed }),
|
||||
summary,
|
||||
))
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
LIST_GAME_SESSIONS,
|
||||
"列出最近的游戏会话(启动时间、退出码、是否崩溃、规则引擎首选结论)。用于定位要读哪一次会话的日志。",
|
||||
object(
|
||||
&[
|
||||
("limit", integer("返回条数", 1, MAX_SESSION_LIMIT as i64)),
|
||||
("instance_id", string("只看某个实例的会话;省略则全部实例")),
|
||||
],
|
||||
&[],
|
||||
),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
fn read_log_spec(port: Arc<dyn GameSessionPort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
|
||||
let session_id = args
|
||||
.get("session_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))?
|
||||
.to_string();
|
||||
|
||||
let kinds: Vec<String> = args
|
||||
.get("kinds")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let max_events = args
|
||||
.get("max_events")
|
||||
.and_then(Value::as_u64)
|
||||
.map_or(DEFAULT_LOG_EVENTS, |n| n as usize)
|
||||
.clamp(1, MAX_LOG_EVENTS);
|
||||
|
||||
let query = LogQuery {
|
||||
from_seq: args.get("from_seq").and_then(Value::as_u64),
|
||||
to_seq: args.get("to_seq").and_then(Value::as_u64),
|
||||
kinds,
|
||||
max_events,
|
||||
};
|
||||
let page = port
|
||||
.read_log(&session_id, &query)
|
||||
.map_err(ToolError::Port)?;
|
||||
Ok(render_page(&page))
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
READ_GAME_LOG,
|
||||
"读取某次游戏会话的事件流(stdout/stderr 按行、退出事件、crash-report 摘录)。可按 seq 区间和事件类型过滤;单次最多 500 条、单条最长 800 字符,优先用 kinds 过滤 stderr/exit/crash_found 而不是拉全量。",
|
||||
object(
|
||||
&[
|
||||
("session_id", non_empty_string("会话 id,来自 list_game_sessions")),
|
||||
("from_seq", integer("起始 seq(含)", 0, 9_007_199_254_740_991)),
|
||||
("to_seq", integer("结束 seq(含)", 0, 9_007_199_254_740_991)),
|
||||
(
|
||||
"kinds",
|
||||
enum_string_array(&KNOWN_KINDS, "只要这些事件类型;省略则全部", 13),
|
||||
),
|
||||
("max_events", integer("最多返回多少条", 1, MAX_LOG_EVENTS as i64)),
|
||||
],
|
||||
&["session_id"],
|
||||
),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
/// 把端口返回的一页日志压成工具结果(含摘要,模型据此决定要不要继续翻页)。
|
||||
fn render_page(page: &LogPage) -> ToolOutput {
|
||||
let events: Vec<Value> = page.events.iter().map(render_event).collect();
|
||||
let kind_summary = page
|
||||
.kind_counts
|
||||
.iter()
|
||||
.map(|(kind, count)| format!("{kind}×{count}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
let (content, summary) = if events.is_empty() {
|
||||
(
|
||||
json!({
|
||||
"session_id": page.session_id,
|
||||
"total_events": page.total_events,
|
||||
"matched_events": 0,
|
||||
"truncated": false,
|
||||
"kind_counts": page.kind_counts,
|
||||
"first_ts": page.first_ts,
|
||||
"last_ts": page.last_ts,
|
||||
"events": [],
|
||||
"hint": "没有符合过滤条件的事件;把 kinds/from_seq/to_seq 放宽再试",
|
||||
}),
|
||||
format!(
|
||||
"会话 {} 没有匹配的事件(共 {} 条)",
|
||||
page.session_id, page.total_events
|
||||
),
|
||||
)
|
||||
} else {
|
||||
let truncated_note = if page.truncated {
|
||||
"(已截断)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
(
|
||||
json!({
|
||||
"session_id": page.session_id,
|
||||
"total_events": page.total_events,
|
||||
"matched_events": page.matched_events,
|
||||
"truncated": page.truncated,
|
||||
"kind_counts": page.kind_counts,
|
||||
"first_ts": page.first_ts,
|
||||
"last_ts": page.last_ts,
|
||||
"events": events,
|
||||
}),
|
||||
format!(
|
||||
"会话 {}:共 {} 条事件,返回 {} 条{truncated_note};类型分布 {kind_summary}",
|
||||
page.session_id,
|
||||
page.total_events,
|
||||
events.len(),
|
||||
),
|
||||
)
|
||||
};
|
||||
ToolOutput::ok(content, summary)
|
||||
}
|
||||
|
||||
fn render_event(event: &LogEvent) -> Value {
|
||||
json!({
|
||||
"seq": event.seq,
|
||||
"ts": event.ts,
|
||||
"t_ms": event.t_ms,
|
||||
"kind": event.kind,
|
||||
"payload": shrink(&event.payload),
|
||||
})
|
||||
}
|
||||
|
||||
/// 递归脱敏 + 截断(第二道脱敏防线)。
|
||||
fn shrink(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::String(text) => Value::String(cap(&redact_text(text))),
|
||||
Value::Array(items) => Value::Array(items.iter().map(shrink).collect()),
|
||||
Value::Object(map) => {
|
||||
let mut out = Map::with_capacity(map.len());
|
||||
for (key, item) in map {
|
||||
out.insert(key.clone(), shrink(item));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cap(text: &str) -> String {
|
||||
let length = text.chars().count();
|
||||
if length <= MAX_PAYLOAD_CHARS {
|
||||
return text.to_string();
|
||||
}
|
||||
let head: String = text.chars().take(MAX_PAYLOAD_CHARS).collect();
|
||||
format!("{head}…(已截断 {} 字符)", length - MAX_PAYLOAD_CHARS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockSessions;
|
||||
use serde_json::json;
|
||||
|
||||
const SESSION: &str = "20261029T124501-9f3c2a";
|
||||
|
||||
fn mock() -> MockSessions {
|
||||
let (summary, events) = MockSessions::crashing_session(SESSION);
|
||||
let (older, older_events) = MockSessions::crashing_session("20261028T100000-aaaaaa");
|
||||
let mut sessions = MockSessions::new();
|
||||
sessions.add(summary, events);
|
||||
sessions.add(older, older_events);
|
||||
sessions
|
||||
}
|
||||
|
||||
fn registry() -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
register(&mut registry, Arc::new(mock())).expect("注册应成功");
|
||||
registry
|
||||
}
|
||||
|
||||
/// 走**真实注册表 → 真实 handler**,不复制实现(否则测的是复制品)。
|
||||
fn call(tool: &str, args: Value) -> ToolOutput {
|
||||
let registry = registry();
|
||||
let spec = registry.get(tool).expect("工具应存在").clone();
|
||||
(spec.handler)(
|
||||
&ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
},
|
||||
&args,
|
||||
)
|
||||
.expect("工具应成功")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_sessions_newest_first() {
|
||||
let out = call(LIST_GAME_SESSIONS, json!({ "limit": 5 }));
|
||||
assert_eq!(out.content["count"], json!(2));
|
||||
assert_eq!(out.content["sessions"][0]["session_id"], json!(SESSION));
|
||||
assert_eq!(out.content["problem_count"], json!(2));
|
||||
assert!(out.summary.contains("2 个崩溃/异常退出"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_respects_limit() {
|
||||
let out = call(LIST_GAME_SESSIONS, json!({ "limit": 1 }));
|
||||
assert_eq!(out.content["count"], json!(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_by_kind_and_keeps_summary() {
|
||||
let out = call(
|
||||
READ_GAME_LOG,
|
||||
json!({ "session_id": SESSION, "kinds": ["stderr"] }),
|
||||
);
|
||||
assert_eq!(out.content["events"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(out.content["kind_counts"]["stdout"], json!(1));
|
||||
assert!(out.summary.contains("共 5 条事件"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_when_more_events_than_cap() {
|
||||
let out = call(
|
||||
READ_GAME_LOG,
|
||||
json!({ "session_id": SESSION, "max_events": 2 }),
|
||||
);
|
||||
assert_eq!(out.content["events"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(out.content["truncated"], json!(true));
|
||||
assert_eq!(out.content["matched_events"], json!(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_user_dir_in_payload() {
|
||||
let out = call(
|
||||
READ_GAME_LOG,
|
||||
json!({ "session_id": SESSION, "kinds": ["crash_found"] }),
|
||||
);
|
||||
let payload = &out.content["events"][0]["payload"];
|
||||
assert_eq!(
|
||||
payload["path"],
|
||||
json!(r"C:\Users\***\.minecraft\crash-reports\crash-1.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_empty_result_with_hint() {
|
||||
let out = call(
|
||||
READ_GAME_LOG,
|
||||
json!({ "session_id": SESSION, "kinds": ["window_detected"] }),
|
||||
);
|
||||
assert_eq!(out.content["events"], json!([]));
|
||||
assert!(out.content["hint"].is_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_long_payload_text() {
|
||||
let long = "x".repeat(MAX_PAYLOAD_CHARS + 10);
|
||||
assert!(cap(&long).contains("已截断 10 字符"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_session_is_a_port_error() {
|
||||
let registry = registry();
|
||||
let spec = registry.get(READ_GAME_LOG).expect("工具应存在").clone();
|
||||
let err = (spec.handler)(
|
||||
&ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
},
|
||||
&json!({ "session_id": "nope" }),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "port_error");
|
||||
assert!(err.message().contains("不存在"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! 对话循环集成测试:mock provider 驱动**完整链路**(provider → 工具编排 → 审计 → 事件)。
|
||||
//!
|
||||
//! 这里测"循环的骨架":多轮工具调用、步数上限、流式增量、禁用态、provider 失败。
|
||||
//! 安全边界(白名单/确认/YOLO/审计标记)在 `tools_security.rs`。
|
||||
|
||||
mod support;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use support::{
|
||||
block_on, list_args, tool_call_reply, Harness, MockProvider, AGENT_SESSION, SESSION,
|
||||
};
|
||||
use unlucky_agent::audit::AuditOutcome;
|
||||
use unlucky_agent::brain::{BrainError, ChatMessage, ChatReply, StopReason, ToolCall};
|
||||
use unlucky_agent::event::AgentEvent;
|
||||
|
||||
#[test]
|
||||
fn single_turn_without_tools_returns_text() {
|
||||
let harness = Harness::new(vec![ChatReply::text("你好,需要我看看最近的崩溃吗?")]);
|
||||
let mut history = Vec::new();
|
||||
|
||||
let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap();
|
||||
|
||||
assert_eq!(outcome.text, "你好,需要我看看最近的崩溃吗?");
|
||||
assert_eq!(outcome.steps, 1);
|
||||
assert_eq!(outcome.tool_calls, 0);
|
||||
assert_eq!(outcome.stopped_reason, StopReason::Completed);
|
||||
assert_eq!(history.len(), 2, "应只有 user + assistant");
|
||||
assert!(harness.audit.is_empty(), "没调工具就不该有审计记录");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_prompt_leads_every_request_and_tools_are_projected() {
|
||||
let harness = Harness::new(vec![ChatReply::text("好的")]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "看看配置"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let requests = harness.provider.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let request = &requests[0];
|
||||
assert!(
|
||||
matches!(&request.messages[0], ChatMessage::System { content } if content.contains("Unlucky-Launcher")),
|
||||
"system prompt 必须在首位"
|
||||
);
|
||||
assert_eq!(request.model, "mock-model");
|
||||
let names: Vec<&str> = request.tools.iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"get_config",
|
||||
"set_config",
|
||||
"get_config_schema",
|
||||
"list_instances",
|
||||
"list_versions",
|
||||
"get_java_status",
|
||||
"list_accounts",
|
||||
"list_game_sessions",
|
||||
"read_game_log",
|
||||
"diagnose_crash",
|
||||
],
|
||||
"注册表的双投影载荷必须原样交给 provider"
|
||||
);
|
||||
// 双投影契约:provider 收到的 schema 与注册表定义逐字节相等。
|
||||
assert_eq!(request.tools, harness.registry.definitions());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_round_trip_feeds_result_back_and_finishes() {
|
||||
let harness = Harness::new(vec![
|
||||
tool_call_reply("call_1", "list_game_sessions", list_args()),
|
||||
ChatReply::text("最近一次会话崩溃了,要不要跑诊断?"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
|
||||
let outcome = block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "最近崩了吗"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.tool_calls, 1);
|
||||
assert_eq!(outcome.steps, 2);
|
||||
assert_eq!(outcome.stopped_reason, StopReason::Completed);
|
||||
|
||||
let requests = harness.provider.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let tool_message = requests[1]
|
||||
.messages
|
||||
.iter()
|
||||
.find_map(|m| match m {
|
||||
ChatMessage::Tool {
|
||||
call_id,
|
||||
name,
|
||||
content,
|
||||
} => Some((call_id.clone(), name.clone(), content.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.expect("必须有工具结果消息");
|
||||
assert_eq!(tool_message.0, "call_1");
|
||||
assert_eq!(tool_message.1, "list_game_sessions");
|
||||
assert!(
|
||||
tool_message.2.contains(SESSION),
|
||||
"工具结果应包含会话 id:{}",
|
||||
tool_message.2
|
||||
);
|
||||
|
||||
let audits = harness.audit.records();
|
||||
assert_eq!(audits.len(), 1);
|
||||
assert_eq!(audits[0].tool, "list_game_sessions");
|
||||
assert_eq!(audits[0].outcome, AuditOutcome::Ok);
|
||||
assert!(!audits[0].yolo);
|
||||
assert!(!audits[0].confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_tool_calls_in_one_step_all_execute() {
|
||||
let reply = ChatReply::with_tool_calls(vec![
|
||||
ToolCall {
|
||||
id: "c1".to_string(),
|
||||
name: "list_game_sessions".to_string(),
|
||||
arguments: list_args(),
|
||||
},
|
||||
ToolCall {
|
||||
id: "c2".to_string(),
|
||||
name: "diagnose_crash".to_string(),
|
||||
arguments: json!({ "session_id": SESSION }),
|
||||
},
|
||||
]);
|
||||
let harness = Harness::new(vec![reply, ChatReply::text("诊断完成")]);
|
||||
let mut history = Vec::new();
|
||||
|
||||
let outcome = block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "查一下"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.tool_calls, 2);
|
||||
assert_eq!(harness.audit.len(), 2);
|
||||
assert_eq!(
|
||||
harness
|
||||
.audit
|
||||
.records()
|
||||
.iter()
|
||||
.map(|r| r.tool.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["list_game_sessions", "diagnose_crash"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_limit_stops_runaway_loop() {
|
||||
// 每一步都要求调工具,永远不会给最终回答。
|
||||
let script: Vec<ChatReply> = (0..16)
|
||||
.map(|i| tool_call_reply(&format!("c{i}"), "get_config", json!({})))
|
||||
.collect();
|
||||
let harness = Harness::new(script);
|
||||
let mut history = Vec::new();
|
||||
|
||||
let outcome = block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "死循环"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.stopped_reason, StopReason::StepLimit);
|
||||
assert_eq!(outcome.steps, unlucky_agent::brain::DEFAULT_MAX_STEPS);
|
||||
assert_eq!(outcome.tool_calls, unlucky_agent::brain::DEFAULT_MAX_STEPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_deltas_are_forwarded_as_events() {
|
||||
let harness = Harness::new(vec![ChatReply::text("日志里是 NoClassDefFoundError")]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "什么错误"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
harness.events.streamed_text(),
|
||||
"日志里是 NoClassDefFoundError"
|
||||
);
|
||||
let events = harness.events.events();
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEvent::TurnStarted { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEvent::AssistantText { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEvent::TurnFinished { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_provider_fails_fast() {
|
||||
let harness = Harness::from_provider(Arc::new(MockProvider::disabled()));
|
||||
let mut history = Vec::new();
|
||||
|
||||
let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err();
|
||||
assert_eq!(err, BrainError::Disabled);
|
||||
assert!(err.to_string().contains("规则引擎"), "要提示降级能力");
|
||||
assert!(
|
||||
harness.provider.requests().is_empty(),
|
||||
"禁用态不该发起任何请求"
|
||||
);
|
||||
assert!(history.is_empty(), "禁用态不该改动历史");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_error_surfaces_as_brain_error_and_event() {
|
||||
let harness = Harness::from_provider(Arc::new(MockProvider::failing(
|
||||
"连接 http://127.0.0.1:8080 失败",
|
||||
)));
|
||||
let mut history = Vec::new();
|
||||
|
||||
let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
BrainError::Provider(unlucky_agent::brain::ProviderError::new(
|
||||
"连接 http://127.0.0.1:8080 失败"
|
||||
))
|
||||
);
|
||||
assert!(harness
|
||||
.events
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEvent::Error { message } if message.contains("8080"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_script_yields_empty_completion_turn() {
|
||||
let harness = Harness::new(vec![]);
|
||||
let mut history = Vec::new();
|
||||
let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap();
|
||||
assert_eq!(outcome.text, "");
|
||||
assert_eq!(outcome.stopped_reason, StopReason::Completed);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! **双投影契约测试(MCP 侧)**:注册表 → rmcp `Tool` 必须与 rig 侧逐项相等。
|
||||
//!
|
||||
//! research/02 §6.2 建议进 CI 的那条断言,M16 落的是「注册表自身是纯投影」+「rig 适配层
|
||||
//! 只做直投」,M39 补上 rmcp 侧:
|
||||
//!
|
||||
//! 1. `mcp::projection::tool_attrs(®istry)` 的 `(name, description, input_schema)`
|
||||
//! 与 `registry.definitions()` **逐项相等**(JSON 层面,不是"看起来差不多");
|
||||
//! 2. `tools/list` 的顺序与注册顺序一致,数量等于注册表长度;
|
||||
//! 3. 每个 schema 根是 `object`(rig 与 rmcp 的共同前提);
|
||||
//! 4. `read_only_hint` 与 `Access` 一致(外部客户端据此决定能不能静默调用);
|
||||
//! 5. 只读子集不会漏出写工具(`agent.mcp.allow_write=false` 的默认形态)。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_agent::mcp::projection::{tool_attr, tool_attrs};
|
||||
use unlucky_agent::port::Ports;
|
||||
use unlucky_agent::registry::{Access, ToolDefinition, ToolRegistry};
|
||||
use unlucky_agent::testing::mock_ports;
|
||||
use unlucky_agent::tools::build_v1_registry;
|
||||
|
||||
fn registry() -> ToolRegistry {
|
||||
let (ports, _settings) = mock_ports();
|
||||
build_v1_registry(&ports).expect("注册表应装配成功")
|
||||
}
|
||||
|
||||
fn ports() -> Ports {
|
||||
mock_ports().0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_projection_equals_registry_definition_item_by_item() {
|
||||
let registry = registry();
|
||||
let definitions = registry.definitions();
|
||||
let attrs = tool_attrs(®istry);
|
||||
assert_eq!(attrs.len(), definitions.len());
|
||||
|
||||
for (attr, definition) in attrs.iter().zip(definitions.iter()) {
|
||||
assert_eq!(attr.name.as_ref(), definition.name, "工具名必须相等");
|
||||
assert_eq!(
|
||||
attr.description.as_deref(),
|
||||
Some(definition.description.as_str()),
|
||||
"{} 的 description 必须逐字相等",
|
||||
definition.name
|
||||
);
|
||||
assert_eq!(
|
||||
Value::Object((*attr.input_schema).clone()),
|
||||
definition.input_schema,
|
||||
"{} 的 JSON Schema 必须逐项相等",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_order_matches_registration_order() {
|
||||
let registry = registry();
|
||||
let projected: Vec<String> = tool_attrs(®istry)
|
||||
.iter()
|
||||
.map(|attr| attr.name.to_string())
|
||||
.collect();
|
||||
assert_eq!(projected, registry.names(), "两侧顺序必须一致");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_projected_schema_is_an_object_and_round_trips() {
|
||||
for attr in tool_attrs(®istry()) {
|
||||
let schema = Value::Object((*attr.input_schema).clone());
|
||||
assert_eq!(
|
||||
schema["type"],
|
||||
json!("object"),
|
||||
"{} 的 schema 根必须是 object",
|
||||
attr.name
|
||||
);
|
||||
// rmcp 会把 Tool 序列化进 tools/list 的响应体:必须能反序列化回来。
|
||||
let json = serde_json::to_string(&attr).expect("Tool 可序列化");
|
||||
let back: rmcp::model::Tool = serde_json::from_str(&json).expect("Tool 可反序列化");
|
||||
assert_eq!(back, attr);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_hint_tracks_access() {
|
||||
let registry = registry();
|
||||
for (attr, spec) in tool_attrs(®istry).iter().zip(registry.specs()) {
|
||||
let hint = attr
|
||||
.annotations
|
||||
.as_ref()
|
||||
.and_then(|annotations| annotations.read_only_hint)
|
||||
.expect("每个工具都应有 read_only_hint");
|
||||
assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_subset_projects_without_the_writing_tool() {
|
||||
let subset = registry().read_only_subset();
|
||||
assert_eq!(subset.len(), 9);
|
||||
let names: Vec<String> = tool_attrs(&subset)
|
||||
.iter()
|
||||
.map(|attr| attr.name.to_string())
|
||||
.collect();
|
||||
assert!(!names.iter().any(|name| name == "set_config"), "{names:?}");
|
||||
assert!(subset.access_of("set_config").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_attrs_are_pure_functions_of_the_definition() {
|
||||
let definition = ToolDefinition {
|
||||
name: "demo".to_string(),
|
||||
description: "演示".to_string(),
|
||||
input_schema: json!({ "type": "object", "properties": {}, "required": [] }),
|
||||
};
|
||||
let first = tool_attr(&definition, Access::ReadOnly);
|
||||
let second = tool_attr(&definition, Access::ReadOnly);
|
||||
assert_eq!(first, second);
|
||||
|
||||
// 副作用等级换一下,只有 annotations 变,name/description/schema 不变。
|
||||
let mutating = tool_attr(&definition, Access::Mutating);
|
||||
assert_eq!(first.name, mutating.name);
|
||||
assert_eq!(first.input_schema, mutating.input_schema);
|
||||
assert_ne!(first.annotations, mutating.annotations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_ports_cover_every_port_the_registry_needs() {
|
||||
// 装配完整性:五个端口都在,注册表才有这十个工具(少一个端口 = 少工具)。
|
||||
let ports = ports();
|
||||
let registry = build_v1_registry(&ports).expect("注册表应装配成功");
|
||||
assert_eq!(registry.len(), 10);
|
||||
assert!(unlucky_agent::testing::expected_v1_tool_names()
|
||||
.iter()
|
||||
.all(|name| registry.get(name).is_some()));
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! MCP over Streamable HTTP 的端到端测试:真起监听、打裸 JSON-RPC,断言工具返回与审计。
|
||||
//!
|
||||
//! 为什么不用 MCP 客户端 crate:客户端依赖会再拖一棵 reqwest 树(且 3.8 的客户端 feature
|
||||
//! 与我们的服务端 feature 不是一组)。裸 HTTP 反而更贴近「用户用 Claude Code 连上来」
|
||||
//! 的实际形状——握手、会话头、`tools/list`、`tools/call` 一个都不省。
|
||||
//!
|
||||
//! 覆盖的安全语义(02 §3.1 / §4b):
|
||||
//! - 只读模式下 `set_config` 根本不在工具表里,硬调 → tool-level 错误 + `unknown_tool` 审计;
|
||||
//! - 普通键(`download.concurrency`,Low)直接写入,审计 `outcome=ok`;
|
||||
//! - 敏感键(`java.selected_path`,High)在**没有弹窗**的外部场景被拒,文案引导去设置页/YOLO;
|
||||
//! - 打开 YOLO 后同一个写入成功,审计 `yolo=true`。
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use unlucky_agent::audit::{AuditOutcome, AuditRecord, JsonlAudit};
|
||||
use unlucky_agent::brain::exec::ToolExecutor;
|
||||
use unlucky_agent::clock::{FixedClock, SharedClock};
|
||||
use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate};
|
||||
use unlucky_agent::event::NoopSink;
|
||||
use unlucky_agent::mcp::{bind, serve_http_on, McpConfig, RegistryServer};
|
||||
use unlucky_agent::testing::mock_ports;
|
||||
use unlucky_agent::tools::build_v1_registry;
|
||||
|
||||
const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ull-test","version":"0.1"}}}"#;
|
||||
|
||||
struct Harness {
|
||||
addr: SocketAddr,
|
||||
audit_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
fn audit_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ull-mcp-http-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn server(read_only: bool, yolo: bool, audit: Arc<JsonlAudit>) -> RegistryServer {
|
||||
let (ports, _settings) = mock_ports();
|
||||
let registry = Arc::new(build_v1_registry(&ports).expect("注册表应装配成功"));
|
||||
let exposed = if read_only {
|
||||
Arc::new(registry.read_only_subset())
|
||||
} else {
|
||||
Arc::clone(®istry)
|
||||
};
|
||||
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
|
||||
let executor = Arc::new(
|
||||
ToolExecutor::new(
|
||||
exposed,
|
||||
audit,
|
||||
Arc::new(NoUiGate),
|
||||
ConfirmPolicy { yolo },
|
||||
clock,
|
||||
Arc::new(NoopSink),
|
||||
)
|
||||
.with_guidance(DenyGuidance::External),
|
||||
);
|
||||
RegistryServer::new(registry, executor, "mcp-http", read_only)
|
||||
}
|
||||
|
||||
async fn spawn(tag: &str, read_only: bool, yolo: bool) -> Harness {
|
||||
let dir = audit_dir(tag);
|
||||
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
|
||||
let audit = Arc::new(JsonlAudit::new(&dir, clock));
|
||||
let server = server(read_only, yolo, audit);
|
||||
|
||||
let cfg = McpConfig {
|
||||
port: 0,
|
||||
..McpConfig::default()
|
||||
};
|
||||
let (addr, listener) = bind(&cfg).await.expect("应能绑定临时端口");
|
||||
tokio::spawn(async move {
|
||||
let _ = serve_http_on(server, cfg, addr, listener).await;
|
||||
});
|
||||
Harness {
|
||||
addr,
|
||||
audit_dir: dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// 发一条 HTTP POST(`Connection: close` 让读取变成 read_to_end)。
|
||||
async fn post(
|
||||
addr: SocketAddr,
|
||||
body: &str,
|
||||
session: Option<&str>,
|
||||
token: Option<&str>,
|
||||
) -> (u16, Option<String>, String) {
|
||||
let mut stream = TcpStream::connect(addr).await.expect("连接失败");
|
||||
let mut head = format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\
|
||||
Accept: application/json, text/event-stream\r\nConnection: close\r\n\
|
||||
Content-Length: {}\r\n",
|
||||
body.len()
|
||||
);
|
||||
if let Some(session) = session {
|
||||
head.push_str(&format!("Mcp-Session-Id: {session}\r\n"));
|
||||
}
|
||||
if let Some(token) = token {
|
||||
head.push_str(&format!("Authorization: Bearer {token}\r\n"));
|
||||
}
|
||||
head.push_str("\r\n");
|
||||
stream
|
||||
.write_all(format!("{head}{body}").as_bytes())
|
||||
.await
|
||||
.expect("写请求失败");
|
||||
stream.flush().await.expect("刷新失败");
|
||||
|
||||
let mut raw = Vec::new();
|
||||
stream.read_to_end(&mut raw).await.expect("读响应失败");
|
||||
let text = String::from_utf8_lossy(&raw).to_string();
|
||||
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((text.as_str(), ""));
|
||||
let status = head
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.and_then(|code| code.parse::<u16>().ok())
|
||||
.unwrap_or(0);
|
||||
let session = head.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("mcp-session-id")
|
||||
.then(|| value.trim().to_string())
|
||||
});
|
||||
(status, session, body.to_string())
|
||||
}
|
||||
|
||||
/// 响应体可能是裸 JSON,也可能是 SSE(`data: {...}`);两种都解出第一条 JSON-RPC 响应。
|
||||
fn parse_rpc(body: &str) -> Value {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(body.trim()) {
|
||||
return value;
|
||||
}
|
||||
for line in body.lines() {
|
||||
let Some(data) = line.strip_prefix("data: ") else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(value) = serde_json::from_str::<Value>(data.trim()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
panic!("响应体既不是 JSON 也不是 SSE:{body}");
|
||||
}
|
||||
|
||||
async fn initialize(addr: SocketAddr) -> Option<String> {
|
||||
let (status, session, body) = post(addr, INITIALIZE, None, None).await;
|
||||
assert_eq!(status, 200, "initialize 应成功:{body}");
|
||||
let response = parse_rpc(&body);
|
||||
assert_eq!(
|
||||
response["result"]["serverInfo"]["name"],
|
||||
json!("unlucky-launcher")
|
||||
);
|
||||
// 按 MCP 规范补一条 initialized 通知(无 id、无响应体)。
|
||||
let _ = post(
|
||||
addr,
|
||||
r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
|
||||
session.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
}
|
||||
|
||||
async fn rpc(
|
||||
addr: SocketAddr,
|
||||
session: Option<&str>,
|
||||
id: u32,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Value {
|
||||
let body =
|
||||
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
|
||||
let (status, _session, text) = post(addr, &body, session, None).await;
|
||||
assert_eq!(status, 200, "{method} 应成功:{text}");
|
||||
parse_rpc(&text)
|
||||
}
|
||||
|
||||
fn audit_records(dir: &std::path::Path) -> Vec<AuditRecord> {
|
||||
let mut out = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let text = std::fs::read_to_string(entry.path()).unwrap_or_default();
|
||||
for line in text.lines() {
|
||||
if let Ok(record) = serde_json::from_str::<AuditRecord>(line) {
|
||||
out.push(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn readonly_server_lists_and_serves_only_read_tools() {
|
||||
let harness = spawn("readonly", true, false).await;
|
||||
let session = initialize(harness.addr).await;
|
||||
|
||||
let list = rpc(harness.addr, session.as_deref(), 2, "tools/list", json!({})).await;
|
||||
let tools = list["result"]["tools"].as_array().expect("应有 tools 数组");
|
||||
let names: Vec<&str> = tools
|
||||
.iter()
|
||||
.filter_map(|tool| tool["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(names.len(), 9, "{names:?}");
|
||||
assert!(!names.contains(&"set_config"), "只读模式不得暴露写工具");
|
||||
assert!(names.contains(&"get_config"));
|
||||
|
||||
// 真调一个只读工具:返回真实配置概要(mock 端口)。
|
||||
let call = rpc(
|
||||
harness.addr,
|
||||
session.as_deref(),
|
||||
3,
|
||||
"tools/call",
|
||||
json!({ "name": "get_config", "arguments": {} }),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(call["result"]["isError"], json!(true), "{call}");
|
||||
let text = call["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
assert!(text.contains("download.concurrency"), "{text}");
|
||||
|
||||
// 硬调写工具:tool-level 错误(客户端能看见文案)+ unknown_tool 审计。
|
||||
let denied = rpc(
|
||||
harness.addr,
|
||||
session.as_deref(),
|
||||
4,
|
||||
"tools/call",
|
||||
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
|
||||
|
||||
let records = audit_records(&harness.audit_dir);
|
||||
assert!(records.iter().any(|r| r.tool == "get_config"
|
||||
&& r.outcome == AuditOutcome::Ok
|
||||
&& r.agent_session_id == "mcp-http"));
|
||||
assert!(records
|
||||
.iter()
|
||||
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::UnknownTool));
|
||||
let _ = std::fs::remove_dir_all(&harness.audit_dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn write_server_enforces_sensitive_key_confirmation() {
|
||||
let harness = spawn("write", false, false).await;
|
||||
let session = initialize(harness.addr).await;
|
||||
|
||||
// 普通键:直接写。
|
||||
let ok = rpc(
|
||||
harness.addr,
|
||||
session.as_deref(),
|
||||
2,
|
||||
"tools/call",
|
||||
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(ok["result"]["isError"], json!(true), "{ok}");
|
||||
|
||||
// 敏感键:外部客户端没有弹窗 → 拒绝,文案要能指导用户下一步。
|
||||
let denied = rpc(
|
||||
harness.addr,
|
||||
session.as_deref(),
|
||||
3,
|
||||
"tools/call",
|
||||
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
|
||||
let text = denied["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
text.contains("YOLO"),
|
||||
"拒绝文案应引导到 YOLO/设置页:{text}"
|
||||
);
|
||||
assert!(text.contains("设置页"), "{text}");
|
||||
|
||||
let records = audit_records(&harness.audit_dir);
|
||||
assert!(records
|
||||
.iter()
|
||||
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::AwaitingConfirmation));
|
||||
assert!(records
|
||||
.iter()
|
||||
.any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Denied && !r.yolo));
|
||||
let _ = std::fs::remove_dir_all(&harness.audit_dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn yolo_lets_the_same_write_through_and_audits_the_flag() {
|
||||
let harness = spawn("yolo", false, true).await;
|
||||
let session = initialize(harness.addr).await;
|
||||
|
||||
let written = rpc(
|
||||
harness.addr,
|
||||
session.as_deref(),
|
||||
2,
|
||||
"tools/call",
|
||||
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(written["result"]["isError"], json!(true), "{written}");
|
||||
|
||||
let records = audit_records(&harness.audit_dir);
|
||||
let yolo_ok = records
|
||||
.iter()
|
||||
.find(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Ok);
|
||||
let record = yolo_ok.expect("应有一条 ok 审计");
|
||||
assert!(record.yolo, "YOLO 放行必须标记 yolo=true");
|
||||
assert!(!record.confirmed, "YOLO 自动放行不算用户确认");
|
||||
let _ = std::fs::remove_dir_all(&harness.audit_dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bearer_token_is_enforced_when_configured() {
|
||||
let dir = audit_dir("token");
|
||||
let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123));
|
||||
let audit = Arc::new(JsonlAudit::new(&dir, clock));
|
||||
let server = server(true, false, audit);
|
||||
let cfg = McpConfig {
|
||||
port: 0,
|
||||
token: Some("s3cret".to_string()),
|
||||
..McpConfig::default()
|
||||
};
|
||||
let (addr, listener) = bind(&cfg).await.expect("应能绑定");
|
||||
tokio::spawn(async move {
|
||||
let _ = serve_http_on(server, cfg, addr, listener).await;
|
||||
});
|
||||
|
||||
let (status, _, _) = post(addr, INITIALIZE, None, None).await;
|
||||
assert_eq!(status, 401, "没带令牌必须 401");
|
||||
|
||||
let (status, _, body) = post(addr, INITIALIZE, None, Some("s3cret")).await;
|
||||
assert_eq!(status, 200, "带对令牌应通过:{body}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! **双投影契约测试**(research/02 §6.2 建议进 CI 的那一条)。
|
||||
//!
|
||||
//! rig 侧(`DynamicTool::new`)与 rmcp 侧(`ToolRoute::new_dyn`)吃的都是
|
||||
//! `(name, description, serde_json::Value schema)`。本 crate 只维护一份 `ToolSpec`,
|
||||
//! 所以「两侧相等」在这里等价于三条可断言的性质:
|
||||
//! 1. [`unlucky_agent::registry::ToolRegistry::definitions`] 是纯投影(多次调用逐字节相等);
|
||||
//! 2. 每个 schema 根是 `type: object`(两侧都要求对象入参)且拒绝多余参数;
|
||||
//! 3. 名字唯一,`required` 真的被执行(缺参能被校验器抓到)。
|
||||
//!
|
||||
//! 本文件只锁**投影载荷本身**;两侧的一致性各有专测:
|
||||
//! - rig 侧:`tests/brain_loop.rs` 断言 `provider` 收到的 `request.tools` 与
|
||||
//! `registry.definitions()` 逐字节相等(`brain/rig.rs` 只做直投);
|
||||
//! - MCP 侧:`tests/mcp_contract.rs` 断言 rmcp `Tool` 的 `(name, description, schema)` 与
|
||||
//! 注册表逐项相等(M39 补的那条)。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_agent::registry::validate::validate;
|
||||
use unlucky_agent::registry::{Access, ToolDefinition};
|
||||
use unlucky_agent::testing::mock_ports;
|
||||
|
||||
fn registry() -> unlucky_agent::registry::ToolRegistry {
|
||||
let (ports, _settings) = mock_ports();
|
||||
unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ships_exactly_the_v1_tools() {
|
||||
let registry = registry();
|
||||
assert_eq!(
|
||||
registry.names(),
|
||||
vec![
|
||||
"get_config",
|
||||
"set_config",
|
||||
"get_config_schema",
|
||||
"list_instances",
|
||||
"list_versions",
|
||||
"get_java_status",
|
||||
"list_accounts",
|
||||
"list_game_sessions",
|
||||
"read_game_log",
|
||||
"diagnose_crash",
|
||||
]
|
||||
);
|
||||
assert_eq!(registry.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definitions_are_pure_projections() {
|
||||
let registry = registry();
|
||||
let first = registry.definitions();
|
||||
let second = registry.definitions();
|
||||
assert_eq!(first, second, "投影必须是纯函数");
|
||||
|
||||
for definition in &first {
|
||||
// 序列化后再反序列化必须完全一样(rig 与 rmcp 都走 JSON)。
|
||||
let json = serde_json::to_string(definition).expect("可序列化");
|
||||
let round_trip: ToolDefinition = serde_json::from_str(&json).expect("可反序列化");
|
||||
assert_eq!(&round_trip, definition);
|
||||
assert!(!definition.description.trim().is_empty());
|
||||
assert_eq!(
|
||||
definition.input_schema["type"],
|
||||
json!("object"),
|
||||
"{} 的 schema 根必须是 object",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_set_config_is_mutating() {
|
||||
let registry = registry();
|
||||
for name in registry.names() {
|
||||
let expected = if name == "set_config" {
|
||||
Access::Mutating
|
||||
} else {
|
||||
Access::ReadOnly
|
||||
};
|
||||
assert_eq!(registry.access_of(name), Some(expected), "{name}");
|
||||
}
|
||||
let read_only: Vec<String> = registry
|
||||
.read_only_definitions()
|
||||
.into_iter()
|
||||
.map(|d| d.name)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
read_only,
|
||||
vec![
|
||||
"get_config",
|
||||
"get_config_schema",
|
||||
"list_instances",
|
||||
"list_versions",
|
||||
"get_java_status",
|
||||
"list_accounts",
|
||||
"list_game_sessions",
|
||||
"read_game_log",
|
||||
"diagnose_crash",
|
||||
],
|
||||
"只读 MCP 模式只暴露这九个(写侧只有 set_config)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_schema_rejects_unknown_arguments() {
|
||||
for definition in registry().definitions() {
|
||||
let err = validate(&definition.input_schema, &json!({ "__unexpected": 1 }))
|
||||
.expect_err(&format!("{} 应拒绝野参数", definition.name));
|
||||
assert!(
|
||||
err.iter().any(|e| e.contains("不是可接受的参数名")),
|
||||
"{}: {err:?}",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_fields_are_actually_enforced() {
|
||||
for definition in registry().definitions() {
|
||||
let required = definition
|
||||
.input_schema
|
||||
.get("required")
|
||||
.and_then(|r| r.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if required.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let err = validate(&definition.input_schema, &json!({}))
|
||||
.expect_err(&format!("{} 应有必填参数", definition.name));
|
||||
for field in required {
|
||||
let name = field.as_str().unwrap_or_default();
|
||||
assert!(
|
||||
err.iter().any(|e| e.contains(name)),
|
||||
"{} 的必填 `{name}` 没被校验:{err:?}",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_bounds_are_declared_and_enforced() {
|
||||
let registry = registry();
|
||||
let read_log = registry.get("read_game_log").expect("工具存在");
|
||||
let max_events = &read_log.input_schema["properties"]["max_events"];
|
||||
assert_eq!(max_events["minimum"], json!(1));
|
||||
assert_eq!(max_events["maximum"], json!(500));
|
||||
|
||||
assert!(validate(
|
||||
&read_log.input_schema,
|
||||
&json!({ "session_id": "s", "max_events": 0 })
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate(
|
||||
&read_log.input_schema,
|
||||
&json!({ "session_id": "s", "max_events": 501 })
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate(
|
||||
&read_log.input_schema,
|
||||
&json!({ "session_id": "s", "max_events": 500 })
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! 集成测试的公共脚手架:**mock provider** + 迷你 `block_on` + 装配好的 harness。
|
||||
//!
|
||||
//! `block_on` 是手写的 20 行:mock provider 的 future 永远立刻 Ready,所以不需要运行时。
|
||||
//! 这样 agent 的循环测试**零 tokio 依赖**,阶段 B 换成 rig 后才需要真运行时。
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_agent::audit::{AuditSink, MemoryAudit};
|
||||
use unlucky_agent::brain::{Brain, BrainConfig, BrainDeps};
|
||||
use unlucky_agent::brain::{
|
||||
ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderConfig, ProviderError,
|
||||
ProviderFuture, ToolCall,
|
||||
};
|
||||
use unlucky_agent::clock::{FixedClock, SharedClock};
|
||||
use unlucky_agent::confirm::{AutoApproveGate, ConfirmGate, ConfirmPolicy, NoUiGate};
|
||||
use unlucky_agent::event::{AgentEventSink, MemoryEventSink};
|
||||
use unlucky_agent::registry::ToolRegistry;
|
||||
use unlucky_agent::testing::{mock_ports, MockSettings};
|
||||
|
||||
/// 立刻完成的 future 直接 poll;挂起就是测试写错了。
|
||||
pub fn block_on<F: Future>(future: F) -> F::Output {
|
||||
let mut future: Pin<Box<F>> = Box::pin(future);
|
||||
let waker = Waker::noop();
|
||||
let mut context = Context::from_waker(waker);
|
||||
match future.as_mut().poll(&mut context) {
|
||||
Poll::Ready(value) => value,
|
||||
Poll::Pending => panic!("mock provider 不应挂起:测试里没有执行器"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 脚本化 provider:按顺序回放 [`ChatReply`],并记录收到的请求。
|
||||
pub struct MockProvider {
|
||||
config: ProviderConfig,
|
||||
script: Mutex<VecDeque<ChatReply>>,
|
||||
requests: Mutex<Vec<ChatRequest>>,
|
||||
fail_with: Option<String>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
/// 用默认模型名(本地 llama.cpp 形态)回放脚本。
|
||||
#[must_use]
|
||||
pub fn new(script: Vec<ChatReply>) -> Self {
|
||||
Self {
|
||||
config: ProviderConfig::llamacpp_local("mock-model"),
|
||||
script: Mutex::new(script.into()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
fail_with: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 禁用态(测 `BrainError::Disabled`)。
|
||||
#[must_use]
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
config: ProviderConfig::disabled(),
|
||||
script: Mutex::new(VecDeque::new()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
fail_with: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 每次补全都失败(测 provider 错误透传)。
|
||||
#[must_use]
|
||||
pub fn failing(message: &str) -> Self {
|
||||
Self {
|
||||
fail_with: Some(message.to_string()),
|
||||
..Self::new(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 收到的请求(断言 system prompt / 工具定义 / 回灌的工具消息)。
|
||||
#[must_use]
|
||||
pub fn requests(&self) -> Vec<ChatRequest> {
|
||||
self.requests.lock().map(|r| r.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatProvider for MockProvider {
|
||||
fn config(&self) -> &ProviderConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
fn complete<'a>(
|
||||
&'a self,
|
||||
request: &'a ChatRequest,
|
||||
observer: &'a dyn DeltaObserver,
|
||||
) -> ProviderFuture<'a> {
|
||||
Box::pin(async move {
|
||||
if let Ok(mut requests) = self.requests.lock() {
|
||||
requests.push(request.clone());
|
||||
}
|
||||
if let Some(message) = &self.fail_with {
|
||||
return Err(ProviderError::new(message.clone()));
|
||||
}
|
||||
let reply = self
|
||||
.script
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut script| script.pop_front())
|
||||
.unwrap_or_default();
|
||||
// 按 4 字符切块吐增量,模拟流式。
|
||||
if !reply.text.is_empty() {
|
||||
for chunk in reply.text.chars().collect::<Vec<_>>().chunks(4) {
|
||||
observer.on_delta(&chunk.iter().collect::<String>());
|
||||
}
|
||||
}
|
||||
Ok(reply)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 装配好的测试环境。
|
||||
pub struct Harness {
|
||||
/// 对话循环。
|
||||
pub brain: Brain,
|
||||
/// 审计出口(内存)。
|
||||
pub audit: Arc<MemoryAudit>,
|
||||
/// 事件出口(内存)。
|
||||
pub events: Arc<MemoryEventSink>,
|
||||
/// mock provider(查请求)。
|
||||
pub provider: Arc<MockProvider>,
|
||||
/// mock 配置端口(查是否真的写入)。
|
||||
pub settings: Arc<MockSettings>,
|
||||
/// 工具注册表。
|
||||
pub registry: Arc<ToolRegistry>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
/// P0 默认形态:无确认 UI(`NoUiGate`,敏感操作 fail-closed)+ 非 YOLO。
|
||||
/// 确认请求本身仍会作为 [`unlucky_agent::event::AgentEvent::ConfirmRequested`] 发出。
|
||||
#[must_use]
|
||||
pub fn new(script: Vec<ChatReply>) -> Self {
|
||||
Self::with_gate(script, ConfirmPolicy::default(), |_sink| Arc::new(NoUiGate))
|
||||
}
|
||||
|
||||
/// YOLO:自动放行全部(除破坏性)。
|
||||
#[must_use]
|
||||
pub fn yolo(script: Vec<ChatReply>) -> Self {
|
||||
Self::with_gate(script, ConfirmPolicy { yolo: true }, |_sink| {
|
||||
Arc::new(AutoApproveGate)
|
||||
})
|
||||
}
|
||||
|
||||
/// 用指定的(可能失效/禁用的)provider 装配,其余同 [`Harness::new`]。
|
||||
#[must_use]
|
||||
pub fn from_provider(provider: Arc<MockProvider>) -> Self {
|
||||
Self::build(provider, ConfirmPolicy::default(), |_sink| {
|
||||
Arc::new(NoUiGate)
|
||||
})
|
||||
}
|
||||
|
||||
/// 自定义闸门 + 策略。
|
||||
#[must_use]
|
||||
pub fn with_gate(
|
||||
script: Vec<ChatReply>,
|
||||
policy: ConfirmPolicy,
|
||||
make_gate: impl FnOnce(Arc<dyn AgentEventSink>) -> Arc<dyn ConfirmGate>,
|
||||
) -> Self {
|
||||
Self::build(Arc::new(MockProvider::new(script)), policy, make_gate)
|
||||
}
|
||||
|
||||
fn build(
|
||||
provider: Arc<MockProvider>,
|
||||
policy: ConfirmPolicy,
|
||||
make_gate: impl FnOnce(Arc<dyn AgentEventSink>) -> Arc<dyn ConfirmGate>,
|
||||
) -> Self {
|
||||
let (ports, settings) = mock_ports();
|
||||
let registry =
|
||||
Arc::new(unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功"));
|
||||
let audit = Arc::new(MemoryAudit::new());
|
||||
let events = Arc::new(MemoryEventSink::new());
|
||||
let clock: SharedClock = Arc::new(FixedClock::with_step(1_793_314_353_123, 3));
|
||||
let gate = make_gate(events.clone());
|
||||
let deps = BrainDeps::new(
|
||||
audit.clone() as Arc<dyn AuditSink>,
|
||||
gate,
|
||||
policy,
|
||||
clock,
|
||||
events.clone() as Arc<dyn AgentEventSink>,
|
||||
);
|
||||
let brain = Brain::new(
|
||||
provider.clone(),
|
||||
Arc::clone(®istry),
|
||||
deps,
|
||||
BrainConfig::default(),
|
||||
);
|
||||
Self {
|
||||
brain,
|
||||
audit,
|
||||
events,
|
||||
provider,
|
||||
settings,
|
||||
registry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一条工具调用回复。
|
||||
#[must_use]
|
||||
pub fn tool_call_reply(id: &str, name: &str, arguments: Value) -> ChatReply {
|
||||
ChatReply::with_tool_calls(vec![ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments,
|
||||
}])
|
||||
}
|
||||
|
||||
/// 便捷:游戏会话 id(与 `MockSessions::crashing_session` 一致)。
|
||||
pub const SESSION: &str = "20261029T124501-9f3c2a";
|
||||
|
||||
/// 便捷:agent 对话会话 id。
|
||||
pub const AGENT_SESSION: &str = "chat-1";
|
||||
|
||||
/// 便捷:`list_game_sessions` 的最小参数。
|
||||
#[must_use]
|
||||
pub fn list_args() -> Value {
|
||||
json!({ "limit": 5 })
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! 安全边界集成测试:白名单拦截、分级确认、YOLO、审计标记。
|
||||
//!
|
||||
//! 这些是 02 文档 §3.1「安全边界」+ §4b「YOLO」与 M12 `setting-接口约定.md` §8
|
||||
//! (`Low/Medium/High` 三档 + `writable=false` 凭据)的可执行版本;
|
||||
//! 判断口径对照 research/02 §7 R9(写操作的破坏半径)。
|
||||
|
||||
mod support;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use support::{block_on, tool_call_reply, Harness, AGENT_SESSION};
|
||||
use unlucky_agent::audit::AuditOutcome;
|
||||
use unlucky_agent::brain::ChatReply;
|
||||
use unlucky_agent::confirm::{ConfirmDecision, ConfirmPolicy, ScriptedGate};
|
||||
use unlucky_agent::event::AgentEvent;
|
||||
|
||||
fn set_config_reply(path: &str, value: Value) -> ChatReply {
|
||||
tool_call_reply("c1", "set_config", json!({ "path": path, "value": value }))
|
||||
}
|
||||
|
||||
fn tool_results(harness: &Harness) -> Vec<String> {
|
||||
harness
|
||||
.provider
|
||||
.requests()
|
||||
.last()
|
||||
.map(|request| {
|
||||
request
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|m| match m {
|
||||
unlucky_agent::brain::ChatMessage::Tool { content, .. } => {
|
||||
Some(content.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn confirm_events(harness: &Harness) -> usize {
|
||||
harness
|
||||
.events
|
||||
.events()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, AgentEvent::ConfirmRequested { .. }))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_key_writes_silently_without_confirmation() {
|
||||
let harness = Harness::new(vec![
|
||||
set_config_reply("appearance.theme_mode", json!("metro_light")),
|
||||
ChatReply::text("已切换主题"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换成浅色"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 1);
|
||||
assert_eq!(
|
||||
harness.settings.value_of("appearance.theme_mode"),
|
||||
Some(json!("metro_light"))
|
||||
);
|
||||
assert_eq!(confirm_events(&harness), 0, "Low 键不该打扰用户");
|
||||
|
||||
let records = harness.audit.records_for("set_config");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].outcome, AuditOutcome::Ok);
|
||||
assert!(!records[0].confirmed, "静默生效不是「用户确认」");
|
||||
assert!(!records[0].yolo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_key_also_writes_silently() {
|
||||
// M12:Medium = 静默生效但审计标记(不弹窗)
|
||||
let harness = Harness::new(vec![
|
||||
set_config_reply("download.region", json!("cn")),
|
||||
ChatReply::text("已切到国内源"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换下载源"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 1);
|
||||
assert_eq!(confirm_events(&harness), 0);
|
||||
assert_eq!(
|
||||
harness.audit.records_for("set_config")[0].outcome,
|
||||
AuditOutcome::Ok
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_key_is_fail_closed_without_ui() {
|
||||
let harness = Harness::new(vec![
|
||||
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
|
||||
ChatReply::text("需要你确认"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
harness.settings.set_calls(),
|
||||
0,
|
||||
"P0 无 UI 时必须 fail-closed"
|
||||
);
|
||||
assert_eq!(harness.settings.value_of("java.selected_path"), None);
|
||||
assert_eq!(confirm_events(&harness), 1, "确认请求必须作为事件发出");
|
||||
|
||||
let outcomes: Vec<AuditOutcome> = harness
|
||||
.audit
|
||||
.records_for("set_config")
|
||||
.iter()
|
||||
.map(|r| r.outcome)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
vec![AuditOutcome::AwaitingConfirmation, AuditOutcome::Denied],
|
||||
"先记「AI 想改」,再记「被拒绝」"
|
||||
);
|
||||
assert!(tool_results(&harness)
|
||||
.iter()
|
||||
.any(|content| content.contains("denied")));
|
||||
assert!(harness
|
||||
.events
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEvent::ToolDenied { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_approval_writes_and_marks_confirmed() {
|
||||
let harness = Harness::with_gate(
|
||||
vec![
|
||||
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
|
||||
ChatReply::text("已更新"),
|
||||
],
|
||||
ConfirmPolicy::default(),
|
||||
|_sink| {
|
||||
Arc::new(ScriptedGate::new(
|
||||
[ConfirmDecision::Approved],
|
||||
ConfirmDecision::Denied,
|
||||
))
|
||||
},
|
||||
);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 1, "确认后只写一次");
|
||||
let records = harness.audit.records_for("set_config");
|
||||
let last = records.last().expect("必须有审计记录");
|
||||
assert_eq!(last.outcome, AuditOutcome::Ok);
|
||||
assert!(last.confirmed, "用户点了允许");
|
||||
assert!(!last.yolo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yolo_skips_confirm_but_audits_flag() {
|
||||
let harness = Harness::yolo(vec![
|
||||
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
|
||||
ChatReply::text("已更新"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 1);
|
||||
assert_eq!(confirm_events(&harness), 0, "YOLO 不打扰用户");
|
||||
let records = harness.audit.records_for("set_config");
|
||||
assert_eq!(records.len(), 1, "YOLO 只留结局记录");
|
||||
assert!(records[0].yolo, "审计必须标记 yolo");
|
||||
assert!(!records[0].confirmed, "自动放行不是用户确认");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitelist_violation_is_rejected_and_audited() {
|
||||
let harness = Harness::new(vec![
|
||||
set_config_reply("java.home.mystery", json!("x")),
|
||||
ChatReply::text("换个方案"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "改个不存在的键"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 0);
|
||||
let records = harness.audit.records_for("set_config");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
|
||||
let results = tool_results(&harness);
|
||||
assert!(
|
||||
results.iter().any(|c| c.contains("白名单")),
|
||||
"模型需要知道为什么被拒:{results:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_args_are_rejected_before_any_side_effect() {
|
||||
// 缺 value(schema required)
|
||||
let harness = Harness::new(vec![
|
||||
tool_call_reply(
|
||||
"c1",
|
||||
"set_config",
|
||||
json!({ "path": "appearance.theme_mode" }),
|
||||
),
|
||||
ChatReply::text("参数补上"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "改主题"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 0);
|
||||
let records = harness.audit.records_for("set_config");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
|
||||
assert!(tool_results(&harness)
|
||||
.iter()
|
||||
.any(|c| c.contains("缺少必填参数")));
|
||||
|
||||
// 多余参数(additionalProperties: false)
|
||||
let harness = Harness::new(vec![
|
||||
tool_call_reply(
|
||||
"c2",
|
||||
"read_game_log",
|
||||
json!({ "session_id": "x", "path": "evil" }),
|
||||
),
|
||||
ChatReply::text("好"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "读日志"),
|
||||
)
|
||||
.unwrap();
|
||||
let records = harness.audit.records_for("read_game_log");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tool_lists_available_names() {
|
||||
let harness = Harness::new(vec![
|
||||
tool_call_reply("c1", "delete_everything", json!({})),
|
||||
ChatReply::text("抱歉"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "删库跑路"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let records = harness.audit.records_for("delete_everything");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::UnknownTool);
|
||||
let results = tool_results(&harness);
|
||||
assert!(
|
||||
results.iter().any(|c| c.contains("get_config")),
|
||||
"{results:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_credential_key_is_refused_even_under_yolo() {
|
||||
// M12:ai.api_key 是 writable=false(不在白名单)→ YOLO 也不能放宽可写范围
|
||||
let harness = Harness::yolo(vec![
|
||||
set_config_reply("ai.api_key", json!("sk-super-secret-value")),
|
||||
ChatReply::text("换不了"),
|
||||
]);
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "换 key"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(harness.settings.set_calls(), 0);
|
||||
let records = harness.audit.records_for("set_config");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
|
||||
|
||||
let args = serde_json::to_string(&records[0].args).unwrap();
|
||||
assert!(
|
||||
!args.contains("sk-super-secret-value"),
|
||||
"审计绝不能落明文密钥:{args}"
|
||||
);
|
||||
assert!(args.contains("***"), "{args}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_config_refuses_credential_keys() {
|
||||
// F1:模型可自由给 path,凭据键必须读不到 —— 否则值会进模型上下文发往远端 LLM。
|
||||
let harness = Harness::new(vec![
|
||||
tool_call_reply("c1", "get_config", json!({ "path": "ai.api_key" })),
|
||||
ChatReply::text("读不到"),
|
||||
]);
|
||||
// 模拟 M12 真实实现:凭据键在文件里确实有值(writable=false,不在白名单也要能防住读)
|
||||
harness
|
||||
.settings
|
||||
.seed_value("ai.api_key", json!("sk-live-credential-value"));
|
||||
let mut history = Vec::new();
|
||||
block_on(
|
||||
harness
|
||||
.brain
|
||||
.run_turn(AGENT_SESSION, &mut history, "看看我的 key"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 1) 工具报错并被审计
|
||||
let records = harness.audit.records_for("get_config");
|
||||
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
|
||||
// 2) 模型拿到的是拒绝文案,不是值
|
||||
let results = tool_results(&harness);
|
||||
assert!(
|
||||
results.iter().any(|c| c.contains("凭据")),
|
||||
"应给出可读拒绝:{results:?}"
|
||||
);
|
||||
assert!(
|
||||
!results
|
||||
.iter()
|
||||
.any(|c| c.contains("sk-live-credential-value")),
|
||||
"工具结果绝不能带凭据:{results:?}"
|
||||
);
|
||||
// 3) 事件流与对话历史里也不能出现(P1 事件会落盘 / 历史会持久化)
|
||||
let leaked_in_events = harness
|
||||
.events
|
||||
.events()
|
||||
.iter()
|
||||
.any(|e| format!("{e:?}").contains("sk-live-credential-value"));
|
||||
assert!(!leaked_in_events, "事件流不能带凭据明文");
|
||||
let leaked_in_history = history
|
||||
.iter()
|
||||
.any(|m| format!("{m:?}").contains("sk-live-credential-value"));
|
||||
assert!(!leaked_in_history, "对话历史不能带凭据明文");
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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(¤t);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())), "从未启动");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "网络超时");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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(¬ice).expect("序列化");
|
||||
let back: NoticeView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, notice);
|
||||
assert_eq!(back.level.label(), "注意");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(®ion_path, now).unwrap_or_else(|| {
|
||||
let profile = decide_from(&detect_signals(), now);
|
||||
let _ = store_cached(®ion_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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(¤t);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, "依赖库要计入文件总数");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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("静默"));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user