Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef0922073 | ||
|
|
4c1a6c54e5 | ||
|
|
15caa1b61f | ||
|
|
0bfe22aa6f | ||
|
|
a6013c7d22 | ||
|
|
37ccaaf23f | ||
|
|
dc4fac8391 | ||
|
|
38832df095 | ||
|
|
6ce825db8d | ||
|
|
eaa1414847 | ||
|
|
62019a29cd | ||
|
|
ea6016716e | ||
|
|
92d8a705e8 | ||
|
|
d95c9664c6 | ||
|
|
3d9372029e | ||
|
|
b0f747074f | ||
|
|
858c227900 | ||
|
|
76d83148ff | ||
|
|
f9079f8ab0 | ||
|
|
07fd6113a7 | ||
|
|
8194e38bcf | ||
|
|
f62bb00494 | ||
|
|
daadc9170f | ||
|
|
50b321ec64 | ||
|
|
7b2ff62ddc | ||
|
|
ecded9bef0 | ||
|
|
359a5a4325 | ||
|
|
ddc0577f83 |
No files matched your search
@@ -0,0 +1,355 @@
|
||||
name: Release
|
||||
|
||||
# 发布流水线:tag `v*` → 四平台(六槽位)矩阵构建 → 单文件产物 + SHA256SUMS
|
||||
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
|
||||
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
|
||||
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
|
||||
#
|
||||
# 契约唯一真相源:packaging/README.md(命名/目录/运行时依赖)。
|
||||
# 打包本体在 packaging/make_artifacts.py(本地与 CI 同一份脚本,逐字节同布局)。
|
||||
# 手动触发(workflow_dispatch)默认只跑构建不建 Release —— 用来验证矩阵仍然编得过。
|
||||
# 注意 `inputs.prerelease` 只在 **tag ref** 上 dispatch 才可达:Actions 页面的
|
||||
# "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。
|
||||
# 分支 ref 的 dispatch 与 push 事件一样被 release job 的 `if: github.ref_type == 'tag'`
|
||||
# 挡在发布之外,所以「dispatch 只构建不发布」与「dispatch 可关 pre-release」并不矛盾。
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
prerelease:
|
||||
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build (${{ matrix.slot }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
|
||||
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
|
||||
# 产物根 target/release;自检与打包都从 matrix.build_dir 取路径(不硬编码)。
|
||||
- slot: windows-x64
|
||||
os: windows-latest
|
||||
build_dir: target/release
|
||||
rustflags: -C target-feature=+crt-static
|
||||
# Windows arm64:在 windows-latest(x64 宿主)上**交叉编译**到 aarch64-pc-windows-msvc。
|
||||
# 为什么不用 windows-11-arm 原生 runner(GitHub 文档 2026 已把 `windows-11-arm` 与
|
||||
# `windows-11-vs2026-arm` 列为公开仓库**免费**的标准 arm64 runner,原生路线可行):
|
||||
# ① 双 Windows 槽位共用同一套已验证镜像/工具链,tag 重跑行为与既有槽位一致;
|
||||
# ② M50 的 dumpbin 定位链取的是 `Hostx64/x64/dumpbin.exe`,dumpbin 读 ARM64 PE 的
|
||||
# 导入表没问题,而原生 ARM 宿主上那条链得再长一条 `Hostarm64/arm64` 分支——为一个
|
||||
# 槽位放宽刚立的门禁不值得;
|
||||
# ③ 交叉编译产物路径变成 target/<triple>/release,已用 matrix.build_dir 参数化。
|
||||
# 若将来改成原生:只需把 os 换成 windows-11-arm、删掉 target/rustflags 依赖并给 dumpbin
|
||||
# 定位链补 Hostarm64 分支(届时再评估,不在这里预先放宽)。
|
||||
- slot: windows-arm64
|
||||
os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
build_dir: target/aarch64-pc-windows-msvc/release
|
||||
rustflags: -C target-feature=+crt-static
|
||||
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
|
||||
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 的 glibc 明显更高)。
|
||||
- slot: linux-x64
|
||||
os: ubuntu-22.04
|
||||
build_dir: target/release
|
||||
# Linux arm64:公开仓库免费的原生 ARM64 runner(原生构建,不做交叉)。
|
||||
# 用 `ubuntu-22.04-arm` 而**不是** 24.04:与 x64 同 glibc 地板(2.35),
|
||||
# 覆盖 Debian 12 / Ubuntu 22.04 arm64 / Raspberry Pi OS Bookworm(2.36)这类实际在跑的
|
||||
# ARM 发行版;换 24.04 会把地板抬高、把上面前两个挡在门外。该家族标签有
|
||||
# `ubuntu-22.04-arm` / `ubuntu-24.04-arm` / `ubuntu-26.04-arm`,均 4C/16GB、
|
||||
# 公开仓库免费不限量(GitHub 文档 Standard runners for public repositories)。
|
||||
- slot: linux-arm64
|
||||
os: ubuntu-22.04-arm
|
||||
build_dir: target/release
|
||||
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
|
||||
- slot: macos-arm64
|
||||
os: macos-latest
|
||||
build_dir: target/release
|
||||
- slot: macos-x64
|
||||
os: macos-15-intel
|
||||
build_dir: target/release
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
|
||||
libwayland-dev libxcb1-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
# 只有交叉编译槽位需要额外 target(原生槽位留空 → 本步整体跳过)。
|
||||
# 不把 `${{ }}` 直接拼进 bash:与后面的打包步骤同一纪律,一律经 env 传入再展开。
|
||||
- name: 追加交叉编译目标 ${{ matrix.target }}
|
||||
if: matrix.target != ''
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: rustup target add "$TARGET"
|
||||
|
||||
# 交叉链接的前提是镜像里真有 ARM64 的 MSVC 库(VC `/lib/arm64/libcmt.lib`,+crt-static
|
||||
# 就靠它)与 Windows SDK 的 ARM64 导入库(`um/arm64/kernel32.lib`)。缺了照样是失败,
|
||||
# 但先给一句能照做的话,而不是让链接器吐一屏乱码才发现方向:门禁不降级,只把失败说清楚。
|
||||
- name: Windows ARM64 交叉工具链体检
|
||||
if: matrix.target == 'aarch64-pc-windows-msvc'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
roots=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
|
||||
vc_lib=$(find "${roots[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
|
||||
sdk_version=$(ls -d "C:/Program Files (x86)/Windows Kits/10/Lib"/* 2>/dev/null | tail -n1 || true)
|
||||
sdk_lib=""
|
||||
if [ -n "$sdk_version" ]; then
|
||||
sdk_lib=$(find "$sdk_version/um/arm64" -maxdepth 1 -name 'kernel32.lib' 2>/dev/null | head -n1 || true)
|
||||
fi
|
||||
echo "MSVC ARM64 静态 CRT: ${vc_lib:-<缺失>}"
|
||||
echo "SDK ARM64 导入库: ${sdk_lib:-<缺失>}"
|
||||
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
|
||||
echo "::error::交叉链接缺 ARM64 库:给 VS 加上 Microsoft.VisualStudio.Component.VC.Tools.ARM64(含 ARM64 Windows SDK),或把该槽位改用原生 windows-11-arm runner"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: cargo build --release --locked
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
target_args=()
|
||||
if [ -n "$TARGET" ]; then
|
||||
target_args+=(--target "$TARGET")
|
||||
fi
|
||||
cargo build --release --locked "${target_args[@]}" --bin unlucky-launcher --bin ull
|
||||
|
||||
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
# 跨槽位的关键点:产物在 target/<triple>/release 而不是 target/release。
|
||||
EXE: ${{ matrix.build_dir }}/unlucky-launcher.exe
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
|
||||
# 可再发行包,那就不是"单文件"了,直接 fail。
|
||||
#
|
||||
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位):
|
||||
# ① vswhere:VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/安装布局无关;
|
||||
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
|
||||
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
|
||||
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
|
||||
# Hostx64 的 dumpbin 读 ARM64 PE 的导入表没问题,所以 arm64 槽位共用同一条链。
|
||||
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
DUMPBIN=""
|
||||
VIA=""
|
||||
if [ -x "$VSWHERE" ]; then
|
||||
# -find 的模式优先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
|
||||
for pattern in 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' \
|
||||
'VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe'; do
|
||||
hit=$("$VSWHERE" -latest -products '*' -find "$pattern" 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then
|
||||
DUMPBIN=${hit//\\//}
|
||||
VIA="vswhere"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
# 镜像里的 VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),
|
||||
# 固定层数的 glob 会在 <年>/<版次> 这两级踩空,所以按路径模式扫。
|
||||
for root in "C:/Program Files/Microsoft Visual Studio" \
|
||||
"C:/Program Files (x86)/Microsoft Visual Studio"; do
|
||||
[ -d "$root" ] || continue
|
||||
hit=$(find "$root" -maxdepth 11 -type f \
|
||||
-path '*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe' 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then
|
||||
DUMPBIN=$hit
|
||||
VIA="find: $root"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
hit=$(command -v dumpbin || true)
|
||||
if [ -n "$hit" ]; then
|
||||
DUMPBIN=$hit
|
||||
VIA="PATH"
|
||||
fi
|
||||
fi
|
||||
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
|
||||
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
|
||||
exit 1
|
||||
fi
|
||||
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
|
||||
ls -l "$EXE"
|
||||
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
|
||||
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
|
||||
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 打包(产物 + SHA256SUMS)
|
||||
shell: bash
|
||||
env:
|
||||
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
SLOT: ${{ matrix.slot }}
|
||||
BUILD_DIR: ${{ matrix.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
|
||||
PY=$(command -v python3 || command -v python)
|
||||
# 槽位与产物根都从矩阵来:交叉槽位的二进制不在 target/release,脚本据此定位
|
||||
# 并回读可执行文件头校验架构(宿主 x64 ≠ 产物 arm64 时冒烟自动跳过,只等 CI 真机)。
|
||||
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
|
||||
if [ "$REF_TYPE" = "tag" ]; then
|
||||
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
|
||||
fi
|
||||
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
|
||||
ls -l dist
|
||||
|
||||
- name: 归档内容自检(二进制定位 + 合规文本)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for archive in dist/*.tar.gz; do
|
||||
[ -e "$archive" ] || continue
|
||||
listing=$(tar -tzf "$archive")
|
||||
for required in unlucky-launcher LICENSE NOTICE README.md; do
|
||||
if ! grep -q "/$required$" <<<"$listing"; then
|
||||
echo "::error file=$archive::$archive 缺少 $required"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
|
||||
done
|
||||
if ls dist/*.exe >/dev/null 2>&1; then
|
||||
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ matrix.slot }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: build
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: 矩阵完整性 + 重算全量 SHA256SUMS
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ls -l dist
|
||||
# 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。
|
||||
for slot in windows-x64 windows-arm64 linux-x64 linux-arm64 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
|
||||
@@ -5,6 +5,10 @@
|
||||
# Local development docs
|
||||
/DevDocs/
|
||||
|
||||
# Local acceptance screenshots (screenshot channel output, never committed)
|
||||
/.screens/
|
||||
/.tmp-screens/
|
||||
|
||||
# Agent workspace (local-only)
|
||||
/.tower/
|
||||
|
||||
|
||||
Generated
+2
@@ -6032,6 +6032,8 @@ dependencies = [
|
||||
name = "unlucky-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"slint",
|
||||
"slint-build",
|
||||
"unlucky-agent",
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ all = { level = "deny", priority = -1 }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = "thin"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
**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.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](README.md) · **简体中文**
|
||||
|
||||
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher
|
||||
|
||||
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
|
||||
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ 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
|
||||
@@ -25,5 +30,7 @@ unlucky-agent = { path = "../agent", version = "0.1" }
|
||||
[build-dependencies]
|
||||
slint-build.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -180,10 +180,8 @@ fn wire_instances(ui: &crate::MainWindow, weak: &Weak<Inner>) {
|
||||
let weak = weak.clone();
|
||||
ui.on_new_instance(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state
|
||||
.borrow_mut()
|
||||
.toasts
|
||||
.push(ToastKind::Info, "新建实例向导(G5)在 P1 落地");
|
||||
// G5:打开向导(版本 → 加载器 → 名字 → 确认 → 安装进度)
|
||||
this.state.borrow_mut().wizard = crate::wizard::WizardState::open();
|
||||
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;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ pub fn phase(runtime: &InstanceRuntime) -> (RuntimePhase, f32, String) {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -8,12 +8,17 @@
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
//! 版本列表与下载分类映射(下载页)。
|
||||
|
||||
use crate::dto::{AncientKind, VersionKind as DtoVersionKind};
|
||||
use crate::dto::{AncientKind, VersionCategory, VersionKind as DtoVersionKind, VersionSummary};
|
||||
use crate::state::AppState;
|
||||
use crate::{UiVersion, VersionKind};
|
||||
|
||||
/// 下载页版本行(已按「仅已装」+ 版本号筛选词过滤)。
|
||||
pub fn versions(state: &AppState) -> Vec<UiVersion> {
|
||||
let query = state.download_query.trim().to_lowercase();
|
||||
state
|
||||
.versions
|
||||
/// 分类侧栏 → 该分类下的版本(远古版按子类细分)。
|
||||
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()
|
||||
.filter(|version| !state.installed_only || version.installed)
|
||||
.filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query))
|
||||
.map(|version| UiVersion {
|
||||
id: version.id.clone().into(),
|
||||
kind: version_kind(version.kind),
|
||||
@@ -22,6 +30,20 @@ pub fn versions(state: &AppState) -> Vec<UiVersion> {
|
||||
.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,
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
//! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。
|
||||
//! 非阻塞提示走 [`crate::toast`],不占模态队列。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 对话框标识。
|
||||
pub type DialogId = u64;
|
||||
|
||||
/// 对话框来源:决定插队优先级。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum DialogSource {
|
||||
/// 提示类(教程、首次使用说明)
|
||||
Hint = 0,
|
||||
@@ -26,7 +28,7 @@ impl DialogSource {
|
||||
}
|
||||
|
||||
/// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DialogKind {
|
||||
/// 确认:主按钮 + 次按钮
|
||||
Confirm {
|
||||
@@ -87,7 +89,7 @@ impl DialogKind {
|
||||
}
|
||||
|
||||
/// 一条对话框请求。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DialogRequest {
|
||||
/// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号)
|
||||
pub id: DialogId,
|
||||
|
||||
@@ -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, "网络超时");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 会话消息角色。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ChatRole {
|
||||
/// 用户
|
||||
User,
|
||||
@@ -14,7 +16,7 @@ pub enum ChatRole {
|
||||
}
|
||||
|
||||
/// 工具调用可视化条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolCallView {
|
||||
/// 工具名(与 ToolSpec 注册表同名)
|
||||
pub name: String,
|
||||
@@ -25,7 +27,7 @@ pub struct ToolCallView {
|
||||
}
|
||||
|
||||
/// 一条会话消息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// 角色
|
||||
pub role: ChatRole,
|
||||
@@ -60,7 +62,7 @@ impl ChatMessage {
|
||||
}
|
||||
|
||||
/// agent 侧事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentEvent {
|
||||
/// 追加一条完整消息
|
||||
MessageAppended(ChatMessage),
|
||||
|
||||
+162
-11
@@ -1,12 +1,46 @@
|
||||
//! 事件与指令: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::{AgentEvent, DiagnosisReport, InstanceId, InstanceRuntime, InstanceSummary};
|
||||
use crate::dto::{LogLine, LogSession, SessionId, VersionCategory, VersionSummary};
|
||||
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)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CoreEvent {
|
||||
/// 实例列表整体刷新
|
||||
InstancesChanged(Vec<InstanceSummary>),
|
||||
@@ -21,15 +55,31 @@ pub enum CoreEvent {
|
||||
VersionListChanged(Vec<VersionSummary>),
|
||||
/// 日志会话列表刷新
|
||||
LogSessionsChanged(Vec<LogSession>),
|
||||
/// 当前会话日志行刷新(P0 整批替换;P1 改增量追加)
|
||||
/// 当前会话日志行刷新(整批替换)
|
||||
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)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AppEvent {
|
||||
/// 来自 core
|
||||
Core(CoreEvent),
|
||||
@@ -44,22 +94,58 @@ pub enum AppEvent {
|
||||
}
|
||||
|
||||
/// app → core/agent 的用户意图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AppCommand {
|
||||
/// 重新扫描实例目录
|
||||
RefreshInstances,
|
||||
/// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
|
||||
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 流
|
||||
@@ -68,7 +154,17 @@ pub enum AppCommand {
|
||||
SetYolo(bool),
|
||||
}
|
||||
|
||||
/// 后端通道:core/agent host 实现它;P0 用 [`crate::mock::MockBackend`] 验证通道语义。
|
||||
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 模型。
|
||||
@@ -79,8 +175,63 @@ pub trait Backend {
|
||||
/// 下发一条用户意图。
|
||||
fn dispatch(&mut self, command: AppCommand);
|
||||
|
||||
/// 时间推进(mock 用脚本推进进度;真实后端可以是空实现)。
|
||||
/// 时间推进(真实后端在这里收后台线程的结果并尾随日志)。
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
|
||||
pub type InstanceId = String;
|
||||
|
||||
/// 加载器种类(磁贴徽标 + 详情页版本链)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LoaderKind {
|
||||
/// 原版(无加载器)
|
||||
Vanilla,
|
||||
@@ -37,10 +39,48 @@ impl LoaderKind {
|
||||
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)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoaderInfo {
|
||||
/// 加载器种类
|
||||
pub kind: LoaderKind,
|
||||
@@ -48,6 +88,15 @@ pub struct LoaderInfo {
|
||||
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 {
|
||||
@@ -60,7 +109,7 @@ impl LoaderInfo {
|
||||
}
|
||||
|
||||
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct Progress {
|
||||
/// 已完成量
|
||||
pub done: u64,
|
||||
@@ -85,7 +134,7 @@ impl Progress {
|
||||
}
|
||||
|
||||
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LaunchStage {
|
||||
/// 下载资源
|
||||
Download,
|
||||
@@ -93,6 +142,8 @@ pub enum LaunchStage {
|
||||
Verify,
|
||||
/// 组装命令行并拉起进程
|
||||
Launch,
|
||||
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
|
||||
Window,
|
||||
}
|
||||
|
||||
impl LaunchStage {
|
||||
@@ -101,13 +152,14 @@ impl LaunchStage {
|
||||
match self {
|
||||
Self::Download => "下载资源",
|
||||
Self::Verify => "校验文件",
|
||||
Self::Launch => "启动中",
|
||||
Self::Launch => "组装命令行",
|
||||
Self::Window => "等待窗口",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例运行时状态(磁贴的四种外观)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum InstanceRuntime {
|
||||
/// 空闲
|
||||
#[default]
|
||||
@@ -132,7 +184,7 @@ pub enum InstanceRuntime {
|
||||
}
|
||||
|
||||
/// 实例摘要:磁贴/列表视图唯一数据源。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InstanceSummary {
|
||||
/// 稳定 id
|
||||
pub id: InstanceId,
|
||||
@@ -148,4 +200,97 @@ pub struct InstanceSummary {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
//! 日志页 DTO:会话列表、日志行与 D8 诊断结论。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一次启动会话(日志页左栏单位)标识。
|
||||
pub type SessionId = String;
|
||||
|
||||
/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LogOutcome {
|
||||
/// 正常退出
|
||||
Ok,
|
||||
@@ -15,7 +17,7 @@ pub enum LogOutcome {
|
||||
}
|
||||
|
||||
/// 会话摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LogSession {
|
||||
/// 会话 id
|
||||
pub id: SessionId,
|
||||
@@ -30,7 +32,7 @@ pub struct LogSession {
|
||||
}
|
||||
|
||||
/// 日志行等级(错误行高亮)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LogLevel {
|
||||
/// debug
|
||||
Debug,
|
||||
@@ -43,7 +45,7 @@ pub enum LogLevel {
|
||||
}
|
||||
|
||||
/// 一行日志。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LogLine {
|
||||
/// 原始文本(含时间戳前缀)
|
||||
pub text: String,
|
||||
@@ -52,7 +54,7 @@ pub struct LogLine {
|
||||
}
|
||||
|
||||
/// D8 规则引擎结论(日志页崩溃诊断卡片)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Diagnosis {
|
||||
/// 命中规则 id(`gpu.driver_old`)
|
||||
pub rule: String,
|
||||
@@ -67,7 +69,7 @@ pub struct Diagnosis {
|
||||
}
|
||||
|
||||
/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DiagnosisReport {
|
||||
/// 归属会话
|
||||
pub session: SessionId,
|
||||
|
||||
@@ -2,22 +2,38 @@
|
||||
//!
|
||||
//! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*`
|
||||
//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型,
|
||||
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架。
|
||||
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架,
|
||||
//! P3 起由 [`crate::host`] 的真实后端驱动全部页面。
|
||||
//!
|
||||
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / events),
|
||||
//! 全量 `pub use` 出去,调用方依旧写 `crate::dto::InstanceSummary` 这类路径。
|
||||
//! 结构:**按领域拆子模块**(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};
|
||||
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
|
||||
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
|
||||
pub use instances::{
|
||||
InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 版本类型(远古版按 unlisted-versions 的分类拆开)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VersionKind {
|
||||
/// 正式版
|
||||
Release,
|
||||
@@ -12,7 +14,7 @@ pub enum VersionKind {
|
||||
}
|
||||
|
||||
/// 远古版子类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AncientKind {
|
||||
/// classic(rd-*)
|
||||
Classic,
|
||||
@@ -32,7 +34,7 @@ impl VersionKind {
|
||||
}
|
||||
|
||||
/// 下载页版本行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VersionSummary {
|
||||
/// 版本 id(如 `1.20.4` / `rd-132211`)
|
||||
pub id: String,
|
||||
@@ -45,7 +47,7 @@ pub struct VersionSummary {
|
||||
}
|
||||
|
||||
/// 下载页左侧分类(页内常驻侧栏的选中项)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VersionCategory {
|
||||
/// 正式版
|
||||
Release,
|
||||
|
||||
@@ -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())));
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! 真实后端:把 core(game/launch/install/auth/java/diagnose/setting)装配成 GUI 的 host。
|
||||
//!
|
||||
//! 边界:本模块是整个 app 里**唯一**允许 `use unlucky_core::…` 的地方(连同 `settings_bridge`);
|
||||
//! 出口只有 [`crate::dto`] 的快照/事件。线程模型:
|
||||
//!
|
||||
//! ```text
|
||||
//! dispatch(AppCommand) ──► 立即动作(读盘/写设置)或 spawn 后台线程
|
||||
//! 后台线程 ──HostTask──► rx ──tick()──► CoreEvent ──► state.apply()
|
||||
//! tick() 另外每 ~600ms 尾随一次运行中会话的 JSONL(实时日志滚动)
|
||||
//! ```
|
||||
//!
|
||||
//! AI 对话暂时仍由 [`MockBackend`] 提供脚本回复(agent 的 GUI host 是后续 mission);
|
||||
//! 其余页面全部走真实 core。
|
||||
|
||||
mod accounts;
|
||||
mod dispatch;
|
||||
mod engine;
|
||||
pub mod env;
|
||||
mod game;
|
||||
mod handle;
|
||||
mod identity;
|
||||
mod install;
|
||||
mod install_journal;
|
||||
mod install_processors;
|
||||
mod java;
|
||||
mod job;
|
||||
mod launch;
|
||||
mod logs;
|
||||
mod meter;
|
||||
mod opener;
|
||||
mod settings;
|
||||
mod versions;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::dto::{
|
||||
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
|
||||
InstanceRuntime, JavaChainStep, NoticeView,
|
||||
};
|
||||
use crate::mock::MockBackend;
|
||||
use crate::toast::{Toast, ToastKind};
|
||||
|
||||
use accounts::LoginJob;
|
||||
pub use env::HostEnv;
|
||||
use job::{HostTask, JobReceiver, JobSender};
|
||||
|
||||
/// 每拍最多处理的后台消息数(防止一次 tick 吃掉整帧预算)。
|
||||
const MAX_TASKS_PER_TICK: usize = 64;
|
||||
|
||||
/// 实时日志尾随间隔(拍数,33ms/拍 → 约 600ms 一次)。
|
||||
const TAIL_POLL_TICKS: u64 = 18;
|
||||
|
||||
/// 一次在跑的启动(并发启动时按实例 id 区分,停止/尾随都不会串到别的实例)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct LiveLaunch {
|
||||
instance: String,
|
||||
session: String,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
/// 真实 host。
|
||||
pub struct CoreHost {
|
||||
env: HostEnv,
|
||||
events: VecDeque<AppEvent>,
|
||||
tx: JobSender,
|
||||
rx: JobReceiver,
|
||||
ticks: u64,
|
||||
/// 当前启动账户 id(内存态;跨会话持久化要 core schema 增键,见 handoff)
|
||||
active_account: Option<String>,
|
||||
accounts: Vec<AccountView>,
|
||||
login: Option<LoginJob>,
|
||||
java_chain: Vec<JavaChainStep>,
|
||||
java_busy: bool,
|
||||
java_message: String,
|
||||
install: Option<InstallProgressView>,
|
||||
notices: Vec<NoticeView>,
|
||||
/// 在跑的启动(实例 → 会话 → pid;支持多个实例同时启动,停止按 id 找正确的 pid)
|
||||
launches: Vec<LiveLaunch>,
|
||||
/// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程)
|
||||
pending: BTreeSet<String>,
|
||||
/// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观)
|
||||
failed: BTreeMap<String, String>,
|
||||
/// 实时日志尾随器
|
||||
tail: logs::Tail,
|
||||
/// AI 对话的临时后端(agent GUI host 落地前的占位)
|
||||
chat: MockBackend,
|
||||
/// 当前安装任务的取消标记(阶段边界检查)
|
||||
install_cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
impl CoreHost {
|
||||
/// 按便携数据根装配;解析不出数据根返回 `None`(调用方退回 mock)。
|
||||
pub fn open() -> Option<Self> {
|
||||
HostEnv::resolve().map(Self::new)
|
||||
}
|
||||
|
||||
/// 用给定环境装配(截图/单测通道)。
|
||||
pub fn new(env: HostEnv) -> Self {
|
||||
Self::build(env, true)
|
||||
}
|
||||
|
||||
/// 单测用:不跑 `refresh_all`(不起版本清单网络线程),其余装配一致。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn headless(env: HostEnv) -> Self {
|
||||
Self::build(env, false)
|
||||
}
|
||||
|
||||
fn build(env: HostEnv, refresh: bool) -> Self {
|
||||
let (tx, rx) = job::channel();
|
||||
game::ensure_root(&env);
|
||||
let mut host = Self {
|
||||
env,
|
||||
events: VecDeque::new(),
|
||||
tx,
|
||||
rx,
|
||||
ticks: 0,
|
||||
active_account: None,
|
||||
accounts: Vec::new(),
|
||||
login: None,
|
||||
java_chain: Vec::new(),
|
||||
java_busy: false,
|
||||
java_message: String::new(),
|
||||
install: None,
|
||||
notices: Vec::new(),
|
||||
launches: Vec::new(),
|
||||
pending: BTreeSet::new(),
|
||||
failed: BTreeMap::new(),
|
||||
tail: logs::Tail::default(),
|
||||
chat: MockBackend::default(),
|
||||
install_cancel: None,
|
||||
};
|
||||
if refresh {
|
||||
host.refresh_all();
|
||||
}
|
||||
host
|
||||
}
|
||||
|
||||
/// 数据根(截图/诊断展示用)。
|
||||
pub fn env(&self) -> &HostEnv {
|
||||
&self.env
|
||||
}
|
||||
|
||||
fn send(&mut self, event: CoreEvent) {
|
||||
self.events.push_back(AppEvent::Core(event));
|
||||
}
|
||||
|
||||
fn toast(&mut self, kind: ToastKind, text: impl Into<String>) {
|
||||
self.events.push_back(AppEvent::Toast(Toast {
|
||||
id: 0,
|
||||
kind,
|
||||
text: text.into(),
|
||||
remaining_ms: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/// 全量刷新:实例 / 会话 / 账户 / 设置 / Java,并触发一次版本清单拉取。
|
||||
pub fn refresh_all(&mut self) {
|
||||
self.refresh_instances();
|
||||
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
|
||||
self.refresh_accounts();
|
||||
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
|
||||
self.refresh_java();
|
||||
versions::spawn_list(self.env.clone(), self.tx.clone());
|
||||
}
|
||||
|
||||
/// 重建实例快照,并把**真实运行时**盖回去。
|
||||
///
|
||||
/// `game::instances` 只读盘(每条都是 `Idle`),所以在跑的实例必须在重建后按
|
||||
/// host 自己的启动表覆写 `Running{pid}`/`Busy`,否则任意一次刷新(装版本成功、
|
||||
/// 加删账户、另一实例收尾)都会把磁贴打回 Idle:停止键与「运行中」条消失,
|
||||
/// 进程还在跑却再也停不下来。
|
||||
fn refresh_instances(&mut self) {
|
||||
let mut instances = game::instances(&self.env);
|
||||
for instance in &mut instances {
|
||||
if let Some(live) = self
|
||||
.launches
|
||||
.iter()
|
||||
.find(|live| live.instance == instance.id)
|
||||
{
|
||||
instance.runtime = InstanceRuntime::Running { pid: live.pid };
|
||||
} else if self.pending.contains(&instance.id) {
|
||||
instance.runtime = InstanceRuntime::Busy {
|
||||
stage: crate::dto::LaunchStage::Launch,
|
||||
progress: crate::dto::Progress { done: 0, total: 1 },
|
||||
};
|
||||
} else if let Some(reason) = self.failed.get(&instance.id) {
|
||||
instance.runtime = InstanceRuntime::Failed {
|
||||
reason: reason.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
self.send(CoreEvent::InstancesChanged(instances));
|
||||
}
|
||||
|
||||
fn refresh_accounts(&mut self) {
|
||||
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
|
||||
if self.active_account.is_none() {
|
||||
// 默认选中第一个账户(没有账户时保持 None = 离线占位)
|
||||
self.active_account = self.accounts.first().map(|account| account.id.clone());
|
||||
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
|
||||
}
|
||||
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
|
||||
}
|
||||
|
||||
fn refresh_java(&mut self) {
|
||||
let page = java::page(
|
||||
&self.env,
|
||||
self.java_chain.clone(),
|
||||
self.java_busy,
|
||||
&self.java_message,
|
||||
);
|
||||
self.send(CoreEvent::JavaChanged(page));
|
||||
}
|
||||
|
||||
fn push_notice(&mut self, notice: NoticeView) {
|
||||
self.notices.retain(|existing| existing.id != notice.id);
|
||||
self.notices.push(notice);
|
||||
self.send(CoreEvent::NoticesChanged(self.notices.clone()));
|
||||
}
|
||||
|
||||
/// 打开一个会话(日志行 + 诊断)。
|
||||
fn open_session(&mut self, id: &str) {
|
||||
let (lines, diagnosis) = logs::open(&self.env, id);
|
||||
self.send(CoreEvent::LogLinesChanged(lines));
|
||||
self.send(CoreEvent::DiagnosisReady(diagnosis.unwrap_or(
|
||||
DiagnosisReport {
|
||||
session: id.to_string(),
|
||||
diagnosis: crate::dto::Diagnosis {
|
||||
rule: String::new(),
|
||||
title: "该会话没有命中已知规则".to_string(),
|
||||
confidence: 0,
|
||||
hint: "可点「让 AI 详解」把日志交给模型进一步分析".to_string(),
|
||||
fix: None,
|
||||
},
|
||||
},
|
||||
)));
|
||||
}
|
||||
|
||||
/// 由实例名/版本 id 找启动目标。
|
||||
fn launch_target(&self, id: &str) -> Option<launch::LaunchTarget> {
|
||||
let instance = game::instances(&self.env)
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id || instance.name == id)?;
|
||||
Some(launch::LaunchTarget {
|
||||
id: instance.id,
|
||||
name: instance.name,
|
||||
version_id: instance.version_id,
|
||||
account: self.active_account.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 某实例是否已在启动/运行(`pending` 与 `launches` 的并集)。
|
||||
///
|
||||
/// 只查 `launches` 不够:`launch::start` 到后台线程回报 `LaunchStarted` 之间有窗口,
|
||||
/// 这期间重复点启动会起第二个进程、并把旧 pid 在启动表里顶掉(旧进程变孤儿)。
|
||||
fn in_flight(&self, key: &str) -> bool {
|
||||
self.pending.contains(key) || self.launches.iter().any(|live| live.instance == key)
|
||||
}
|
||||
|
||||
/// 标记「已受理、等后台线程回报」(`launch::start` 之后的窗口)。
|
||||
///
|
||||
/// 单测也走这个入口而不是直接改 `pending`,保证测的是真实状态流转。
|
||||
fn mark_pending(&mut self, id: &str) {
|
||||
self.pending.insert(id.to_string());
|
||||
self.failed.remove(id);
|
||||
}
|
||||
|
||||
fn start_launch(&mut self, id: &str) {
|
||||
if self.in_flight(id) {
|
||||
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
|
||||
return;
|
||||
}
|
||||
let Some(target) = self.launch_target(id) else {
|
||||
self.toast(ToastKind::Error, format!("找不到实例 {id}"));
|
||||
return;
|
||||
};
|
||||
let instance_id = target.id.clone();
|
||||
if self.in_flight(&instance_id) {
|
||||
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
|
||||
return;
|
||||
}
|
||||
self.mark_pending(&instance_id);
|
||||
if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) {
|
||||
self.push_notice(notice);
|
||||
}
|
||||
self.send(CoreEvent::InstanceRuntimeChanged {
|
||||
id: instance_id.clone(),
|
||||
runtime: InstanceRuntime::Busy {
|
||||
stage: crate::dto::LaunchStage::Launch,
|
||||
progress: crate::dto::Progress { done: 0, total: 1 },
|
||||
},
|
||||
});
|
||||
launch::start(self.env.clone(), self.tx.clone(), target);
|
||||
}
|
||||
|
||||
/// 某实例在跑的 pid(按实例 id 查启动表;不再有「最近一次启动」这种单槽)。
|
||||
///
|
||||
/// 兜底分支读实例快照的 `Running { pid }`:host 自己发的运行时事件会写回快照,
|
||||
/// 但快照是重建的(`game::instances` 每次 Idle),所以这层只是给将来留的口。
|
||||
fn running_pid(&self, id: &str) -> Option<u32> {
|
||||
if let Some(live) = self.launches.iter().find(|live| live.instance == id) {
|
||||
return Some(live.pid);
|
||||
}
|
||||
game::instances(&self.env)
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.and_then(|instance| match instance.runtime {
|
||||
InstanceRuntime::Running { pid } => Some(pid),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for CoreHost {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
self.events.drain(..).collect()
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.dispatch_command(command);
|
||||
}
|
||||
|
||||
fn tick(&mut self, elapsed: Duration) {
|
||||
self.chat.tick(elapsed);
|
||||
for event in self.chat.drain_events() {
|
||||
self.events.push_back(event);
|
||||
}
|
||||
for _ in 0..MAX_TASKS_PER_TICK {
|
||||
let Ok(task) = self.rx.try_recv() else {
|
||||
break;
|
||||
};
|
||||
self.handle(task);
|
||||
}
|
||||
self.ticks += 1;
|
||||
if self.ticks.is_multiple_of(TAIL_POLL_TICKS) {
|
||||
self.poll_tail();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "launch_state_tests.rs"]
|
||||
mod launch_state_tests;
|
||||
@@ -0,0 +1,72 @@
|
||||
//! 用系统默认浏览器打开授权 URL(授权码流唯一需要宿主做的事)。
|
||||
//!
|
||||
//! core 只负责拼授权 URL 与等本地回调(`core::auth::microsoft::authcode`);
|
||||
//! 「谁来打开浏览器」是宿主的决定:GUI 走这里,CLI/无浏览器场景仍用设备码流。
|
||||
//!
|
||||
//! 平台差异收在 [`launcher`] 一处,不往业务代码里散写 `cfg` 补丁(`AGENTS.md` §7)。
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// 拉起系统默认浏览器打开 `url`(只接受 http/https)。
|
||||
///
|
||||
/// 不等待浏览器退出:用户可能在浏览器里停留几分钟,宿主只关心「有没有拉起来」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// URL 不是 http/https,或进程拉不起来时返回用户可读文案。
|
||||
pub fn open_url(url: &str) -> Result<(), String> {
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
return Err(format!("不是可打开的网址:{url}"));
|
||||
}
|
||||
let (program, args) = launcher(url);
|
||||
Command::new(program)
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map(|_child| ())
|
||||
.map_err(|error| format!("拉起浏览器失败({program}):{error}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn launcher(url: &str) -> (&'static str, Vec<String>) {
|
||||
// `rundll32` 不用 shell,URL 里的 `&` 不会被当成命令分隔符(`cmd /C start` 会)。
|
||||
(
|
||||
"rundll32",
|
||||
vec!["url.dll,FileProtocolHandler".to_string(), url.to_string()],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launcher(url: &str) -> (&'static str, Vec<String>) {
|
||||
("open", vec![url.to_string()])
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn launcher(url: &str) -> (&'static str, Vec<String>) {
|
||||
("xdg-open", vec![url.to_string()])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{launcher, open_url};
|
||||
|
||||
#[test]
|
||||
fn rejects_non_http_urls() {
|
||||
assert!(open_url("javascript:alert(1)").is_err());
|
||||
assert!(open_url("").is_err());
|
||||
// 合法 URL 只验命令形态,不在单测里真去拉浏览器。
|
||||
let (program, args) = launcher("http://localhost:1234/auth?code=a&state=b");
|
||||
assert!(!program.is_empty());
|
||||
assert!(args
|
||||
.iter()
|
||||
.any(|arg| arg.contains("http://localhost:1234/auth")));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_uses_the_file_protocol_handler() {
|
||||
let (program, args) = launcher("https://example.invalid/");
|
||||
assert_eq!(program, "rundll32");
|
||||
assert_eq!(args[0], "url.dll,FileProtocolHandler");
|
||||
assert_eq!(args[1], "https://example.invalid/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! 设置页分组:按 `core::setting` schema 的键生成行,写回走 `SettingStore::set`。
|
||||
//!
|
||||
//! 三件纪律(AGENTS §6):
|
||||
//! 1. 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` / AI `set_config` 同源);
|
||||
//! 2. 不自己拼 JSON,也不绕 schema(非法键/越界值由 `store.set` 拒绝);
|
||||
//! 3. 这里只认「键名 + 字符串值」,领域语义留在 core。
|
||||
//!
|
||||
//! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。
|
||||
|
||||
use serde_json::Value;
|
||||
use unlucky_core::game::java::JdkVendor;
|
||||
|
||||
use crate::dto::{SettingGroupView, SettingRowView};
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 全部设置分组(设置页右栏一次拿全,切分组不再请求)。
|
||||
pub fn groups(env: &HostEnv) -> Vec<SettingGroupView> {
|
||||
vec![
|
||||
download_group(env),
|
||||
java_group(env),
|
||||
data_group(env),
|
||||
ai_group(env),
|
||||
mcp_group(env),
|
||||
]
|
||||
}
|
||||
|
||||
/// 读一个键的字符串值(缺省给空串)。
|
||||
fn text(env: &HostEnv, key: &str) -> String {
|
||||
env.store()
|
||||
.doc()
|
||||
.effective_str(key)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 读一个键的整数(缺省给 `fallback`)。
|
||||
fn int(env: &HostEnv, key: &str, fallback: i64) -> i64 {
|
||||
env.store()
|
||||
.effective(key)
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// 读一个键的布尔(缺省给 `fallback`)。
|
||||
fn flag(env: &HostEnv, key: &str, fallback: bool) -> bool {
|
||||
env.store()
|
||||
.effective(key)
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// 枚举值在选项表里的序号(找不到给 0)。
|
||||
fn selected(options: &[&str], value: &str) -> i32 {
|
||||
options
|
||||
.iter()
|
||||
.position(|option| *option == value)
|
||||
.map(|index| i32::try_from(index).unwrap_or(0))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 下载(D7):并发/重试/地区/镜像偏好/代理。
|
||||
fn download_group(env: &HostEnv) -> SettingGroupView {
|
||||
let concurrency = int(env, "download.concurrency", 8).to_string();
|
||||
let retry = int(env, "download.retry_limit", 3).to_string();
|
||||
let concurrency_options = ["4", "8", "16"];
|
||||
let retry_options = ["0", "1", "3", "5"];
|
||||
let regions = ["auto", "cn", "global"];
|
||||
let mirrors = ["auto", "official", "mirror"];
|
||||
let proxies = ["none", "system", "manual"];
|
||||
let region_value = text(env, "download.region");
|
||||
let mirror_value = text(env, "download.mirror_preference");
|
||||
let proxy_value = text(env, "download.proxy_mode");
|
||||
SettingGroupView::new("download", "下载与镜像(D7)")
|
||||
.with(SettingRowView::choice(
|
||||
"download.concurrency",
|
||||
"并发数",
|
||||
"同时下载的文件数(海量小文件,16 路足够压满带宽)",
|
||||
&concurrency_options,
|
||||
selected(&concurrency_options, &concurrency),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"download.retry_limit",
|
||||
"单文件重试次数",
|
||||
"失败后换源重试的上限",
|
||||
&retry_options,
|
||||
selected(&retry_options, &retry),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"download.region",
|
||||
"地区判定",
|
||||
"auto = 时区+语言+探测;决定是否优先国内镜像",
|
||||
®ions,
|
||||
selected(
|
||||
®ions,
|
||||
if region_value.is_empty() {
|
||||
"auto"
|
||||
} else {
|
||||
®ion_value
|
||||
},
|
||||
),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"download.mirror_preference",
|
||||
"镜像偏好",
|
||||
"auto = 按健康度评分;official = 官方源优先",
|
||||
&mirrors,
|
||||
selected(
|
||||
&mirrors,
|
||||
if mirror_value.is_empty() {
|
||||
"auto"
|
||||
} else {
|
||||
&mirror_value
|
||||
},
|
||||
),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"download.proxy_mode",
|
||||
"代理模式",
|
||||
"none / system(跟随系统)/ manual(下面填地址)",
|
||||
&proxies,
|
||||
selected(
|
||||
&proxies,
|
||||
if proxy_value.is_empty() {
|
||||
"none"
|
||||
} else {
|
||||
&proxy_value
|
||||
},
|
||||
),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"download.proxy_url",
|
||||
"HTTP 代理",
|
||||
"manual 模式生效;留空则用系统代理",
|
||||
&text(env, "download.proxy_url"),
|
||||
))
|
||||
.with(SettingRowView::action(
|
||||
"download.action.detect_region",
|
||||
"重新检测地区",
|
||||
"清掉 24h 地区缓存,下次下载重新判定",
|
||||
"detect_region",
|
||||
))
|
||||
}
|
||||
|
||||
/// Java(D13/D23):自动安装/内存/选路/发行版优先级。
|
||||
fn java_group(env: &HostEnv) -> SettingGroupView {
|
||||
let memory = int(env, "java.default_memory_mb", 2048).to_string();
|
||||
let memory_options = ["1024", "2048", "4096", "8192"];
|
||||
let priority: Vec<String> = env
|
||||
.store()
|
||||
.effective("java.vendorPriority")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let priority_text = if priority.is_empty() {
|
||||
"地区默认序(Temurin → …)".to_string()
|
||||
} else {
|
||||
priority.join(" → ")
|
||||
};
|
||||
let roots = super::engine::java_scan_roots(env);
|
||||
SettingGroupView::new("java", "Java(D13)")
|
||||
.with(SettingRowView::toggle(
|
||||
"java.auto_install",
|
||||
"缺 Java 时自动下载",
|
||||
"按回退链装一个托管 JDK(基准 JDK17)",
|
||||
flag(env, "java.auto_install", true),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"java.default_memory_mb",
|
||||
"默认最大堆内存",
|
||||
"实例没单独指定时用这个值(MB)",
|
||||
&memory_options,
|
||||
selected(&memory_options, &memory),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"java.selected_path",
|
||||
"指定 Java 可执行文件",
|
||||
"留空 = 自动挑(托管 > JAVA_HOME > PATH)",
|
||||
&text(env, "java.selected_path"),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"java.installRoot",
|
||||
"托管 JDK 安装根",
|
||||
"留空 = 按数据目录模式派生(portable 进数据根 / standard 进系统目录)",
|
||||
&text(env, "java.installRoot"),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"java.extra_args",
|
||||
"附加 JVM 参数",
|
||||
"原样拼到命令行尾部",
|
||||
&text(env, "java.extra_args"),
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"发行版优先级(java.vendorPriority)",
|
||||
&priority_text,
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"托管根",
|
||||
&roots
|
||||
.iter()
|
||||
.map(|root| root.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · "),
|
||||
))
|
||||
}
|
||||
|
||||
/// 数据目录(D6 三模式)。
|
||||
fn data_group(env: &HostEnv) -> SettingGroupView {
|
||||
let modes = ["portable", "standard", "custom"];
|
||||
let mode = env.mode().as_str().to_string();
|
||||
SettingGroupView::new("data", "数据目录(D6 三模式)")
|
||||
.with(SettingRowView::choice(
|
||||
"core.data_mode",
|
||||
"存储模式",
|
||||
"便携(运行目录)/ 标准(系统应用数据目录)/ 自定义",
|
||||
&modes,
|
||||
selected(&modes, &mode),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"core.data_custom_root",
|
||||
"自定义根目录",
|
||||
"custom 模式生效(绝对路径)",
|
||||
&text(env, "core.data_custom_root"),
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"启动器数据目录",
|
||||
&env.data.launcher_home.display().to_string(),
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"游戏目录",
|
||||
&env.data.game_home.display().to_string(),
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"生效模式",
|
||||
&format!("{}(改数据根需重启启动器)", env.mode()),
|
||||
))
|
||||
}
|
||||
|
||||
/// AI(D4):开关/端点/模型/YOLO。
|
||||
fn ai_group(env: &HostEnv) -> SettingGroupView {
|
||||
let providers = ["openai_compatible", "anthropic", "ollama"];
|
||||
let provider = text(env, "ai.provider");
|
||||
SettingGroupView::new("ai", "AI 助手(D4)")
|
||||
.with(SettingRowView::toggle(
|
||||
"ai.enabled",
|
||||
"启用 AI 助手",
|
||||
"关闭后规则引擎诊断仍可用",
|
||||
flag(env, "ai.enabled", true),
|
||||
))
|
||||
.with(SettingRowView::choice(
|
||||
"ai.provider",
|
||||
"模型端点",
|
||||
"OpenAI 兼容(含本地 llama.cpp)/ Anthropic / Ollama",
|
||||
&providers,
|
||||
selected(
|
||||
&providers,
|
||||
if provider.is_empty() {
|
||||
"openai_compatible"
|
||||
} else {
|
||||
&provider
|
||||
},
|
||||
),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"ai.base_url",
|
||||
"端点 base_url",
|
||||
"如 http://127.0.0.1:8080/v1",
|
||||
&text(env, "ai.base_url"),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"ai.model",
|
||||
"模型名",
|
||||
"如 deepseek-chat",
|
||||
&text(env, "ai.model"),
|
||||
))
|
||||
.with(SettingRowView::toggle(
|
||||
"ai.yolo",
|
||||
"YOLO 模式",
|
||||
"跳过确认弹窗(审计照记;账户/凭据操作永不 YOLO)",
|
||||
flag(env, "ai.yolo", false),
|
||||
))
|
||||
}
|
||||
|
||||
/// M39 的对外 MCP server(默认关、默认只读)。
|
||||
fn mcp_group(env: &HostEnv) -> SettingGroupView {
|
||||
let port = int(env, "agent.mcp.port", 47_823);
|
||||
SettingGroupView::new("mcp", "MCP server(M39)")
|
||||
.with(SettingRowView::toggle(
|
||||
"agent.mcp.enabled",
|
||||
"对外提供 MCP server",
|
||||
"127.0.0.1 上的 Streamable HTTP(stdio 需显式启动)",
|
||||
flag(env, "agent.mcp.enabled", false),
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
"agent.mcp.port",
|
||||
"端口",
|
||||
"1024~65535,只绑 127.0.0.1",
|
||||
&port.to_string(),
|
||||
))
|
||||
.with(SettingRowView::toggle(
|
||||
"agent.mcp.allow_write",
|
||||
"允许外部客户端写配置",
|
||||
"默认只读;写仍受 schema 白名单与分级确认约束",
|
||||
flag(env, "agent.mcp.allow_write", false),
|
||||
))
|
||||
.with(SettingRowView::read_only(
|
||||
"客户端地址",
|
||||
&format!("http://127.0.0.1:{port}/mcp(令牌见 agent.mcp.token,AI 不可写)"),
|
||||
))
|
||||
}
|
||||
|
||||
/// 写一条设置。`value` 是字符串形态,按 schema 键的类型转换后交 `store.set`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 未知键、值类型不符、越界、落盘失败时返回用户可读文案。
|
||||
pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
|
||||
if key == "download.action.detect_region" {
|
||||
return detect_region(env);
|
||||
}
|
||||
let parsed = parse_value(key, value)?;
|
||||
let mut store = env.store();
|
||||
store
|
||||
.set(key, parsed)
|
||||
.map_err(|error| format!("设置 {key} 失败:{error}"))?;
|
||||
store
|
||||
.save()
|
||||
.map_err(|error| format!("保存设置失败:{error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。
|
||||
fn parse_value(key: &str, value: &str) -> Result<Value, String> {
|
||||
if matches!(key, "download.concurrency" | "download.retry_limit") {
|
||||
let number: u32 = value
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("{key} 需要整数:{value}"))?;
|
||||
return Ok(Value::from(number));
|
||||
}
|
||||
if matches!(key, "java.default_memory_mb" | "agent.mcp.port") {
|
||||
let number: u32 = value
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("{key} 需要整数:{value}"))?;
|
||||
return Ok(Value::from(number));
|
||||
}
|
||||
if matches!(
|
||||
key,
|
||||
"java.auto_install"
|
||||
| "ai.enabled"
|
||||
| "ai.yolo"
|
||||
| "agent.mcp.enabled"
|
||||
| "agent.mcp.allow_write"
|
||||
) {
|
||||
let flag = match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "on" | "yes" => true,
|
||||
"0" | "false" | "off" | "no" => false,
|
||||
_ => return Err(format!("{key} 需要布尔值:{value}")),
|
||||
};
|
||||
return Ok(Value::Bool(flag));
|
||||
}
|
||||
Ok(Value::String(value.to_string()))
|
||||
}
|
||||
|
||||
/// 清掉 24h 地区缓存(下次建引擎重新判定)。
|
||||
fn detect_region(env: &HostEnv) -> Result<(), String> {
|
||||
let path = env.layout.cache().join("region.json");
|
||||
if path.is_file() {
|
||||
std::fs::remove_file(&path).map_err(|error| format!("清地区缓存失败:{error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置页「重置发行版优先级」用(清空 enum_list)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 落盘失败时返回用户可读文案。
|
||||
pub fn clear_vendor_priority(env: &HostEnv) -> Result<(), String> {
|
||||
let mut store = env.store();
|
||||
store
|
||||
.set("java.vendorPriority", Value::Array(Vec::new()))
|
||||
.and_then(|()| store.save())
|
||||
.map_err(|error| format!("清空发行版优先级失败:{error}"))?;
|
||||
let _ = JdkVendor::ALL;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "settings_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,77 @@
|
||||
//! host/settings.rs 的单测(拆文件守 400 行上限)。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn env_in(dir: &std::path::Path) -> HostEnv {
|
||||
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config");
|
||||
HostEnv::at_root(dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_cover_the_six_required_areas() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-settings-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = env_in(&dir);
|
||||
let groups = groups(&env);
|
||||
let ids: Vec<&str> = groups.iter().map(|group| group.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["download", "java", "data", "ai", "mcp"]);
|
||||
let keys: Vec<&str> = groups
|
||||
.iter()
|
||||
.flat_map(|group| group.rows.iter())
|
||||
.map(|row| row.key.as_str())
|
||||
.collect();
|
||||
for key in [
|
||||
"download.region",
|
||||
"download.mirror_preference",
|
||||
"java.default_memory_mb",
|
||||
"core.data_mode",
|
||||
"agent.mcp.enabled",
|
||||
"agent.mcp.port",
|
||||
"ai.base_url",
|
||||
] {
|
||||
assert!(keys.contains(&key), "缺少 {key}");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_parses_types_and_persists() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-settings-w-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = env_in(&dir);
|
||||
|
||||
set(&env, "download.concurrency", "16").expect("写并发");
|
||||
set(&env, "agent.mcp.enabled", "true").expect("写开关");
|
||||
set(&env, "agent.mcp.port", "48000").expect("写端口");
|
||||
set(&env, "download.region", "cn").expect("写地区");
|
||||
|
||||
let groups = groups(&env);
|
||||
let row = |key: &str| {
|
||||
groups
|
||||
.iter()
|
||||
.flat_map(|g| g.rows.iter())
|
||||
.find(|r| r.key == key)
|
||||
.cloned()
|
||||
};
|
||||
assert_eq!(row("download.concurrency").unwrap().selected, 2);
|
||||
assert!(row("agent.mcp.enabled").unwrap().flag);
|
||||
assert_eq!(row("agent.mcp.port").unwrap().value_text, "48000");
|
||||
assert_eq!(row("download.region").unwrap().selected, 1);
|
||||
|
||||
assert!(set(&env, "download.concurrency", "abc").is_err());
|
||||
assert!(set(&env, "agent.mcp.enabled", "maybe").is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_region_action_removes_cache() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-settings-r-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = env_in(&dir);
|
||||
let path = env.layout.cache().join("region.json");
|
||||
std::fs::create_dir_all(env.layout.cache()).expect("cache");
|
||||
std::fs::write(&path, "{}").expect("写缓存");
|
||||
set(&env, "download.action.detect_region", "").expect("检测");
|
||||
assert!(!path.exists());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 版本清单(下载页):远端 manifest(12h 缓存)+ 内嵌 unlisted 清单 → 分类好的行。
|
||||
//!
|
||||
//! 分类口径抄 analyse/03 §2.4:官方 `type` 决定 release/snapshot/远古;远古再按 id
|
||||
//! 前缀拆 classic(`rd-*`)/ old_alpha(`a*`)/ old_beta(`b*`),其余归 unlisted 汇总。
|
||||
//! 拉取失败但有旧缓存时用旧缓存(离线可用),全无才报错。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::game::install::RemoteSource;
|
||||
use unlucky_core::game::unlisted;
|
||||
use unlucky_core::game::version::{ReleaseType, RemoteVersion};
|
||||
|
||||
use crate::dto::{AncientKind, VersionKind, VersionSummary};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{spawn, HostTask, JobSender};
|
||||
|
||||
/// 版本 id / 官方类型 → 下载页的四种归类。
|
||||
pub fn kind_of(id: &str, release_type: ReleaseType) -> VersionKind {
|
||||
match release_type {
|
||||
ReleaseType::Release => VersionKind::Release,
|
||||
ReleaseType::Snapshot | ReleaseType::Pending | ReleaseType::Unobfuscated => {
|
||||
VersionKind::Snapshot
|
||||
}
|
||||
ReleaseType::OldAlpha | ReleaseType::OldBeta | ReleaseType::Modified => {
|
||||
VersionKind::Ancient(ancient_kind(id))
|
||||
}
|
||||
ReleaseType::Unknown => {
|
||||
if id.starts_with("rd-") || id.starts_with("a") || id.starts_with("b") {
|
||||
VersionKind::Ancient(ancient_kind(id))
|
||||
} else {
|
||||
VersionKind::Release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 远古版子类(`rd-*` → classic;`a1.*` → old_alpha;`b1.*` → old_beta;其余 unlisted)。
|
||||
pub fn ancient_kind(id: &str) -> AncientKind {
|
||||
if id.starts_with("rd-") || id.starts_with("inf-") {
|
||||
AncientKind::Classic
|
||||
} else if id.starts_with("a1.") || id.starts_with("a0.") || id.starts_with('a') {
|
||||
AncientKind::OldAlpha
|
||||
} else if id.starts_with("b1.") || id.starts_with('b') {
|
||||
AncientKind::OldBeta
|
||||
} else {
|
||||
AncientKind::Unlisted
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布日期取 `releaseTime` 的日期部分(缺失时退回 `time`,再退回空串)。
|
||||
fn released_of(version: &RemoteVersion) -> String {
|
||||
version
|
||||
.release_time
|
||||
.as_deref()
|
||||
.or(version.time.as_deref())
|
||||
.map(|text| text.get(..10).unwrap_or(text).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 拉清单并翻译成 DTO(安装状态按盘上版本判定)。
|
||||
fn fetch(env: &HostEnv) -> Result<Vec<VersionSummary>, String> {
|
||||
let engine = super::engine::engine(env);
|
||||
let meta_cache = env.layout.meta_cache();
|
||||
let now = now_ms();
|
||||
let (mut list, _freshness) = block_on(async {
|
||||
RemoteSource::new(&engine, &env.game, &meta_cache, now)
|
||||
.manifest(false)
|
||||
.await
|
||||
})
|
||||
.map_err(|error| format!("拉取版本清单失败:{error}"))?;
|
||||
// 内嵌 unlisted 清单先合(同 id 先插入者胜),保证远古版显式可达(09 §G2)。
|
||||
unlisted::merge_into(&mut list);
|
||||
list.sort_newest_first();
|
||||
|
||||
let installed: BTreeSet<String> = env
|
||||
.game
|
||||
.installed_versions()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
Ok(list
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| VersionSummary {
|
||||
id: version.id.clone(),
|
||||
kind: kind_of(&version.id, version.version_type),
|
||||
released: released_of(version),
|
||||
installed: installed.contains(&version.id),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 后台拉清单(网络在后台线程里,UI 不卡)。
|
||||
pub fn spawn_list(env: HostEnv, tx: JobSender) {
|
||||
spawn("versions", move || {
|
||||
let result = fetch(&env);
|
||||
let _ = tx.send(HostTask::Versions(result));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kinds_follow_official_types() {
|
||||
assert_eq!(
|
||||
kind_of("1.21.9", ReleaseType::Release),
|
||||
VersionKind::Release
|
||||
);
|
||||
assert_eq!(
|
||||
kind_of("1.21.9-pre3", ReleaseType::Snapshot),
|
||||
VersionKind::Snapshot
|
||||
);
|
||||
assert_eq!(
|
||||
kind_of("rd-132211", ReleaseType::OldAlpha),
|
||||
VersionKind::Ancient(AncientKind::Classic)
|
||||
);
|
||||
assert_eq!(
|
||||
kind_of("a1.1.1", ReleaseType::OldAlpha),
|
||||
VersionKind::Ancient(AncientKind::OldAlpha)
|
||||
);
|
||||
assert_eq!(
|
||||
kind_of("b1.7.3", ReleaseType::OldBeta),
|
||||
VersionKind::Ancient(AncientKind::OldBeta)
|
||||
);
|
||||
assert_eq!(
|
||||
kind_of("x-modified", ReleaseType::Modified),
|
||||
VersionKind::Ancient(AncientKind::Unlisted)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn released_uses_date_part() {
|
||||
let version = RemoteVersion {
|
||||
id: "1.20.4".to_string(),
|
||||
version_type: ReleaseType::Release,
|
||||
url: String::new(),
|
||||
time: None,
|
||||
release_time: Some("2023-12-07T12:00:00+00:00".to_string()),
|
||||
sha1: None,
|
||||
size: None,
|
||||
};
|
||||
assert_eq!(released_of(&version), "2023-12-07");
|
||||
let empty = RemoteVersion {
|
||||
id: "x".to_string(),
|
||||
version_type: ReleaseType::Unknown,
|
||||
url: String::new(),
|
||||
time: None,
|
||||
release_time: None,
|
||||
sha1: None,
|
||||
size: None,
|
||||
};
|
||||
assert_eq!(released_of(&empty), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlisted_merge_keeps_ancient_versions_reachable() {
|
||||
// core 的内嵌清单里一定有远古版(`rd-*` / `a1.*` / `b1.*`)
|
||||
let mut list = unlucky_core::game::version::VersionList::default();
|
||||
let added = unlisted::merge_into(&mut list);
|
||||
assert!(added > 0, "内嵌 unlisted 清单不该为空");
|
||||
assert!(
|
||||
list.versions.iter().any(|v| v.id.starts_with("a1.")
|
||||
|| v.id.starts_with("b1.")
|
||||
|| v.id.starts_with("rd-")),
|
||||
"远古版必须显式可达"
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-20
@@ -32,36 +32,40 @@
|
||||
#![allow(missing_docs, dead_code)]
|
||||
|
||||
mod appearance_bind;
|
||||
mod backend;
|
||||
mod bg_image;
|
||||
mod callbacks;
|
||||
mod callbacks_pages;
|
||||
mod conv;
|
||||
mod dialogs;
|
||||
mod dto;
|
||||
mod host;
|
||||
mod mock;
|
||||
mod mock_data;
|
||||
mod page_sync;
|
||||
mod png;
|
||||
mod prefs;
|
||||
mod router;
|
||||
mod screenshot;
|
||||
mod settings_bridge;
|
||||
mod state;
|
||||
mod theme_runtime;
|
||||
mod toast;
|
||||
mod ui_bind;
|
||||
mod wizard;
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
fn main() -> Result<(), slint::PlatformError> {
|
||||
apply_renderer_flag();
|
||||
|
||||
let app = ui_bind::AppUi::new(MainWindow::new()?);
|
||||
let app = ui_bind::AppUi::with_options(MainWindow::new()?, screenshot::app_options());
|
||||
|
||||
// 截图验收通道(M41 视觉验收;无交互桌面/无 GPU 环境下的唯一可靠取图方式:
|
||||
// 不截屏幕,而是让 Slint 把当前场景渲染进内存像素缓冲再自己编码 PNG)。
|
||||
if let Some(path) = screenshot_path() {
|
||||
if std::env::args().any(|arg| arg == "--screenshot-settings") {
|
||||
app.open_settings_appearance();
|
||||
}
|
||||
app.render_screenshot(&path)?;
|
||||
// P3 追加页面选择与中间态(安装进度 / 启动事件流),见 `crate::screenshot`。
|
||||
if let Some(options) = screenshot::shot_options() {
|
||||
screenshot::capture(&app, &options)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -78,17 +82,3 @@ fn apply_renderer_flag() {
|
||||
std::env::set_var("SLINT_BACKEND", "winit-software");
|
||||
}
|
||||
}
|
||||
|
||||
/// `--screenshot <path>` / `--screenshot=<path>`:渲染一帧并写 PNG 后退出。
|
||||
fn screenshot_path() -> Option<String> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if let Some(value) = arg.strip_prefix("--screenshot=") {
|
||||
return Some(value.to_owned());
|
||||
}
|
||||
if arg == "--screenshot" {
|
||||
return args.next();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -260,9 +260,9 @@ impl Backend for MockBackend {
|
||||
crate::mock_data::mock_diagnosis(id),
|
||||
)));
|
||||
}
|
||||
AppCommand::RefreshInstances
|
||||
| AppCommand::RefreshLogSessions
|
||||
| AppCommand::StopAgent => {}
|
||||
AppCommand::RefreshAll | AppCommand::RefreshLogSessions | AppCommand::StopAgent => {}
|
||||
// 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
|
||||
last_played: Some("2026-10-29T19:22:00Z".to_string()),
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
},
|
||||
InstanceSummary {
|
||||
id: "vanilla-latest".to_string(),
|
||||
@@ -34,6 +37,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
|
||||
last_played: Some("2026-10-28T21:03:00Z".to_string()),
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.21.9".to_string(),
|
||||
version_present: true,
|
||||
},
|
||||
InstanceSummary {
|
||||
id: "ancient-1.7.10".to_string(),
|
||||
@@ -46,6 +52,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "shared".to_string(),
|
||||
version_id: "1.7.10".to_string(),
|
||||
version_present: false,
|
||||
},
|
||||
InstanceSummary {
|
||||
id: "modded-dev".to_string(),
|
||||
@@ -60,6 +69,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
|
||||
reason: "exit0 静默失败".to_string(),
|
||||
},
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.21.1-neoforge-21.1.72".to_string(),
|
||||
version_present: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! `PageState` global 的状态灌入(从 `ui_bind` 拆出,守 400 行上限)。
|
||||
//!
|
||||
//! 一个函数只做「AppState → Slint global 属性」的搬运,同步发生在每一拍末尾
|
||||
//! (`ui_bind::Inner::sync`)。分文件而不是砍内容:这些字段每一行都对应一个页面元素。
|
||||
|
||||
use crate::conv;
|
||||
use crate::state::AppState;
|
||||
use crate::ui_bind::model;
|
||||
use crate::{MainWindow, PageState};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
/// 把页面状态灌进 `PageState`。
|
||||
pub(crate) fn apply(ui: &MainWindow, state: &AppState) {
|
||||
// 导航底部账户 + PageState 全量页面状态(账户/Java/安装/设置/通知/向导)
|
||||
ui.set_account_name(conv::active_account_name(state).into());
|
||||
let page = ui.global::<PageState<'_>>();
|
||||
page.set_accounts(model(conv::accounts(state)));
|
||||
page.set_active_account(conv::active_account_name(state).into());
|
||||
page.set_device(conv::device(state));
|
||||
page.set_java_vendors(model(conv::java_vendors(state)));
|
||||
page.set_java_chain(model(conv::java_chain(state)));
|
||||
page.set_java_runtimes(model(conv::java_runtimes(state)));
|
||||
page.set_java_order(state.java.order_text.clone().into());
|
||||
page.set_java_region(state.java.region.clone().into());
|
||||
page.set_java_priority_source(state.java.priority_source.clone().into());
|
||||
page.set_java_message(state.java.message.clone().into());
|
||||
page.set_java_busy(state.java.busy);
|
||||
let major = state.java.majors().last().copied().unwrap_or(17);
|
||||
page.set_java_target_major(i32::try_from(major).unwrap_or(17));
|
||||
page.set_install(conv::install(state));
|
||||
page.set_setting_groups(model(conv::setting_groups(state)));
|
||||
page.set_setting_tab(state.settings_tab.clone().into());
|
||||
page.set_notices(model(conv::notices(state)));
|
||||
page.set_wizard(conv::wizard(state));
|
||||
page.set_wizard_versions(model(conv::wizard_versions(state)));
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! 截图验收通道的参数解析与流程(`--screenshot*` / `--data-dir` / `--mock`)。
|
||||
//!
|
||||
//! 为什么用进程内渲染而不是截屏:验收机没有交互桌面(`CopyFromScreen` 直接失败),
|
||||
//! 而 Slint 的软件渲染器能把场景渲进内存像素缓冲(`Window::take_snapshot`),
|
||||
//! 结果与屏幕上逐像素一致且不需要 GPU(M41 落地的通道,这里只加页面/中间态参数)。
|
||||
//!
|
||||
//! 中间态(安装进度 / 启动事件流)走真实后端:`--screenshot-install <版本>` 会真的发起
|
||||
//! 一次安装并停若干毫秒后取图,`--screenshot-launch <实例>` 同理。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::backend::AppOptions;
|
||||
use crate::dto::AppCommand;
|
||||
use crate::ui_bind::AppUi;
|
||||
|
||||
/// 一次截图请求。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ShotOptions {
|
||||
/// 输出 PNG 路径
|
||||
pub path: String,
|
||||
/// 页面名(instances / download / java / logs / settings / account / appearance / agent)
|
||||
pub page: Option<String>,
|
||||
/// 打开新建实例向导
|
||||
pub wizard: bool,
|
||||
/// 发起一次真实安装并停 `dwell_ms` 后取图(中间态)
|
||||
pub install: Option<String>,
|
||||
/// 安装走哪个加载器(`--screenshot-loader forge`;缺省 = 原版)
|
||||
pub loader: Option<String>,
|
||||
/// 发起一次真实启动并停 `dwell_ms` 后取图(中间态)
|
||||
pub launch: Option<String>,
|
||||
/// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片)
|
||||
pub session: Option<String>,
|
||||
/// 中间态停留毫秒(默认 2500)
|
||||
pub dwell_ms: u64,
|
||||
}
|
||||
|
||||
impl ShotOptions {
|
||||
/// 中间态默认停留时长。
|
||||
pub const DEFAULT_DWELL_MS: u64 = 2_500;
|
||||
}
|
||||
|
||||
/// 从命令行读启动选项(数据根 / mock)。
|
||||
pub fn app_options() -> AppOptions {
|
||||
AppOptions {
|
||||
data_root: value_of("--data-dir").map(PathBuf::from),
|
||||
mock: flag("--mock"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从命令行读截图请求;没有 `--screenshot` 时返回 `None`。
|
||||
pub fn shot_options() -> Option<ShotOptions> {
|
||||
let path = value_of("--screenshot")?;
|
||||
let page = value_of("--screenshot-page");
|
||||
let mut options = ShotOptions {
|
||||
path,
|
||||
page: if flag("--screenshot-settings") {
|
||||
Some("appearance".to_string())
|
||||
} else {
|
||||
page
|
||||
},
|
||||
wizard: flag("--screenshot-wizard"),
|
||||
install: value_of("--screenshot-install"),
|
||||
loader: value_of("--screenshot-loader"),
|
||||
launch: value_of("--screenshot-launch"),
|
||||
session: value_of("--screenshot-session"),
|
||||
dwell_ms: value_of("--screenshot-dwell")
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(ShotOptions::DEFAULT_DWELL_MS),
|
||||
};
|
||||
if options.dwell_ms < 500 {
|
||||
options.dwell_ms = 500;
|
||||
}
|
||||
options.page = options.page.filter(|page| !page.trim().is_empty());
|
||||
Some(options)
|
||||
}
|
||||
|
||||
/// 执行截图流程:切页 → (可选)中间态 → 渲染 PNG。
|
||||
///
|
||||
/// # Errors
|
||||
/// 窗口显示/快照/写文件失败时返回错误。
|
||||
pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::PlatformError> {
|
||||
if let Some(page) = &options.page {
|
||||
app.open_page(page);
|
||||
// 先给后端一次全量刷新:版本清单要在后台线程里拉(12h 缓存),
|
||||
// 下载页/Java 页/向导需要它才有「有数据态」。
|
||||
let warmup = if matches!(page.as_str(), "download" | "java") {
|
||||
4_000
|
||||
} else {
|
||||
1_200
|
||||
};
|
||||
app.dispatch_and_run(AppCommand::RefreshAll, warmup);
|
||||
}
|
||||
if options.wizard {
|
||||
app.open_wizard();
|
||||
}
|
||||
if let Some(version) = &options.install {
|
||||
app.dispatch_and_run(
|
||||
install_command(version, options.loader.as_deref()),
|
||||
options.dwell_ms,
|
||||
);
|
||||
}
|
||||
if let Some(session) = &options.session {
|
||||
app.open_session(session);
|
||||
app.dispatch_and_run(AppCommand::RefreshLogSessions, options.dwell_ms.min(1_000));
|
||||
}
|
||||
if let Some(instance) = &options.launch {
|
||||
app.dispatch_and_run(
|
||||
AppCommand::LaunchInstance(instance.clone()),
|
||||
options.dwell_ms,
|
||||
);
|
||||
}
|
||||
app.render_screenshot(&options.path)?;
|
||||
// 启动中间态截完就把进程收掉:截图进程退出后子进程会变孤儿,
|
||||
// 所以在取图之后显式 stop(kill_tree),不留给系统。
|
||||
if let Some(instance) = &options.launch {
|
||||
app.dispatch_and_run(AppCommand::StopInstance(instance.clone()), 1_500);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 截图通道的安装指令:缺省原版,给了 `--screenshot-loader` 就走向导同一条路
|
||||
/// (`CreateInstance`),因此 Forge/NeoForge 的中间态也能取图。
|
||||
fn install_command(version: &str, loader: Option<&str>) -> AppCommand {
|
||||
match loader.map(crate::dto::LoaderKind::parse) {
|
||||
Some(Some(kind)) => AppCommand::CreateInstance(crate::dto::CreateRequest {
|
||||
version: version.to_string(),
|
||||
loader: kind,
|
||||
loader_version: String::new(),
|
||||
name: version.to_string(),
|
||||
}),
|
||||
_ => AppCommand::InstallVersion(version.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--flag` 是否存在。
|
||||
pub fn flag(name: &str) -> bool {
|
||||
std::env::args().skip(1).any(|arg| arg == name)
|
||||
}
|
||||
|
||||
/// `--key value` / `--key=value` 的取值。
|
||||
pub fn value_of(name: &str) -> Option<String> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if let Some(value) = arg.strip_prefix(&format!("{name}=")) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
if arg == name {
|
||||
return args.next();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_dwell_is_a_visible_mid_state() {
|
||||
const DEFAULT: u64 = ShotOptions::DEFAULT_DWELL_MS;
|
||||
let dwell = DEFAULT;
|
||||
assert!(dwell >= 1_000, "中间态至少要停 1 秒才看得见进度");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_without_screenshot_are_none() {
|
||||
// 测试进程没有 --screenshot 参数
|
||||
assert!(
|
||||
shot_options().is_none() || std::env::args().any(|arg| arg.contains("--screenshot"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_of_ignores_absent_flags() {
|
||||
assert!(value_of("--definitely-not-a-real-flag").is_none());
|
||||
}
|
||||
}
|
||||
+95
-5
@@ -5,12 +5,14 @@
|
||||
|
||||
use crate::dialogs::DialogQueue;
|
||||
use crate::dto::{
|
||||
AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DiagnosisReport, InstanceRuntime,
|
||||
InstanceSummary, LogLine, LogSession, Progress, ToolCallView, VersionSummary,
|
||||
AccountView, AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DeviceLoginView,
|
||||
DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine,
|
||||
LogSession, NoticeView, Progress, SettingGroupView, ToolCallView, VersionSummary,
|
||||
};
|
||||
use crate::prefs::UiPrefs;
|
||||
use crate::router::{AiPanelMode, Route, Router};
|
||||
use crate::toast::ToastCenter;
|
||||
use crate::toast::{ToastCenter, ToastKind};
|
||||
use crate::wizard::WizardState;
|
||||
|
||||
/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -75,6 +77,9 @@ pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
|
||||
pub const MAX_LIVE_LINES: usize = 2_000;
|
||||
|
||||
/// 应用全局状态。
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
@@ -106,6 +111,24 @@ pub struct AppState {
|
||||
pub installed_only: bool,
|
||||
/// 下载页版本号筛选词
|
||||
pub download_query: String,
|
||||
/// 账户列表(三轨统一视图;凭据不在其中)
|
||||
pub accounts: Vec<AccountView>,
|
||||
/// 微软登录状态
|
||||
pub device_login: DeviceLoginView,
|
||||
/// Java 页快照(发行版 / 回退链 / 运行时)
|
||||
pub java: JavaPageView,
|
||||
/// 安装进度(None = 没有安装任务)
|
||||
pub install: Option<InstallProgressView>,
|
||||
/// 设置分组(设置页右栏)
|
||||
pub settings_groups: Vec<SettingGroupView>,
|
||||
/// 当前选中的设置分组(download / java / data / ai / mcp)
|
||||
pub settings_tab: String,
|
||||
/// 横幅通知
|
||||
pub notices: Vec<NoticeView>,
|
||||
/// 新建实例向导
|
||||
pub wizard: WizardState,
|
||||
/// 正在实时尾随的会话
|
||||
pub live_session: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
@@ -132,6 +155,15 @@ impl AppState {
|
||||
instance_query: String::new(),
|
||||
installed_only: false,
|
||||
download_query: String::new(),
|
||||
accounts: Vec::new(),
|
||||
device_login: DeviceLoginView::new(),
|
||||
java: JavaPageView::new(),
|
||||
install: None,
|
||||
settings_groups: Vec::new(),
|
||||
settings_tab: "download".to_string(),
|
||||
notices: Vec::new(),
|
||||
wizard: WizardState::new(),
|
||||
live_session: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,12 +218,51 @@ impl AppState {
|
||||
}
|
||||
CoreEvent::VersionListChanged(versions) => self.versions = versions,
|
||||
CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions,
|
||||
CoreEvent::LogLinesChanged(lines) => self.log_lines = lines,
|
||||
CoreEvent::LogLinesChanged(lines) => {
|
||||
self.log_lines = lines;
|
||||
self.live_session = None;
|
||||
}
|
||||
CoreEvent::LogAppended(batch) => self.append_live_lines(&batch),
|
||||
CoreEvent::LaunchFinished(finished) => {
|
||||
self.live_session = None;
|
||||
self.selected_session = Some(finished.session.clone());
|
||||
let kind = if finished.outcome == crate::dto::LogOutcome::Ok {
|
||||
ToastKind::Success
|
||||
} else {
|
||||
ToastKind::Error
|
||||
};
|
||||
self.toasts.push(kind, finished.summary);
|
||||
}
|
||||
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
|
||||
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
|
||||
CoreEvent::DeviceLoginChanged(view) => {
|
||||
if view.phase == crate::dto::DeviceLoginPhase::Succeeded {
|
||||
self.toasts
|
||||
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
|
||||
}
|
||||
if view.phase == crate::dto::DeviceLoginPhase::Failed {
|
||||
self.toasts.push(ToastKind::Error, view.message.clone());
|
||||
}
|
||||
self.device_login = view;
|
||||
}
|
||||
CoreEvent::JavaChanged(page) => self.java = page,
|
||||
CoreEvent::InstallProgress(progress) => match progress {
|
||||
Some(progress) => {
|
||||
let terminal = progress.stage.is_terminal();
|
||||
self.install = Some(progress);
|
||||
if terminal {
|
||||
self.wizard.open = false;
|
||||
self.wizard.submitting = false;
|
||||
}
|
||||
}
|
||||
None => self.install = None,
|
||||
},
|
||||
CoreEvent::SettingsChanged(groups) => self.settings_groups = groups,
|
||||
CoreEvent::NoticesChanged(notices) => self.notices = notices,
|
||||
}
|
||||
}
|
||||
|
||||
/// agent 事件。
|
||||
/// agent 事件(AI 会话的流式消息与工具调用)。
|
||||
pub fn apply_agent(&mut self, event: AgentEvent) {
|
||||
match event {
|
||||
AgentEvent::MessageAppended(message) => {
|
||||
@@ -242,6 +313,25 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。
|
||||
fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) {
|
||||
if self
|
||||
.live_session
|
||||
.as_deref()
|
||||
.is_some_and(|session| session != batch.session)
|
||||
{
|
||||
// 换了一个会话在跑:清掉上一场的行,避免两场日志混在一起
|
||||
self.log_lines.clear();
|
||||
}
|
||||
self.live_session = batch.running.then(|| batch.session.clone());
|
||||
self.selected_session = Some(batch.session.clone());
|
||||
self.log_lines.extend(batch.lines.iter().cloned());
|
||||
if self.log_lines.len() > MAX_LIVE_LINES {
|
||||
let drop = self.log_lines.len() - MAX_LIVE_LINES;
|
||||
self.log_lines.drain(..drop);
|
||||
}
|
||||
}
|
||||
|
||||
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
|
||||
pub fn tiles(&self) -> Vec<TileCell<'_>> {
|
||||
let query = self.instance_query.trim().to_lowercase();
|
||||
|
||||
@@ -23,6 +23,9 @@ fn instance(id: &str, name: &str) -> InstanceSummary {
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
//! 与模态对话框队列([`crate::dialogs`])分开:Toast 不阻塞、不排序、可叠加,
|
||||
//! 只做「先进先出 + 上限裁剪 + TTL 到期」。UI 侧由 Rust 定时器每帧调用 [`ToastCenter::expire`]。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Toast 标识。
|
||||
pub type ToastId = u64;
|
||||
|
||||
/// Toast 语义色。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToastKind {
|
||||
/// 一般信息
|
||||
Info,
|
||||
@@ -20,7 +22,7 @@ pub enum ToastKind {
|
||||
}
|
||||
|
||||
/// 一条 Toast;`remaining_ms` 由 [`ToastCenter::expire`] 递减。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Toast {
|
||||
/// 队列内唯一 id(由 [`ToastCenter::push_request`] 重新编号)
|
||||
pub id: ToastId,
|
||||
|
||||
+87
-88
@@ -14,14 +14,14 @@ use std::time::Duration;
|
||||
|
||||
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||
|
||||
use crate::backend::{build_backend, config_path, AppOptions};
|
||||
use crate::bg_image::BackgroundLayers;
|
||||
use crate::conv;
|
||||
use crate::dto::{AppCommand, Backend, InstanceId};
|
||||
use crate::mock::MockBackend;
|
||||
use crate::dto::{AppCommand, Backend};
|
||||
use crate::prefs::BgFill;
|
||||
use crate::settings_bridge::SettingsBridge;
|
||||
use crate::state::AppState;
|
||||
use crate::{MainWindow, SettingSection};
|
||||
use crate::{MainWindow, PageState, SettingSection};
|
||||
|
||||
/// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。
|
||||
const TICK_MS: u64 = 33;
|
||||
@@ -67,19 +67,28 @@ pub(crate) struct BackgroundKey {
|
||||
}
|
||||
|
||||
impl AppUi {
|
||||
/// 建状态 + 假后端、读配置、接全部回调、灌初始事件并做第一次同步。
|
||||
/// 建状态 + 真实后端、读配置、接全部回调、灌初始事件并做第一次同步。
|
||||
pub fn new(ui: MainWindow) -> Self {
|
||||
Self::with_options(ui, AppOptions::default())
|
||||
}
|
||||
|
||||
/// 带启动选项装配(数据根覆盖 / 强制 mock)。
|
||||
pub fn with_options(ui: MainWindow, options: AppOptions) -> Self {
|
||||
// 先读一次落盘配置:主题三态 + 外观四件必须在首帧前就位(否则会闪一下出厂外观)
|
||||
let bridge = SettingsBridge::open();
|
||||
let bridge = match &options.data_root {
|
||||
Some(root) => SettingsBridge::with_path(config_path(root)),
|
||||
None => SettingsBridge::open(),
|
||||
};
|
||||
let (appearance, theme) = bridge.load();
|
||||
let mut state = AppState::new();
|
||||
state.prefs.appearance = appearance;
|
||||
state.prefs.theme = theme;
|
||||
|
||||
let backend = build_backend(&options);
|
||||
let inner = Rc::new(Inner {
|
||||
ui,
|
||||
state: RefCell::new(state),
|
||||
backend: RefCell::new(Box::new(MockBackend::new())),
|
||||
backend: RefCell::new(backend),
|
||||
bridge: RefCell::new(bridge),
|
||||
background: RefCell::new(BackgroundLayers::default()),
|
||||
background_key: RefCell::new(None),
|
||||
@@ -88,6 +97,7 @@ impl AppUi {
|
||||
});
|
||||
|
||||
crate::callbacks::wire(Rc::downgrade(&inner));
|
||||
crate::callbacks_pages::wire_pages(Rc::downgrade(&inner));
|
||||
inner.drain_events();
|
||||
inner.align_system_theme();
|
||||
inner.apply_theme();
|
||||
@@ -134,6 +144,71 @@ impl AppUi {
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
|
||||
/// 直接跳到一个页面(截图验收用:每页一张)。
|
||||
pub fn open_page(&self, page: &str) {
|
||||
let route = match page {
|
||||
"download" | "java" => crate::router::Route::Download,
|
||||
"logs" => crate::router::Route::Logs,
|
||||
"settings" | "account" | "appearance" => crate::router::Route::Settings,
|
||||
"agent" => crate::router::Route::Agent,
|
||||
_ => crate::router::Route::Instances,
|
||||
};
|
||||
{
|
||||
let mut state = self.inner.state.borrow_mut();
|
||||
state.navigate(route);
|
||||
match page {
|
||||
"account" => state.settings_tab = "account".to_string(),
|
||||
"java" => state.settings_tab = "java".to_string(),
|
||||
"settings" => state.settings_tab = "download".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
let download_tab = i32::from(page == "java");
|
||||
self.inner
|
||||
.ui
|
||||
.global::<PageState<'_>>()
|
||||
.set_download_tab(download_tab);
|
||||
}
|
||||
match page {
|
||||
"account" => self.inner.ui.set_settings_section(SettingSection::Account),
|
||||
"java" => self.inner.ui.set_settings_section(SettingSection::Java),
|
||||
"appearance" => self
|
||||
.inner
|
||||
.ui
|
||||
.set_settings_section(SettingSection::Appearance),
|
||||
"settings" => self.inner.ui.set_settings_section(SettingSection::Download),
|
||||
_ => {}
|
||||
}
|
||||
self.inner.sync();
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
|
||||
/// 打开新建实例向导(截图验收用)。
|
||||
pub fn open_wizard(&self) {
|
||||
self.inner.state.borrow_mut().wizard = crate::wizard::WizardState::open();
|
||||
self.inner.sync();
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
|
||||
/// 打开一个日志会话(截图验收:日志行 + 诊断卡片 + 头部标题都要有值)。
|
||||
pub fn open_session(&self, id: &str) {
|
||||
self.inner.state.borrow_mut().selected_session = Some(id.to_string());
|
||||
self.inner
|
||||
.dispatch(AppCommand::OpenLogSession(id.to_string()));
|
||||
self.inner.sync();
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
|
||||
/// 派发一条指令并跑若干毫秒(截图中间态:安装进度 / 启动事件流)。
|
||||
pub fn dispatch_and_run(&self, command: AppCommand, millis: u64) {
|
||||
self.inner.dispatch(command);
|
||||
let steps = (millis / TICK_MS).max(1);
|
||||
for _ in 0..steps {
|
||||
self.inner.tick();
|
||||
slint::platform::update_timers_and_animations();
|
||||
std::thread::sleep(Duration::from_millis(TICK_MS));
|
||||
}
|
||||
}
|
||||
|
||||
/// 渲染当前场景并存成 PNG(`--screenshot` 通道;见 `crate::png`)。
|
||||
///
|
||||
/// 为什么要这条通道:验收环境可能没有交互桌面(`CopyFromScreen` 直接失败),
|
||||
@@ -150,8 +225,8 @@ impl AppUi {
|
||||
.set_size(slint::WindowSize::Logical(slint::LogicalSize::new(
|
||||
1180.0, 760.0,
|
||||
)));
|
||||
// 先走几拍:把 mock 事件(实例/版本/日志)与 33ms 节拍的效果灌进去
|
||||
for _ in 0..4 {
|
||||
// 先走几拍:把后端事件(实例/版本/日志/账户/设置)与 33ms 节拍的效果灌进去
|
||||
for _ in 0..8 {
|
||||
self.tick_once();
|
||||
std::thread::sleep(Duration::from_millis(TICK_MS));
|
||||
}
|
||||
@@ -245,89 +320,13 @@ impl Inner {
|
||||
ui.set_dialog(dialog);
|
||||
ui.set_dialog_open(open);
|
||||
ui.set_dialog_queued(queued);
|
||||
|
||||
// PageState(账户/Java/安装/设置/通知/向导)在独立模块里灌,守 400 行
|
||||
crate::page_sync::apply(ui, &state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slint 列表模型(VecModel → ModelRc)。
|
||||
pub(crate) fn model<T: Clone + 'static>(items: Vec<T>) -> ModelRc<T> {
|
||||
ModelRc::new(VecModel::from(items))
|
||||
}
|
||||
|
||||
/// 停止实例的指令构造(阶段 C 的磁贴「停止」键用;P0 只保留 API)。
|
||||
#[allow(dead_code)]
|
||||
pub fn stop_instance(id: &InstanceId) -> AppCommand {
|
||||
AppCommand::StopInstance(id.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::send_agent_message;
|
||||
use crate::dto::{Backend, ChatRole};
|
||||
use crate::mock::MockBackend;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 集成测试(callbacks→state→mock 全链路):发一次提问后
|
||||
/// ①用户消息恰出现一次(无本地回显重复)②所有 assistant 气泡收敛(无幽灵气泡)。
|
||||
///
|
||||
/// 分层单测漏掉这条正是 review P1-1 的根因,故这里用真实 mock 后端跑完整事件流。
|
||||
#[test]
|
||||
fn send_path_has_no_duplicate_user_message_and_no_ghost_stream() {
|
||||
let mut state = AppState::new();
|
||||
let mut backend = MockBackend::new();
|
||||
// 先灌入种子事件(实例/版本/日志快照)
|
||||
for event in backend.drain_events() {
|
||||
state.apply(event);
|
||||
}
|
||||
|
||||
send_agent_message(&mut state, &mut backend, "装哪个版本");
|
||||
|
||||
fn user_count(state: &AppState) -> usize {
|
||||
state
|
||||
.ai
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| message.role == ChatRole::User)
|
||||
.count()
|
||||
}
|
||||
assert_eq!(
|
||||
user_count(&state),
|
||||
1,
|
||||
"用户消息必须恰好一条(本地回显会造成两条)"
|
||||
);
|
||||
assert!(state.ai.streaming);
|
||||
|
||||
// 推进到流式收敛(mock 300ms/片 + 工具调用 + 收尾消息)
|
||||
for _ in 0..12 {
|
||||
backend.tick(Duration::from_millis(400));
|
||||
for event in backend.drain_events() {
|
||||
state.apply(event);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(user_count(&state), 1, "事件回流后用户消息仍应只有一条");
|
||||
assert!(
|
||||
state.ai.messages.iter().all(|message| !message.streaming),
|
||||
"所有气泡必须收敛(幽灵气泡会永久 streaming)"
|
||||
);
|
||||
assert!(!state.ai.streaming);
|
||||
assert!(
|
||||
state
|
||||
.ai
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role == ChatRole::Assistant && !message.text.is_empty()),
|
||||
"增量应累积到 assistant 正文"
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.ai
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| message.role == ChatRole::Assistant)
|
||||
.count(),
|
||||
2,
|
||||
"一轮对话 = 流式回复 + 收尾回复"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! 新建实例向导(09 §G5)的纯状态机:版本 → 加载器 → 实例名 → 确认 → 进度。
|
||||
//!
|
||||
//! 放纯 Rust 而不是 Slint 里:步骤推进、名字合法性、加载器约束都能单测,
|
||||
//! Slint 只负责画。提交时由 host 转成 [`crate::dto::CreateRequest`] 走安装管线。
|
||||
|
||||
use crate::dto::LoaderKind;
|
||||
|
||||
/// 向导步骤。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum WizardStep {
|
||||
/// 选版本
|
||||
#[default]
|
||||
Version,
|
||||
/// 选加载器
|
||||
Loader,
|
||||
/// 起名
|
||||
Name,
|
||||
/// 确认(显示将下载的项)
|
||||
Confirm,
|
||||
/// 进度(提交后由安装进度接管)
|
||||
Progress,
|
||||
}
|
||||
|
||||
impl WizardStep {
|
||||
/// 步骤序号(0 起,用于「第 N 步」文案)。
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Version => 0,
|
||||
Self::Loader => 1,
|
||||
Self::Name => 2,
|
||||
Self::Confirm => 3,
|
||||
Self::Progress => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// 步骤标题。
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Version => "选择版本",
|
||||
Self::Loader => "选择加载器",
|
||||
Self::Name => "实例名",
|
||||
Self::Confirm => "确认",
|
||||
Self::Progress => "安装中",
|
||||
}
|
||||
}
|
||||
|
||||
/// 下一步(进度是终点)。
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Version | Self::Progress => {
|
||||
if self == Self::Progress {
|
||||
Self::Progress
|
||||
} else {
|
||||
Self::Loader
|
||||
}
|
||||
}
|
||||
Self::Loader => Self::Name,
|
||||
Self::Name => Self::Confirm,
|
||||
Self::Confirm => Self::Progress,
|
||||
}
|
||||
}
|
||||
|
||||
/// 上一步(版本是起点)。
|
||||
pub fn back(self) -> Self {
|
||||
match self {
|
||||
Self::Version | Self::Progress => Self::Version,
|
||||
Self::Loader => Self::Version,
|
||||
Self::Name => Self::Loader,
|
||||
Self::Confirm => Self::Name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 向导状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WizardState {
|
||||
/// 是否打开(全屏模态)
|
||||
pub open: bool,
|
||||
/// 当前步骤
|
||||
pub step: WizardStep,
|
||||
/// 选中的版本 id
|
||||
pub version: String,
|
||||
/// 选中的加载器
|
||||
pub loader: LoaderKind,
|
||||
/// 加载器版本(空 = 最新)
|
||||
pub loader_version: String,
|
||||
/// 实例名草稿
|
||||
pub name: String,
|
||||
/// 校验错误(显示在向导内,不弹 Toast)
|
||||
pub error: String,
|
||||
/// 已提交(防重复点)
|
||||
pub submitting: bool,
|
||||
}
|
||||
|
||||
impl Default for WizardState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WizardState {
|
||||
/// 关闭态。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
open: false,
|
||||
step: WizardStep::Version,
|
||||
version: String::new(),
|
||||
loader: LoaderKind::Vanilla,
|
||||
loader_version: String::new(),
|
||||
name: String::new(),
|
||||
error: String::new(),
|
||||
submitting: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开向导并复位。
|
||||
pub fn open() -> Self {
|
||||
Self {
|
||||
open: true,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 选了版本:自动把实例名填成版本号(用户不改就用它)。
|
||||
pub fn select_version(&mut self, version: &str) {
|
||||
self.version = version.to_string();
|
||||
if self.name.trim().is_empty() {
|
||||
self.name = version.to_string();
|
||||
}
|
||||
self.error.clear();
|
||||
}
|
||||
|
||||
/// 选加载器(原版会清掉加载器版本)。
|
||||
pub fn select_loader(&mut self, loader: LoaderKind) {
|
||||
self.loader = loader;
|
||||
if loader == LoaderKind::Vanilla {
|
||||
self.loader_version.clear();
|
||||
}
|
||||
self.error.clear();
|
||||
}
|
||||
|
||||
/// 推进到下一步;校验不通过时留在原地并写 `error`。
|
||||
pub fn advance(&mut self) -> bool {
|
||||
match self.step {
|
||||
WizardStep::Version => {
|
||||
if self.version.trim().is_empty() {
|
||||
self.error = "请先选择一个版本".to_string();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
WizardStep::Name => {
|
||||
if let Err(error) = validate_name(&self.name) {
|
||||
self.error = error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
WizardStep::Loader | WizardStep::Confirm | WizardStep::Progress => {}
|
||||
}
|
||||
self.error.clear();
|
||||
self.step = self.step.next();
|
||||
true
|
||||
}
|
||||
|
||||
/// 回退一步。
|
||||
pub fn step_back(&mut self) {
|
||||
self.error.clear();
|
||||
self.step = self.step.back();
|
||||
}
|
||||
|
||||
/// 提交前的最后校验(Confirm 步)。
|
||||
pub fn ready_to_submit(&self) -> Result<(), String> {
|
||||
if self.version.trim().is_empty() {
|
||||
return Err("版本为空".to_string());
|
||||
}
|
||||
validate_name(&self.name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 提交成功后进入进度步(关掉可编辑态)。
|
||||
pub fn submitted(&mut self) {
|
||||
self.submitting = true;
|
||||
self.step = WizardStep::Progress;
|
||||
self.error.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例名合法性(不能空、不能含路径分隔符/控制字符、长度上限 64)。
|
||||
pub fn validate_name(name: &str) -> Result<(), String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("实例名不能为空".to_string());
|
||||
}
|
||||
if trimmed.chars().count() > 64 {
|
||||
return Err("实例名最多 64 个字符".to_string());
|
||||
}
|
||||
if trimmed.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
|
||||
return Err("实例名不能包含 / \\ : * ? \" < > |".to_string());
|
||||
}
|
||||
if trimmed.chars().any(|ch| ch.is_control()) {
|
||||
return Err("实例名不能包含控制字符".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn steps_walk_forward_and_back() {
|
||||
assert_eq!(WizardStep::Version.next(), WizardStep::Loader);
|
||||
assert_eq!(WizardStep::Loader.next(), WizardStep::Name);
|
||||
assert_eq!(WizardStep::Name.next(), WizardStep::Confirm);
|
||||
assert_eq!(WizardStep::Confirm.next(), WizardStep::Progress);
|
||||
assert_eq!(WizardStep::Progress.next(), WizardStep::Progress);
|
||||
assert_eq!(WizardStep::Confirm.back(), WizardStep::Name);
|
||||
assert_eq!(WizardStep::Version.back(), WizardStep::Version);
|
||||
assert_eq!(WizardStep::Progress.index(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_selection_fills_name_but_does_not_overwrite() {
|
||||
let mut wizard = WizardState::open();
|
||||
assert!(wizard.open);
|
||||
wizard.select_version("1.20.4");
|
||||
assert_eq!(wizard.name, "1.20.4");
|
||||
wizard.name = "生存".to_string();
|
||||
wizard.select_version("1.21");
|
||||
assert_eq!(wizard.name, "生存", "用户改过的名字不该被覆盖");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_requires_a_version_and_a_valid_name() {
|
||||
let mut wizard = WizardState::open();
|
||||
assert!(!wizard.advance(), "没有版本不能进下一步");
|
||||
assert_eq!(wizard.error, "请先选择一个版本");
|
||||
|
||||
wizard.select_version("1.20.4");
|
||||
assert!(wizard.advance());
|
||||
assert_eq!(wizard.step, WizardStep::Loader);
|
||||
assert!(wizard.advance());
|
||||
assert_eq!(wizard.step, WizardStep::Name);
|
||||
|
||||
wizard.name = "a/b".to_string();
|
||||
assert!(!wizard.advance(), "非法名字不能确认");
|
||||
assert!(wizard.error.contains("不能包含"));
|
||||
|
||||
wizard.name = "生存".to_string();
|
||||
assert!(wizard.advance());
|
||||
assert_eq!(wizard.step, WizardStep::Confirm);
|
||||
assert!(wizard.ready_to_submit().is_ok());
|
||||
wizard.submitted();
|
||||
assert_eq!(wizard.step, WizardStep::Progress);
|
||||
assert!(wizard.submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_selection_clears_loader_version_for_vanilla() {
|
||||
let mut wizard = WizardState::open();
|
||||
wizard.loader_version = "0.16.5".to_string();
|
||||
wizard.select_loader(LoaderKind::Fabric);
|
||||
assert_eq!(wizard.loader_version, "0.16.5");
|
||||
wizard.select_loader(LoaderKind::Vanilla);
|
||||
assert_eq!(wizard.loader, LoaderKind::Vanilla);
|
||||
assert!(wizard.loader_version.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_validation_rejects_long_and_control_input() {
|
||||
assert!(validate_name("生存").is_ok());
|
||||
assert!(validate_name("").is_err());
|
||||
assert!(validate_name(&"x".repeat(65)).is_err());
|
||||
assert!(validate_name("a\u{7}b").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 安装进度卡片(下载页底部 / 向导进度步共用):阶段 + 进度条 + 文件/字节/速度/剩余 + 取消。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiInstallProgress, InstallStage } from "../models.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { ProgressBar } from "../components/progress-bar.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
|
||||
export component InstallPanel inherits Rectangle {
|
||||
in property <UiInstallProgress> data;
|
||||
callback cancel();
|
||||
|
||||
height: 96px;
|
||||
visible: root.data.visible;
|
||||
background: Theme.bg-surface;
|
||||
border-width: 1px;
|
||||
border-color: Theme.stroke;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Metrics.gap-3;
|
||||
spacing: Metrics.gap-1;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
AppIcon {
|
||||
icon-size: 16px;
|
||||
kind: root.data.failed ? IconKind.error
|
||||
: root.data.terminal ? IconKind.check
|
||||
: IconKind.download;
|
||||
tint: root.data.failed ? Theme.state-danger
|
||||
: root.data.terminal ? Theme.state-success
|
||||
: Theme.accent;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: root.data.instance + " · " + root.data.loader;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.stage_label;
|
||||
color: root.data.failed ? Theme.state-danger : Theme.fg-secondary;
|
||||
}
|
||||
if !root.data.terminal : MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "取消";
|
||||
button-height: 24px;
|
||||
clicked => { root.cancel(); }
|
||||
}
|
||||
}
|
||||
|
||||
ProgressBar {
|
||||
ratio: root.data.ratio;
|
||||
known: root.data.known;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-3;
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.message;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
UiText { role: TextRole.caption; text: root.data.files_label; }
|
||||
UiText { role: TextRole.caption; text: root.data.bytes_label; }
|
||||
UiText { role: TextRole.caption; text: root.data.speed; color: Theme.accent; }
|
||||
UiText { role: TextRole.caption; text: root.data.eta; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 横幅通知条:隔离档位切换提示(M30 ModeChangeWarning)/ 启动失败 / 启动告警。
|
||||
// 与 Toast 的分工:Toast 是「刚发生了什么」,通知条是「现在需要你处理」(用户点了才收)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiNotice, NoticeKind } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
|
||||
// 横幅通知:隔离档位切换提示(M30 ModeChangeWarning)/ 启动失败 / 启动告警。
|
||||
// 与 Toast 的分工:Toast 是「刚发生了什么」,通知条是「现在需要你处理」。
|
||||
export component NoticeBar inherits Rectangle {
|
||||
in property <[UiNotice]> notices;
|
||||
|
||||
visible: root.notices.length > 0;
|
||||
height: root.notices.length > 0 ? 48px : 0px;
|
||||
background: Theme.state-warn-soft;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
for notice in root.notices : Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
background: transparent;
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-2;
|
||||
AppIcon {
|
||||
icon-size: 16px;
|
||||
kind: notice.kind == NoticeKind.error ? IconKind.error : IconKind.warn;
|
||||
tint: notice.kind == NoticeKind.error ? Theme.state-danger : Theme.state-warn;
|
||||
}
|
||||
VerticalLayout {
|
||||
vertical-stretch: 1;
|
||||
spacing: 0px;
|
||||
UiText { role: TextRole.caption; text: notice.title; horizontal-stretch: 1; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: notice.body;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
color: Theme.fg-secondary;
|
||||
}
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "知道了";
|
||||
button-height: 22px;
|
||||
clicked => { PageState.notice-dismiss(notice.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 细进度条(0.0~1.0):安装进度 / 下载页顶部 / 向导进度共用。
|
||||
// 未知总量(known = false)时画一条不确定态的弱色轨道,而不是假装 0%。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
|
||||
export component ProgressBar inherits Rectangle {
|
||||
in property <float> ratio: 0.0;
|
||||
in property <bool> known: true;
|
||||
in property <length> bar-height: 6px;
|
||||
|
||||
height: root.bar-height;
|
||||
border-radius: root.bar-height / 2;
|
||||
background: Theme.bg-hover;
|
||||
clip: true;
|
||||
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: root.known ? max(0px, root.width * clamp(root.ratio, 0.0, 1.0)) : 0px;
|
||||
height: root.height;
|
||||
border-radius: root.bar-height / 2;
|
||||
background: Theme.accent;
|
||||
animate width { duration: 180ms; easing: ease-out; }
|
||||
}
|
||||
|
||||
if !root.known : Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: root.width / 3;
|
||||
height: root.height;
|
||||
border-radius: root.bar-height / 2;
|
||||
background: Theme.accent-soft;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
// 亮/暗双套色板(LightPalette/DarkPalette)、主题三态解析、排版 ramp 与字体栈;M11 的占位
|
||||
// NavItem/Tile 组件由 `components/nav.slint` 与 `components/tile.slint` 取代,占位内核不再保留。
|
||||
|
||||
// P3:页面状态 global 从根文件再导出一遍,Rust 侧才能拿到 `PageState` 类型。
|
||||
export { PageState } from "page_state.slint";
|
||||
import { AppShell } from "shell.slint";
|
||||
import { Theme } from "theme/tokens.slint";
|
||||
import { Metrics } from "theme/metrics.slint";
|
||||
|
||||
@@ -216,3 +216,149 @@ export struct UiToast {
|
||||
kind: ToastKind,
|
||||
text: string,
|
||||
}
|
||||
|
||||
// ── P3 全量接线的视图模型(账户 / Java / 安装 / 设置分组 / 通知 / 向导)──────────
|
||||
// 这些类型由 Rust 的 dto 单向转换(`conv/pages.rs`),Slint 只读不写业务真相。
|
||||
// 全部经 `page_state.slint` 的 `PageState` global 下发(避免 main/shell 的属性转发膨胀)。
|
||||
|
||||
export enum AccountKind {
|
||||
offline,
|
||||
microsoft,
|
||||
authlib,
|
||||
}
|
||||
|
||||
export struct UiAccount {
|
||||
id: string,
|
||||
name: string,
|
||||
kind: AccountKind,
|
||||
kind_label: string,
|
||||
profile_id: string,
|
||||
login_name: string,
|
||||
created: string,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// 微软登录面板(用户码 / 授权链接都不是凭据,可直接展示)。
|
||||
export struct UiDeviceCode {
|
||||
visible: bool,
|
||||
busy: bool,
|
||||
user_code: string,
|
||||
verification_uri: string,
|
||||
qr_link: string,
|
||||
expires: int,
|
||||
message: string,
|
||||
phase_label: string,
|
||||
}
|
||||
|
||||
export enum JavaStepOutcome {
|
||||
pending,
|
||||
skip,
|
||||
failed,
|
||||
hit,
|
||||
}
|
||||
|
||||
export struct UiJavaStep {
|
||||
vendor: string,
|
||||
outcome: JavaStepOutcome,
|
||||
outcome_label: string,
|
||||
detail: string,
|
||||
}
|
||||
|
||||
export struct UiJavaVendor {
|
||||
id: string,
|
||||
display: string,
|
||||
rank: int,
|
||||
majors: string,
|
||||
sources: string,
|
||||
installed: bool,
|
||||
}
|
||||
|
||||
export struct UiJavaRuntime {
|
||||
binary: string,
|
||||
version: string,
|
||||
vendor: string,
|
||||
origin: string,
|
||||
}
|
||||
|
||||
export enum InstallStage {
|
||||
planning,
|
||||
downloading,
|
||||
installing,
|
||||
registering,
|
||||
done,
|
||||
failed,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
export struct UiInstallProgress {
|
||||
visible: bool,
|
||||
version: string,
|
||||
instance: string,
|
||||
loader: string,
|
||||
stage: InstallStage,
|
||||
stage_label: string,
|
||||
ratio: float,
|
||||
known: bool,
|
||||
files_label: string,
|
||||
bytes_label: string,
|
||||
speed: string,
|
||||
eta: string,
|
||||
message: string,
|
||||
terminal: bool,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
export enum SettingControl {
|
||||
toggle,
|
||||
choice,
|
||||
text,
|
||||
readonly,
|
||||
action,
|
||||
}
|
||||
|
||||
export struct UiSettingRow {
|
||||
key: string,
|
||||
label: string,
|
||||
description: string,
|
||||
control: SettingControl,
|
||||
flag: bool,
|
||||
selected: int,
|
||||
options: [string],
|
||||
value_text: string,
|
||||
action: string,
|
||||
}
|
||||
|
||||
export struct UiSettingGroup {
|
||||
id: string,
|
||||
title: string,
|
||||
rows: [UiSettingRow],
|
||||
}
|
||||
|
||||
export enum NoticeKind {
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
}
|
||||
|
||||
export struct UiNotice {
|
||||
id: string,
|
||||
kind: NoticeKind,
|
||||
title: string,
|
||||
body: string,
|
||||
}
|
||||
|
||||
export struct UiWizard {
|
||||
open: bool,
|
||||
step: int,
|
||||
step_label: string,
|
||||
step_index: int,
|
||||
step_total: int,
|
||||
version: string,
|
||||
loader: int,
|
||||
loader_label: string,
|
||||
loader_version: string,
|
||||
name: string,
|
||||
error: string,
|
||||
busy: bool,
|
||||
can-next: bool,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// P3 全量接线的页面状态:一个导出的 global,Slint 任何页面都能直接读/回推。
|
||||
//
|
||||
// 为什么用 global 而不是继续经 main.slint → shell.slint 逐层转发:属性表已经 100+ 条,
|
||||
// 再加五页的数据会把 main.slint 顶破 400 行上限(AGENTS §3)。global 在同一个 Slint
|
||||
// 编译单元内是单例,Rust 侧用 `ui.global::<PageState>()` 读写,语义与 property 相同。
|
||||
//
|
||||
// 纪律:这里只放「页面要显示什么」与「用户点了什么」,业务真相在 Rust 的 state.rs。
|
||||
import { UiAccount, UiDeviceCode, UiJavaVendor, UiJavaStep, UiJavaRuntime, UiInstallProgress, UiSettingGroup, UiNotice, UiWizard, UiVersion } from "models.slint";
|
||||
|
||||
export global PageState {
|
||||
// ── 账户页 ────────────────────────────────────────────────
|
||||
in-out property <[UiAccount]> accounts;
|
||||
in property <string> active-account: "";
|
||||
in property <UiDeviceCode> device;
|
||||
/// 离线添加表单的输入(不落盘,点「添加」才提交)
|
||||
in-out property <string> offline-name: "";
|
||||
|
||||
// ── Java(下载页 Java 分组 / 设置页 Java 分组)──────────────
|
||||
in property <[UiJavaVendor]> java-vendors;
|
||||
in property <[UiJavaStep]> java-chain;
|
||||
in property <[UiJavaRuntime]> java-runtimes;
|
||||
in property <string> java-order: "";
|
||||
in property <string> java-region: "";
|
||||
in property <string> java-priority-source: "";
|
||||
in property <string> java-message: "";
|
||||
in property <bool> java-busy: false;
|
||||
in property <int> java-target-major: 17;
|
||||
|
||||
// ── 安装进度(下载页 + 向导共用)────────────────────────────
|
||||
in property <UiInstallProgress> install;
|
||||
/// 下载页右侧视图:0 = 版本列表,1 = Java 运行时(截图通道也从这里切)
|
||||
in-out property <int> download-tab: 0;
|
||||
|
||||
// ── 设置分组(settings_bridge 同源 launcher.json)──────────
|
||||
in property <[UiSettingGroup]> setting-groups;
|
||||
in-out property <string> setting-tab: "download";
|
||||
|
||||
// ── 通知条 / 向导 ─────────────────────────────────────────
|
||||
in property <[UiNotice]> notices;
|
||||
in property <UiWizard> wizard;
|
||||
in property <[UiVersion]> wizard-versions;
|
||||
|
||||
// ── 账户 ──────────────────────────────────────────────────
|
||||
callback account-add-offline(string);
|
||||
callback account-remove(string);
|
||||
callback account-switch(string);
|
||||
callback msa-begin();
|
||||
callback msa-browser-begin();
|
||||
callback msa-cancel();
|
||||
|
||||
// ── Java ──────────────────────────────────────────────────
|
||||
callback java-install(string);
|
||||
|
||||
// ── 设置 ──────────────────────────────────────────────────
|
||||
callback setting-changed(string, string);
|
||||
callback setting-action(string);
|
||||
callback settings-tab-changed(string);
|
||||
|
||||
// ── 通知 ──────────────────────────────────────────────────
|
||||
callback notice-dismiss(string);
|
||||
|
||||
// ── 向导 ──────────────────────────────────────────────────
|
||||
callback wizard-open();
|
||||
callback wizard-close();
|
||||
callback wizard-select-version(string);
|
||||
callback wizard-select-loader(int);
|
||||
callback wizard-name-changed(string);
|
||||
callback wizard-next();
|
||||
callback wizard-back();
|
||||
callback wizard-confirm();
|
||||
callback install-cancel();
|
||||
|
||||
// ── 日志页:崩溃日志导入 / 手动刷新 ──────────────────────────
|
||||
in-out property <string> crash-path: "";
|
||||
callback crash-import(string);
|
||||
callback logs-refresh();
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// 下载页 · Java 分组(G2 的第二个分类):四家发行版 + 回退链可视化 + 本地运行时。
|
||||
//
|
||||
// 回退链是「可观测性」核心:安装失败时用户要能看见 Temurin 跳过 → Oracle 失败 →
|
||||
// Microsoft 命中的每一步原因(D23),而不是只看到一句「下载失败」。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiJavaVendor, UiJavaStep, UiJavaRuntime, JavaStepOutcome } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { Badge } from "../components/badge.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
|
||||
component ChainStep inherits Rectangle {
|
||||
in property <UiJavaStep> step;
|
||||
|
||||
height: 26px;
|
||||
background: transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-1;
|
||||
|
||||
AppIcon {
|
||||
icon-size: 12px;
|
||||
kind: root.step.outcome == JavaStepOutcome.hit ? IconKind.check
|
||||
: root.step.outcome == JavaStepOutcome.failed ? IconKind.error
|
||||
: root.step.outcome == JavaStepOutcome.skip ? IconKind.warn
|
||||
: IconKind.more;
|
||||
tint: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success
|
||||
: root.step.outcome == JavaStepOutcome.failed ? Theme.state-danger
|
||||
: Theme.fg-secondary;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.step.vendor;
|
||||
width: 72px;
|
||||
color: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success : Theme.fg-primary;
|
||||
}
|
||||
Badge {
|
||||
text: root.step.outcome_label;
|
||||
tint: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success
|
||||
: root.step.outcome == JavaStepOutcome.failed ? Theme.state-danger
|
||||
: Theme.fg-secondary;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.step.detail;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component VendorRow inherits Rectangle {
|
||||
in property <UiJavaVendor> data;
|
||||
callback install();
|
||||
|
||||
height: 56px;
|
||||
background: vendor-touch.has-hover ? Theme.bg-hover : transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: root.data.rank + ". " + root.data.display;
|
||||
width: 170px;
|
||||
}
|
||||
if root.data.installed : Badge { text: "已装"; tint: Theme.state-success; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "Java " + root.data.majors;
|
||||
width: 96px;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.sources;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "安装此家";
|
||||
button-height: 26px;
|
||||
enabled: !PageState.java-busy;
|
||||
clicked => { root.install(); }
|
||||
}
|
||||
}
|
||||
|
||||
vendor-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.install(); }
|
||||
}
|
||||
}
|
||||
|
||||
export component JavaPanel inherits Rectangle {
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
VerticalLayout {
|
||||
// 顶栏:地区 / 优先级来源 / 目标大版本 / 一键按回退链安装
|
||||
Rectangle {
|
||||
height: 56px;
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
UiText {
|
||||
role: TextRole.subtitle;
|
||||
text: "Java 运行时";
|
||||
width: 110px;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "地区 " + PageState.java-region + " · 优先级 " + PageState.java-priority-source
|
||||
+ " · 目标 Java " + PageState.java-target-major;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.accent;
|
||||
text: PageState.java-busy ? "安装中…" : "按回退链安装";
|
||||
button-height: 28px;
|
||||
enabled: !PageState.java-busy;
|
||||
clicked => { PageState.java-install(""); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.caption;
|
||||
text: PageState.java-order;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
if PageState.java-message != "" : Rectangle {
|
||||
height: 30px;
|
||||
background: Theme.accent-soft;
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.caption;
|
||||
text: PageState.java-message;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
ScrollView {
|
||||
vertical-stretch: 1;
|
||||
|
||||
VerticalLayout {
|
||||
padding-bottom: Metrics.gap-4;
|
||||
|
||||
// 回退链
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.bodyStrong;
|
||||
text: "回退链";
|
||||
}
|
||||
for step in PageState.java-chain : ChainStep { step: step; }
|
||||
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.bodyStrong;
|
||||
text: "四家发行版";
|
||||
}
|
||||
for vendor in PageState.java-vendors : VendorRow {
|
||||
data: vendor;
|
||||
install => { PageState.java-install(vendor.id); }
|
||||
}
|
||||
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.bodyStrong;
|
||||
text: "本地已发现";
|
||||
}
|
||||
if PageState.java-runtimes.length == 0 : UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.caption;
|
||||
text: "还没有探测到 Java:点上面的「按回退链安装」装一个托管 JDK。";
|
||||
}
|
||||
for runtime in PageState.java-runtimes : Rectangle {
|
||||
height: 24px;
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText { role: TextRole.caption; text: "Java " + runtime.version; width: 120px; }
|
||||
UiText { role: TextRole.caption; text: runtime.vendor; width: 96px; }
|
||||
UiText { role: TextRole.caption; text: runtime.origin; width: 80px; }
|
||||
UiText { role: TextRole.caption; text: runtime.binary; horizontal-stretch: 1; overflow: elide; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
-124
@@ -1,14 +1,19 @@
|
||||
// G2 下载页(09 §2 P0·G2 / Q24-4 定案):页内独立左侧分类栏(常驻,非 tab)+ 右侧版本列表。
|
||||
// 远古版入口显式可达(ULL 对 HMCL「隐藏」行为的纠偏),下挂 classic/alpha/beta/unlisted 子类树,
|
||||
// 进入远古版分类时顶部提示兼容性 + 每行带「远古版」徽标(防误装)。
|
||||
//
|
||||
// P3 接线:右侧多一个「Java 运行时」视图(四家发行版 + 回退链),页面底部常驻安装进度条。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiVersion, VersionKind, DownloadCategory } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { Badge } from "../components/badge.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { SearchField, CheckBox } from "../widgets/inputs.slint";
|
||||
import { InstallPanel } from "../components/install-panel.slint";
|
||||
import { JavaPanel } from "download-java.slint";
|
||||
import { ListView } from "std-widgets.slint";
|
||||
|
||||
component CategoryRow inherits Rectangle {
|
||||
@@ -112,6 +117,8 @@ export component DownloadPage inherits Rectangle {
|
||||
in-out property <string> query: "";
|
||||
/// 远古版子类树是否展开(默认展开,显式可达)
|
||||
in-out property <bool> ancient-expanded: true;
|
||||
/// 右侧视图:0 = 版本列表,1 = Java 运行时
|
||||
in-out property <int> view-tab <=> PageState.download-tab;
|
||||
|
||||
callback category-changed(DownloadCategory);
|
||||
callback install(string);
|
||||
@@ -123,148 +130,191 @@ export component DownloadPage inherits Rectangle {
|
||||
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
HorizontalLayout {
|
||||
// ── 页内分类侧栏(常驻)───────────────────────────────────
|
||||
VerticalLayout {
|
||||
// 视图切换(版本 / Java)
|
||||
Rectangle {
|
||||
width: 200px;
|
||||
height: 40px;
|
||||
background: Theme.bg-surface;
|
||||
border-width: 1px;
|
||||
border-color: Theme.stroke;
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText {
|
||||
role: TextRole.subtitle;
|
||||
text: "版本获取";
|
||||
width: 96px;
|
||||
}
|
||||
MetroButton {
|
||||
kind: root.view-tab == 0 ? ButtonKind.accent : ButtonKind.subtle;
|
||||
text: "版本列表";
|
||||
button-height: 26px;
|
||||
clicked => { root.view-tab = 0; }
|
||||
}
|
||||
MetroButton {
|
||||
kind: root.view-tab == 1 ? ButtonKind.accent : ButtonKind.subtle;
|
||||
text: "Java 运行时";
|
||||
button-height: 26px;
|
||||
clicked => { root.view-tab = 1; }
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
if PageState.java-busy : UiText {
|
||||
role: TextRole.caption;
|
||||
text: "Java 安装中…";
|
||||
color: Theme.accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
if root.view-tab == 0 : HorizontalLayout {
|
||||
vertical-stretch: 1;
|
||||
|
||||
// ── 页内分类侧栏(常驻)───────────────────────────────────
|
||||
Rectangle {
|
||||
width: 200px;
|
||||
background: Theme.bg-surface;
|
||||
border-width: 1px;
|
||||
border-color: Theme.stroke;
|
||||
|
||||
VerticalLayout {
|
||||
padding-top: Metrics.gap-2;
|
||||
|
||||
CategoryRow {
|
||||
label: "正式版 release";
|
||||
active: root.category == DownloadCategory.release;
|
||||
clicked => { root.category-changed(DownloadCategory.release); }
|
||||
}
|
||||
CategoryRow {
|
||||
label: "预览版 snapshot";
|
||||
active: root.category == DownloadCategory.snapshot;
|
||||
clicked => { root.category-changed(DownloadCategory.snapshot); }
|
||||
}
|
||||
|
||||
// 远古版:可展开的父节点 + 子类树
|
||||
CategoryRow {
|
||||
label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸";
|
||||
active: root.category == DownloadCategory.ancientClassic
|
||||
|| root.category == DownloadCategory.ancientAlpha
|
||||
|| root.category == DownloadCategory.ancientBeta
|
||||
|| root.category == DownloadCategory.ancientUnlisted;
|
||||
clicked => { root.toggle-ancient(); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "classic(rd-*)";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientClassic;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientClassic); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "old_alpha(a1.*)";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientAlpha;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientAlpha); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "old_beta(b1.*)";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientBeta;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientBeta); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "unlisted 汇总";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientUnlisted;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientUnlisted); }
|
||||
}
|
||||
|
||||
CategoryRow {
|
||||
label: "整合包(P1)";
|
||||
disabled: true;
|
||||
}
|
||||
CategoryRow {
|
||||
label: "Mod(P1)";
|
||||
disabled: true;
|
||||
}
|
||||
|
||||
Rectangle { vertical-stretch: 1; background: transparent; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 右侧版本列表 ──────────────────────────────────────────
|
||||
VerticalLayout {
|
||||
padding-top: Metrics.gap-2;
|
||||
vertical-stretch: 1;
|
||||
|
||||
CategoryRow {
|
||||
label: "正式版 release";
|
||||
active: root.category == DownloadCategory.release;
|
||||
clicked => { root.category-changed(DownloadCategory.release); }
|
||||
}
|
||||
CategoryRow {
|
||||
label: "预览版 snapshot";
|
||||
active: root.category == DownloadCategory.snapshot;
|
||||
clicked => { root.category-changed(DownloadCategory.snapshot); }
|
||||
Rectangle {
|
||||
height: Metrics.page-header-height;
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
SearchField {
|
||||
width: 220px;
|
||||
placeholder: "筛选版本号";
|
||||
text <=> root.query;
|
||||
edited(t) => { root.query-changed(t); }
|
||||
}
|
||||
CheckBox {
|
||||
text: "仅已装";
|
||||
checked: root.installed-only;
|
||||
toggled(v) => { root.installed-only-changed(v); }
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
}
|
||||
}
|
||||
|
||||
// 远古版:可展开的父节点 + 子类树
|
||||
CategoryRow {
|
||||
label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸";
|
||||
active: root.category == DownloadCategory.ancientClassic
|
||||
// 远古版兼容性提示条(首次进入显示,显式可达的代价告知)
|
||||
Rectangle {
|
||||
height: 44px;
|
||||
visible: root.category == DownloadCategory.ancientClassic
|
||||
|| root.category == DownloadCategory.ancientAlpha
|
||||
|| root.category == DownloadCategory.ancientBeta
|
||||
|| root.category == DownloadCategory.ancientUnlisted;
|
||||
clicked => { root.toggle-ancient(); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "classic(rd-*)";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientClassic;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientClassic); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "old_alpha(a1.*)";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientAlpha;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientAlpha); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "old_beta(b1.*)";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientBeta;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientBeta); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "unlisted 汇总";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientUnlisted;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientUnlisted); }
|
||||
background: Theme.state-warn-soft;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
AppIcon { kind: IconKind.warn; icon-size: 16px; tint: Theme.state-warn; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "远古版(classic/alpha/beta)需要旧版 Java 与旧启动参数,装完请先看兼容性说明。";
|
||||
wrap: word-wrap;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "说明";
|
||||
button-height: 26px;
|
||||
clicked => { root.ancient-help(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CategoryRow {
|
||||
label: "整合包(P1)";
|
||||
disabled: true;
|
||||
}
|
||||
CategoryRow {
|
||||
label: "Mod(P1)";
|
||||
disabled: true;
|
||||
}
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
Rectangle { vertical-stretch: 1; background: transparent; }
|
||||
ListView {
|
||||
vertical-stretch: 1;
|
||||
|
||||
for v in root.versions : VersionRow {
|
||||
data: v;
|
||||
install => { root.install(v.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 右侧版本列表 ──────────────────────────────────────────
|
||||
VerticalLayout {
|
||||
if root.view-tab == 1 : JavaPanel {
|
||||
vertical-stretch: 1;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: Metrics.page-header-height;
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
UiText {
|
||||
role: TextRole.subtitle;
|
||||
text: "版本获取";
|
||||
width: 96px;
|
||||
}
|
||||
SearchField {
|
||||
width: 220px;
|
||||
placeholder: "筛选版本号";
|
||||
text <=> root.query;
|
||||
edited(t) => { root.query-changed(t); }
|
||||
}
|
||||
CheckBox {
|
||||
text: "仅已装";
|
||||
checked: root.installed-only;
|
||||
toggled(v) => { root.installed-only-changed(v); }
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
}
|
||||
}
|
||||
|
||||
// 远古版兼容性提示条(首次进入显示,显式可达的代价告知)
|
||||
Rectangle {
|
||||
height: 44px;
|
||||
visible: root.category == DownloadCategory.ancientClassic
|
||||
|| root.category == DownloadCategory.ancientAlpha
|
||||
|| root.category == DownloadCategory.ancientBeta
|
||||
|| root.category == DownloadCategory.ancientUnlisted;
|
||||
background: Theme.state-warn-soft;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
AppIcon { kind: IconKind.warn; icon-size: 16px; tint: Theme.state-warn; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "远古版(classic/alpha/beta)需要旧版 Java 与旧启动参数,装完请先看兼容性说明。";
|
||||
wrap: word-wrap;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "说明";
|
||||
button-height: 26px;
|
||||
clicked => { root.ancient-help(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
ListView {
|
||||
vertical-stretch: 1;
|
||||
|
||||
for v in root.versions : VersionRow {
|
||||
data: v;
|
||||
install => { root.install(v.id); }
|
||||
}
|
||||
}
|
||||
// 安装进度(下载页行内安装 / 向导共用同一份进度)
|
||||
InstallPanel {
|
||||
data: PageState.install;
|
||||
cancel => { PageState.install-cancel(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { Badge } from "../components/badge.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { SearchField } from "../widgets/inputs.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { ListView } from "std-widgets.slint";
|
||||
|
||||
component SessionRow inherits Rectangle {
|
||||
@@ -106,6 +107,8 @@ export component LogsPage inherits Rectangle {
|
||||
callback ask-ai(string);
|
||||
callback apply-fix();
|
||||
callback query-changed(string);
|
||||
/// 重新扫会话索引(手动刷新)
|
||||
callback refresh();
|
||||
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
@@ -157,30 +160,57 @@ export component LogsPage inherits Rectangle {
|
||||
VerticalLayout {
|
||||
vertical-stretch: 1;
|
||||
|
||||
// 会话头:标题 + 实时标记 + 筛选 + 刷新(第一行),崩溃日志导入(第二行)
|
||||
Rectangle {
|
||||
height: 44px;
|
||||
height: 84px;
|
||||
background: Theme.bg-surface;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
VerticalLayout {
|
||||
HorizontalLayout {
|
||||
height: 44px;
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: root.selected == "" ? "未选择会话" : root.selected;
|
||||
horizontal-stretch: 1;
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: root.selected == "" ? "未选择会话" : root.selected;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
if root.live : Badge { text: "实时"; tint: Theme.state-success; }
|
||||
SearchField {
|
||||
width: 140px;
|
||||
placeholder: "筛选日志行";
|
||||
text <=> root.query;
|
||||
edited(t) => { root.query-changed(t); }
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "刷新";
|
||||
button-height: 26px;
|
||||
clicked => { root.refresh(); }
|
||||
}
|
||||
}
|
||||
SearchField {
|
||||
width: 200px;
|
||||
placeholder: "筛选日志行";
|
||||
text <=> root.query;
|
||||
edited(t) => { root.query-changed(t); }
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "打开目录";
|
||||
button-height: 26px;
|
||||
|
||||
// 崩溃日志导入分析(hs_err_pid*.log / crash-*.txt / latest.log)
|
||||
HorizontalLayout {
|
||||
height: 40px;
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
SearchField {
|
||||
horizontal-stretch: 1;
|
||||
placeholder: "崩溃日志路径(如 .minecraft/crash-reports/crash-*.txt)";
|
||||
text <=> PageState.crash-path;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "导入分析";
|
||||
button-height: 26px;
|
||||
enabled: PageState.crash-path != "";
|
||||
clicked => { PageState.crash-import(PageState.crash-path); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软登录(授权码浏览器 / 设备码)+ 删除/切换。
|
||||
//
|
||||
// 凭据红线:这里能看到的只有「名字 / 轨道 / 公开 UUID / 登录名 / 创建时间」——
|
||||
// token 与密码永远不进 UI(Rust 侧的 AccountView 不含这些字段)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { AccountKind, UiAccount } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { Badge } from "../components/badge.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { SearchField } from "../widgets/inputs.slint";
|
||||
import { SettingGroup } from "../widgets/setting-item.slint";
|
||||
|
||||
component AccountRow inherits Rectangle {
|
||||
in property <UiAccount> data;
|
||||
callback activate();
|
||||
callback remove();
|
||||
|
||||
height: 56px;
|
||||
background: root.data.active ? Theme.accent-soft : transparent;
|
||||
|
||||
if root.data.active : Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: 3px;
|
||||
height: root.height;
|
||||
background: Theme.accent;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
AppIcon {
|
||||
icon-size: 16px;
|
||||
kind: root.data.kind == AccountKind.offline ? IconKind.instances : IconKind.ai;
|
||||
tint: root.data.active ? Theme.accent : Theme.fg-secondary;
|
||||
}
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
vertical-stretch: 1;
|
||||
UiText { role: TextRole.bodyStrong; text: root.data.name; horizontal-stretch: 1; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.profile_id + (root.data.login_name == "" ? "" : " · " + root.data.login_name);
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
Badge { text: root.data.kind_label; tint: Theme.fg-secondary; }
|
||||
UiText { role: TextRole.caption; text: root.data.created; width: 108px; }
|
||||
if !root.data.active : MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "用这个";
|
||||
button-height: 24px;
|
||||
clicked => { root.activate(); }
|
||||
}
|
||||
if root.data.active : Badge { text: "启动中"; tint: Theme.accent; }
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "删除";
|
||||
button-height: 24px;
|
||||
clicked => { root.remove(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component AccountSection inherits Rectangle {
|
||||
background: transparent;
|
||||
|
||||
VerticalLayout {
|
||||
// 微软登录面板(设备码显示用户码;授权码显示可复制的授权链接;失败显示原因)
|
||||
if PageState.device.visible : Rectangle {
|
||||
height: PageState.device.busy ? 96px : 84px;
|
||||
background: PageState.device.phase_label == "失败" ? Theme.state-danger-soft : Theme.accent-soft;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Metrics.gap-3;
|
||||
spacing: Metrics.gap-1;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-2;
|
||||
AppIcon {
|
||||
icon-size: 16px;
|
||||
kind: PageState.device.phase_label == "失败" ? IconKind.error : IconKind.ai;
|
||||
tint: PageState.device.phase_label == "失败" ? Theme.state-danger : Theme.accent;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: "微软登录 · " + PageState.device.phase_label;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
if PageState.device.busy : MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "取消";
|
||||
button-height: 24px;
|
||||
clicked => { PageState.msa-cancel(); }
|
||||
}
|
||||
if !PageState.device.busy : MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "关闭";
|
||||
button-height: 24px;
|
||||
clicked => { PageState.msa-cancel(); }
|
||||
}
|
||||
}
|
||||
|
||||
if PageState.device.user_code != "" : HorizontalLayout {
|
||||
spacing: Metrics.gap-3;
|
||||
UiText {
|
||||
role: TextRole.title;
|
||||
text: PageState.device.user_code;
|
||||
color: Theme.accent;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "在浏览器打开 " + PageState.device.verification_uri + " 输入上面的用户码";
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: PageState.device.message;
|
||||
horizontal-stretch: 1;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if PageState.device.qr_link != "" : UiText {
|
||||
role: TextRole.monoSmall;
|
||||
text: PageState.device.qr_link;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 离线添加表单
|
||||
SettingGroup { title: "添加离线账户"; }
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
SearchField {
|
||||
width: 220px;
|
||||
text <=> PageState.offline-name;
|
||||
placeholder: "玩家名(离线 UUID 自动生成)";
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "添加离线";
|
||||
button-height: 28px;
|
||||
enabled: PageState.offline-name != "";
|
||||
clicked => { PageState.account-add-offline(PageState.offline-name); }
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.accent;
|
||||
text: "微软登录";
|
||||
button-height: 28px;
|
||||
enabled: !PageState.device.busy;
|
||||
clicked => { PageState.msa-browser-begin(); }
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "设备码登录";
|
||||
button-height: 28px;
|
||||
enabled: !PageState.device.busy;
|
||||
clicked => { PageState.msa-begin(); }
|
||||
}
|
||||
}
|
||||
|
||||
// 账户列表
|
||||
SettingGroup { title: "账户(" + PageState.accounts.length + ")"; }
|
||||
if PageState.accounts.length == 0 : Rectangle {
|
||||
height: 72px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 2px;
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "还没有账户:启动时会用「Player」离线占位。";
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
for account in PageState.accounts : AccountRow {
|
||||
data: account;
|
||||
activate => { PageState.account-switch(account.id); }
|
||||
remove => { PageState.account-remove(account.id); }
|
||||
}
|
||||
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
x: Metrics.gap-4;
|
||||
text: "凭据只存在 <数据根>/data/auth/(账号文件与凭据文件分离),界面与 AI 都拿不到 token。";
|
||||
horizontal-stretch: 1;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 设置页右栏的「schema 分组」渲染器:下载 / Java / 数据 / AI / MCP 五组。
|
||||
//
|
||||
// 行来自 Rust 侧的 `SettingGroupView`(core::setting schema 的键),这里只按控件类型画:
|
||||
// 开关 → toggle;分段 → segmented;文本 → text;只读 → 纯文案;动作 → 按钮。
|
||||
// 写回一律 `PageState.setting-changed(key, value)` / `setting-action(action)`,
|
||||
// 由 host 走 `SettingStore::set`(同源 launcher.json)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiSettingGroup, UiSettingRow, SettingControl } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint";
|
||||
|
||||
component RowView inherits Rectangle {
|
||||
in property <UiSettingRow> item;
|
||||
/// 文本编辑的本地缓冲(编辑中不回写模型,失焦/回车才提交)
|
||||
in-out property <string> draft;
|
||||
|
||||
height: root.item.control == SettingControl.readonly ? 48px
|
||||
: root.item.description == "" ? 44px : 58px;
|
||||
background: transparent;
|
||||
|
||||
init => { self.draft = root.item.value_text; }
|
||||
changed item => { self.draft = root.item.value_text; }
|
||||
|
||||
if root.item.control == SettingControl.toggle : SettingItem {
|
||||
label: root.item.label;
|
||||
description: root.item.description;
|
||||
kind: ControlKind.toggle;
|
||||
checked: root.item.flag;
|
||||
toggled(v) => { PageState.setting-changed(root.item.key, v ? "true" : "false"); }
|
||||
}
|
||||
|
||||
if root.item.control == SettingControl.choice : SettingItem {
|
||||
label: root.item.label;
|
||||
description: root.item.description;
|
||||
kind: ControlKind.segmented;
|
||||
options: root.item.options;
|
||||
selected: root.item.selected;
|
||||
changed(i) => {
|
||||
if (i >= 0 && i < root.item.options.length) {
|
||||
PageState.setting-changed(root.item.key, root.item.options[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.item.control == SettingControl.text : SettingItem {
|
||||
label: root.item.label;
|
||||
description: root.item.description;
|
||||
kind: ControlKind.text;
|
||||
value: root.draft;
|
||||
edited(t) => {
|
||||
root.draft = t;
|
||||
PageState.setting-changed(root.item.key, t);
|
||||
}
|
||||
}
|
||||
|
||||
if root.item.control == SettingControl.action : SettingItem {
|
||||
label: root.item.label;
|
||||
description: root.item.description;
|
||||
kind: ControlKind.button;
|
||||
button-text: "执行";
|
||||
clicked => { PageState.setting-action(root.item.action); }
|
||||
}
|
||||
|
||||
if root.item.control == SettingControl.readonly : HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
UiText {
|
||||
role: TextRole.body;
|
||||
text: root.item.label;
|
||||
width: 200px;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.item.description;
|
||||
horizontal-stretch: 1;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component SettingsGroups inherits Rectangle {
|
||||
in property <string> tab: "";
|
||||
|
||||
background: transparent;
|
||||
|
||||
VerticalLayout {
|
||||
padding-bottom: Metrics.gap-5;
|
||||
|
||||
if PageState.setting-groups.length == 0 : Rectangle {
|
||||
height: 120px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 2px;
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "设置还没加载出来(host 未就绪或数据根不可写)";
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for group in PageState.setting-groups : Rectangle {
|
||||
visible: group.id == root.tab;
|
||||
height: group.rows.length * 58px + 40px;
|
||||
background: transparent;
|
||||
|
||||
VerticalLayout {
|
||||
SettingGroup { title: group.title; }
|
||||
for setting-row in group.rows : RowView { item: setting-row; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import { SettingSection, TileSize, ThemeMode, BgFillKind } from "../models.slint
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint";
|
||||
import { AppearanceSection } from "settings-appearance.slint";
|
||||
import { AccountSection } from "settings-account.slint";
|
||||
import { SettingsGroups } from "settings-groups.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { SearchField } from "../widgets/inputs.slint";
|
||||
import { AboutSlint, ScrollView } from "std-widgets.slint";
|
||||
|
||||
@@ -235,101 +238,15 @@ export component SettingsPage inherits Rectangle {
|
||||
toggled(v) => { root.close-to-tray-changed(v); }
|
||||
}
|
||||
|
||||
// ── 数据 ────────────────────────────────────────
|
||||
if root.section == SettingSection.data : SettingGroup { title: "数据目录(D6 三模式)"; }
|
||||
if root.section == SettingSection.data : SettingItem {
|
||||
label: "存储模式";
|
||||
description: "便携(运行目录 .ULL/.minecraft)/ 标准(系统应用数据目录)/ 自定义";
|
||||
kind: ControlKind.segmented;
|
||||
options: ["便携", "标准", "自定义"];
|
||||
selected: root.data-mode;
|
||||
changed(i) => {
|
||||
root.data-mode = i;
|
||||
root.data-mode-changed(i);
|
||||
}
|
||||
}
|
||||
if root.section == SettingSection.data : SettingItem {
|
||||
label: "当前路径";
|
||||
description: root.data-path;
|
||||
kind: ControlKind.button;
|
||||
button-text: "迁移…";
|
||||
clicked => { root.migrate-data(); }
|
||||
}
|
||||
|
||||
// ── 下载 ────────────────────────────────────────
|
||||
if root.section == SettingSection.download : SettingGroup { title: "下载(D7)"; }
|
||||
if root.section == SettingSection.download : SettingItem {
|
||||
label: "并发数";
|
||||
kind: ControlKind.segmented;
|
||||
options: ["4", "8", "16"];
|
||||
selected: root.download-concurrency;
|
||||
changed(i) => {
|
||||
root.download-concurrency = i;
|
||||
root.download-concurrency-changed(i);
|
||||
}
|
||||
}
|
||||
if root.section == SettingSection.download : SettingItem {
|
||||
label: "地区判定";
|
||||
description: "当前:" + root.region-label + "(决定是否走国内镜像)";
|
||||
kind: ControlKind.button;
|
||||
button-text: "重新检测";
|
||||
clicked => { root.detect-region(); }
|
||||
}
|
||||
|
||||
// ── Java ────────────────────────────────────────
|
||||
if root.section == SettingSection.java : SettingGroup { title: "Java(D13)"; }
|
||||
if root.section == SettingSection.java : SettingItem {
|
||||
label: "Java 管理";
|
||||
description: "基准 JDK17 自动下载 · 多版本可选 · 不匹配时黄条提示";
|
||||
kind: ControlKind.button;
|
||||
button-text: "管理多版本";
|
||||
clicked => { root.manage-java(); }
|
||||
}
|
||||
|
||||
// ── 账户 ────────────────────────────────────────
|
||||
if root.section == SettingSection.account : SettingGroup { title: "账户(D9)"; }
|
||||
if root.section == SettingSection.account : SettingItem {
|
||||
label: "当前账户";
|
||||
description: root.account-label;
|
||||
kind: ControlKind.button;
|
||||
button-text: "登录 / 切换";
|
||||
clicked => { root.login(); }
|
||||
}
|
||||
|
||||
// ── AI ──────────────────────────────────────────
|
||||
if root.section == SettingSection.ai : SettingGroup { title: "AI 助手(D4)"; }
|
||||
if root.section == SettingSection.ai : SettingItem {
|
||||
label: "模型端点";
|
||||
description: "OpenAI 兼容 / 本地 llama.cpp / Anthropic / 自定义";
|
||||
kind: ControlKind.segmented;
|
||||
options: ["OpenAI", "llama.cpp", "Anthropic", "自定义"];
|
||||
selected: root.ai-endpoint;
|
||||
changed(i) => {
|
||||
root.ai-endpoint = i;
|
||||
root.ai-endpoint-changed(i);
|
||||
}
|
||||
}
|
||||
if root.section == SettingSection.ai : SettingItem {
|
||||
label: "YOLO 模式";
|
||||
description: "跳过全部确认弹窗(审计照记),账户/凭据操作永不 YOLO";
|
||||
kind: ControlKind.toggle;
|
||||
checked: root.yolo;
|
||||
toggled(v) => { root.yolo-changed(v); }
|
||||
}
|
||||
if root.section == SettingSection.ai : SettingItem {
|
||||
label: "MCP server";
|
||||
description: "允许外部客户端(如 Claude Desktop)调用工具集";
|
||||
kind: ControlKind.toggle;
|
||||
checked: root.mcp-enabled;
|
||||
toggled(v) => { root.mcp-changed(v); }
|
||||
}
|
||||
if root.section == SettingSection.ai : SettingItem {
|
||||
label: "审计日志";
|
||||
description: "AI 的所有写操作都有记录";
|
||||
kind: ControlKind.button;
|
||||
button-text: "查看";
|
||||
clicked => { root.open-audit-log(); }
|
||||
}
|
||||
// ── 数据 / 下载 / Java / 账户 / AI / MCP ────────────
|
||||
// 行来自 core::setting schema(`PageState.setting-groups`),写回走
|
||||
// `SettingStore::set`——与 `ull config set` / AI `set_config` 同一份 launcher.json。
|
||||
if root.section == SettingSection.data : SettingsGroups { tab: "data"; }
|
||||
if root.section == SettingSection.download : SettingsGroups { tab: "download"; }
|
||||
if root.section == SettingSection.java : SettingsGroups { tab: "java"; }
|
||||
if root.section == SettingSection.ai : SettingsGroups { tab: "ai"; }
|
||||
if root.section == SettingSection.ai : SettingsGroups { tab: "mcp"; }
|
||||
if root.section == SettingSection.account : AccountSection { }
|
||||
|
||||
// ── 代理 ────────────────────────────────────────
|
||||
if root.section == SettingSection.proxy : SettingGroup { title: "代理"; }
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind,
|
||||
UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast,
|
||||
} from "models.slint";
|
||||
import { PageState } from "page_state.slint";
|
||||
import { NewInstanceWizard } from "widgets/wizard.slint";
|
||||
import { TitleBar } from "components/titlebar.slint";
|
||||
import { Nav } from "components/nav.slint";
|
||||
import { AiPanel } from "components/ai-panel.slint";
|
||||
@@ -14,11 +16,13 @@ import { UiText, TextRole } from "components/label.slint";
|
||||
import { MetroButton, ButtonKind } from "widgets/button.slint";
|
||||
import { DialogHost } from "widgets/dialog.slint";
|
||||
import { ToastHost } from "widgets/toast.slint";
|
||||
import { NoticeBar } from "components/notice-bar.slint";
|
||||
import { InstancesPage } from "pages/instances.slint";
|
||||
import { DownloadPage } from "pages/download.slint";
|
||||
import { LogsPage } from "pages/logs.slint";
|
||||
import { SettingsPage } from "pages/settings.slint";
|
||||
|
||||
|
||||
export component AppShell inherits Rectangle {
|
||||
// ── 窗口 ────────────────────────────────────────────────────
|
||||
in property <string> app-title: "Unlucky Launcher";
|
||||
@@ -189,6 +193,10 @@ export component AppShell inherits Rectangle {
|
||||
request-close => { root.request-close(); }
|
||||
}
|
||||
|
||||
NoticeBar {
|
||||
notices: PageState.notices;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
// 左侧主导航
|
||||
Nav {
|
||||
@@ -259,6 +267,7 @@ export component AppShell inherits Rectangle {
|
||||
select(id) => { root.log-select(id); }
|
||||
ask-ai(id) => { root.log-ask-ai(id); }
|
||||
apply-fix => { root.log-apply-fix(); }
|
||||
refresh => { PageState.logs-refresh(); }
|
||||
}
|
||||
|
||||
if root.route == RouteKind.settings : SettingsPage {
|
||||
@@ -356,6 +365,11 @@ export component AppShell inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// 新建实例向导(全屏模态;数据来自 PageState)
|
||||
NewInstanceWizard {
|
||||
versions: root.versions;
|
||||
}
|
||||
|
||||
// 覆盖层:模态队列(一次一条)+ 右下 Toast
|
||||
DialogHost {
|
||||
current: root.dialog;
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// 新建实例向导(G5,全屏模态):版本 → 加载器 → 名字 → 确认 → 进度。
|
||||
// 步骤推进/校验在 Rust(`crate::wizard`),这里只画当前步与提交按钮。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiVersion, VersionKind } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { Badge } from "../components/badge.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { SearchField } from "../widgets/inputs.slint";
|
||||
import { InstallPanel } from "../components/install-panel.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
|
||||
component StepDot inherits Rectangle {
|
||||
in property <int> index: 0;
|
||||
in property <string> label: "";
|
||||
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-1;
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: PageState.wizard.step >= root.index ? Theme.accent : Theme.bg-hover;
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.index + 1;
|
||||
horizontal-alignment: center;
|
||||
color: PageState.wizard.step >= root.index ? Theme.fg-on-accent : Theme.fg-secondary;
|
||||
}
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.label;
|
||||
color: PageState.wizard.step >= root.index ? Theme.fg-primary : Theme.fg-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component VersionChoice inherits Rectangle {
|
||||
in property <UiVersion> data;
|
||||
callback picked();
|
||||
|
||||
height: 36px;
|
||||
background: PageState.wizard.version == root.data.id ? Theme.accent-soft
|
||||
: (touch.has-hover ? Theme.bg-hover : transparent);
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText { role: TextRole.body; text: root.data.id; width: 160px; }
|
||||
Badge {
|
||||
text: root.data.kind == VersionKind.release ? "正式版" : "预览版";
|
||||
tint: Theme.fg-secondary;
|
||||
}
|
||||
UiText { role: TextRole.caption; text: root.data.released; horizontal-stretch: 1; }
|
||||
if PageState.wizard.version == root.data.id : AppIcon {
|
||||
kind: IconKind.check;
|
||||
icon-size: 14px;
|
||||
tint: Theme.accent;
|
||||
}
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.picked(); }
|
||||
}
|
||||
}
|
||||
|
||||
component LoaderChoice inherits Rectangle {
|
||||
in property <string> label: "";
|
||||
in property <string> hint: "";
|
||||
in property <int> index: 0;
|
||||
callback picked();
|
||||
|
||||
height: 48px;
|
||||
background: PageState.wizard.loader == root.index ? Theme.accent-soft
|
||||
: (touch.has-hover ? Theme.bg-hover : transparent);
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
VerticalLayout {
|
||||
vertical-stretch: 1;
|
||||
spacing: 0px;
|
||||
UiText { role: TextRole.body; text: root.label; }
|
||||
UiText { role: TextRole.caption; text: root.hint; horizontal-stretch: 1; }
|
||||
}
|
||||
if PageState.wizard.loader == root.index : AppIcon {
|
||||
kind: IconKind.check;
|
||||
icon-size: 14px;
|
||||
tint: Theme.accent;
|
||||
}
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.picked(); }
|
||||
}
|
||||
}
|
||||
|
||||
export component NewInstanceWizard inherits Rectangle {
|
||||
// 版本列表由 shell 传入(与下载页共用一份,避免二次拉取)
|
||||
in property <[UiVersion]> versions;
|
||||
in-out property <string> query: "";
|
||||
|
||||
visible: PageState.wizard.open;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
|
||||
// 吃掉底层点击(模态)
|
||||
TouchArea { }
|
||||
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(parent.width - 80px, 720px);
|
||||
height: min(parent.height - 60px, 560px);
|
||||
background: Theme.bg-surface;
|
||||
border-radius: Metrics.radius-md;
|
||||
border-width: 1px;
|
||||
border-color: Theme.stroke;
|
||||
|
||||
VerticalLayout {
|
||||
// 标题栏
|
||||
Rectangle {
|
||||
height: 52px;
|
||||
background: transparent;
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText {
|
||||
role: TextRole.subtitle;
|
||||
text: "新建实例 · " + PageState.wizard.step_label;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "关闭";
|
||||
button-height: 24px;
|
||||
clicked => { PageState.wizard-close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤指示
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
StepDot { index: 0; label: "版本"; }
|
||||
StepDot { index: 1; label: "加载器"; }
|
||||
StepDot { index: 2; label: "名字"; }
|
||||
StepDot { index: 3; label: "确认"; }
|
||||
StepDot { index: 4; label: "进度"; }
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
// 步骤内容
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
background: transparent;
|
||||
|
||||
// ① 选版本(只列正式版/快照,远古版走下载页的显式入口)
|
||||
if PageState.wizard.step == 0 : VerticalLayout {
|
||||
padding: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
SearchField {
|
||||
width: 220px;
|
||||
text <=> root.query;
|
||||
placeholder: "筛选版本号";
|
||||
}
|
||||
ScrollView {
|
||||
vertical-stretch: 1;
|
||||
VerticalLayout {
|
||||
for v in root.versions : VersionChoice {
|
||||
visible: !v.ancient;
|
||||
data: v;
|
||||
picked => { PageState.wizard-select-version(v.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ② 选加载器
|
||||
if PageState.wizard.step == 1 : VerticalLayout {
|
||||
padding: Metrics.gap-3;
|
||||
spacing: 0px;
|
||||
LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } }
|
||||
LoaderChoice { index: 1; label: "Fabric"; hint: "轻量模组加载器(GUI 可直接装)"; picked => { PageState.wizard-select-loader(1); } }
|
||||
LoaderChoice { index: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } }
|
||||
LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(3); } }
|
||||
LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(4); } }
|
||||
}
|
||||
|
||||
// ③ 名字
|
||||
if PageState.wizard.step == 2 : VerticalLayout {
|
||||
padding: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText { role: TextRole.body; text: "实例名(默认用版本号)"; }
|
||||
SearchField {
|
||||
width: 320px;
|
||||
text: PageState.wizard.name;
|
||||
placeholder: "例如:生存 1.20.4";
|
||||
edited(t) => { PageState.wizard-name-changed(t); }
|
||||
}
|
||||
if PageState.wizard.error != "" : UiText {
|
||||
role: TextRole.caption;
|
||||
text: PageState.wizard.error;
|
||||
color: Theme.state-danger;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "不能包含 / \\ : * ? \" < > |,最多 64 个字符。";
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 确认
|
||||
if PageState.wizard.step == 3 : VerticalLayout {
|
||||
padding: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText { role: TextRole.bodyStrong; text: "将创建实例"; }
|
||||
UiText { role: TextRole.body; text: "版本 " + PageState.wizard.version; }
|
||||
UiText { role: TextRole.body; text: "加载器 " + PageState.wizard.loader_label; }
|
||||
UiText { role: TextRole.body; text: "实例名 " + PageState.wizard.name; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "确认后会按 core 安装管线下载:版本 JSON → 客户端 jar → 库 → 资源索引 → 登记实例。";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if PageState.wizard.error != "" : UiText {
|
||||
role: TextRole.caption;
|
||||
text: PageState.wizard.error;
|
||||
color: Theme.state-danger;
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 进度
|
||||
if PageState.wizard.step == 4 : VerticalLayout {
|
||||
padding: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
InstallPanel {
|
||||
data: PageState.install;
|
||||
cancel => { PageState.install-cancel(); }
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "装完会自动跳到实例页;失败原因会显示在上面。";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
// 底部按钮
|
||||
Rectangle {
|
||||
height: 56px;
|
||||
background: transparent;
|
||||
HorizontalLayout {
|
||||
padding: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "上一步";
|
||||
button-height: 30px;
|
||||
enabled: PageState.wizard.step > 0 && PageState.wizard.step < 4;
|
||||
clicked => { PageState.wizard-back(); }
|
||||
}
|
||||
if PageState.wizard.step < 3 : MetroButton {
|
||||
kind: ButtonKind.accent;
|
||||
text: "下一步";
|
||||
button-height: 30px;
|
||||
enabled: PageState.wizard.can-next;
|
||||
clicked => { PageState.wizard-next(); }
|
||||
}
|
||||
if PageState.wizard.step == 3 : MetroButton {
|
||||
kind: ButtonKind.accent;
|
||||
text: "开始安装";
|
||||
button-height: 30px;
|
||||
enabled: PageState.wizard.can-next && !PageState.wizard.busy;
|
||||
clicked => { PageState.wizard-confirm(); }
|
||||
}
|
||||
if PageState.wizard.step == 4 : MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "后台继续";
|
||||
button-height: 30px;
|
||||
clicked => { PageState.wizard-close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ impl std::fmt::Display for AccountId {
|
||||
pub enum AccountKind {
|
||||
/// 离线(本地生成的 UUID,不联网)。
|
||||
Offline,
|
||||
/// 微软(device-code)。
|
||||
/// 微软(device-code / 授权码浏览器)。
|
||||
Microsoft,
|
||||
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
|
||||
AuthlibInjector,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! 授权码流(`research/08` §3 方案 A):授权 URL 构造 + code 换 token + 整链编排。
|
||||
//!
|
||||
//! 与设备码流的分工:**只多一跳**——拿 MS access_token 的方式不同,往后的
|
||||
//! XBL → XSTS → login_with_xbox → entitlements → profile 与刷新链完全复用
|
||||
//! ([`super::chain::complete_chain`]),所以两条流的凭据存储与刷新语义天然一致。
|
||||
//!
|
||||
//! # 三个硬性细节(都是「抄错就失败」的那一类)
|
||||
//!
|
||||
//! 1. **租户 `consumers`**:授权页与 token 端点都走 `login.microsoftonline.com/consumers/…`,
|
||||
//! 换成 `common`/AAD 租户 id 时 `XboxLive.signin` 直接被拒(`research/08` §2 约束 1)。
|
||||
//! 2. **重定向 URI 严格一致**:`http://localhost:{port}/auth` —— 授权 URL 里的、
|
||||
//! token 请求体里的、本地服务器实际监听的必须是**同一个字符串**(含端口与路径),
|
||||
//! 所以本模块只从 [`RedirectUri`] 取这一个来源。
|
||||
//! 3. **`prompt=select_account`**:不带这个参数,用户会被静默塞进上一次的账号(坑 6)。
|
||||
//!
|
||||
//! scope 与设备码流共用 [`super::flow::SCOPE`](`XboxLive.signin offline_access`)。
|
||||
|
||||
use std::fmt;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::auth::error::AuthResult;
|
||||
use crate::auth::http::post_form;
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
use super::callback::{CallbackServer, REDIRECT_PATH};
|
||||
use super::chain::complete_chain;
|
||||
use super::endpoints::MicrosoftEndpoints;
|
||||
use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE};
|
||||
use super::now_ms;
|
||||
use super::session::MicrosoftSession;
|
||||
|
||||
/// 授权码 grant 类型。
|
||||
pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code";
|
||||
/// 默认等回调的时间预算(用户要开浏览器、登录、点同意)。
|
||||
pub const DEFAULT_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// 默认的端口绑定尝试次数。
|
||||
pub const DEFAULT_BIND_ATTEMPTS: u8 = 3;
|
||||
|
||||
/// 回填到授权 URL 与 token 请求里的重定向 URI(**唯一来源**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RedirectUri {
|
||||
port: u16,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl RedirectUri {
|
||||
/// 按实际绑定的端口构造 `http://localhost:{port}/auth`。
|
||||
///
|
||||
/// 用 `localhost` 而不是 `127.0.0.1`:微软对两者处理不一致(`research/08` 坑 5)。
|
||||
#[must_use]
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self {
|
||||
port,
|
||||
url: format!("http://localhost:{port}{REDIRECT_PATH}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 端口。
|
||||
#[must_use]
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// URI 字面量(授权 URL 与 token 请求共用,保证逐字节一致)。
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RedirectUri {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.url)
|
||||
}
|
||||
}
|
||||
|
||||
/// 授权码流的运行参数(生产用 [`AuthCodePolicy::default`],测试压短超时/固定端口)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthCodePolicy {
|
||||
/// 等浏览器回调的上限。
|
||||
pub callback_timeout: Duration,
|
||||
/// 端口绑定尝试次数(`>= 1`)。
|
||||
pub bind_attempts: u8,
|
||||
/// 固定端口(`None` = 由内核分配,生产路径)。
|
||||
pub port: Option<u16>,
|
||||
/// CSRF 用的 `state`(`None` = 每次随机生成)。
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AuthCodePolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
callback_timeout: DEFAULT_CALLBACK_TIMEOUT,
|
||||
bind_attempts: DEFAULT_BIND_ATTEMPTS,
|
||||
port: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 要宿主做的事:打开浏览器(core 不碰 UI:谁来开由 CLI/GUI 决定)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthCodePrompt {
|
||||
/// 授权页 URL(宿主交给系统默认浏览器;也可展示给用户手动打开)。
|
||||
pub authorization_url: String,
|
||||
/// 本地回调地址(展示/排障用;与授权 URL 里的 `redirect_uri` 同源)。
|
||||
pub redirect_uri: String,
|
||||
/// 最多等多久。
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
/// 拼授权 URL(`response_type=code` + `prompt=select_account` + 严格编码的重定向 URI)。
|
||||
#[must_use]
|
||||
pub fn authorization_url(
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
redirect_uri: &RedirectUri,
|
||||
state: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}?client_id={}&response_type=code&redirect_uri={}&scope={}&prompt=select_account&state={}",
|
||||
endpoints.authorize,
|
||||
encode(client_id),
|
||||
encode(redirect_uri.as_str()),
|
||||
encode(SCOPE),
|
||||
encode(state),
|
||||
)
|
||||
}
|
||||
|
||||
/// 授权码换 token(`grant_type=authorization_code`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// OAuth 拒绝([`crate::auth::AuthError::Denied`] /
|
||||
/// [`crate::auth::AuthError::CredentialExpired`])、网络失败、响应缺 token。
|
||||
pub(crate) async fn exchange_code(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
code: &Secret,
|
||||
redirect_uri: &RedirectUri,
|
||||
) -> AuthResult<OAuthTokens> {
|
||||
let reply = post_form(
|
||||
client,
|
||||
&endpoints.token,
|
||||
vec![
|
||||
("client_id", client_id.to_string()),
|
||||
("grant_type", AUTHORIZATION_CODE_GRANT.to_string()),
|
||||
("code", code.expose().to_string()),
|
||||
("redirect_uri", redirect_uri.as_str().to_string()),
|
||||
("scope", SCOPE.to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if reply.text("error").is_some() || !reply.is_success() {
|
||||
return Err(oauth_error(&reply, &endpoints.token));
|
||||
}
|
||||
tokens_from(&reply, &endpoints.token)
|
||||
}
|
||||
|
||||
/// 授权码登录主体:绑端口 → 出授权 URL(交给 `present`)→ 等回调 → 换 token → 整链。
|
||||
///
|
||||
/// `present` 在**端口绑定之后、等待之前**被调用:宿主此时才知道端口,也只有这时
|
||||
/// 打开浏览器才不会让用户对着还没起来的服务端登录。
|
||||
pub(crate) async fn login<F>(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
policy: AuthCodePolicy,
|
||||
present: F,
|
||||
) -> AuthResult<MicrosoftSession>
|
||||
where
|
||||
F: FnOnce(&AuthCodePrompt),
|
||||
{
|
||||
let state = policy.state.clone().unwrap_or_else(nonce);
|
||||
let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?;
|
||||
let redirect_uri = RedirectUri::new(server.port());
|
||||
present(&AuthCodePrompt {
|
||||
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
|
||||
redirect_uri: redirect_uri.as_str().to_string(),
|
||||
timeout: policy.callback_timeout,
|
||||
});
|
||||
let code = server.wait_for_code(policy.callback_timeout).await?;
|
||||
let tokens = exchange_code(
|
||||
client,
|
||||
endpoints,
|
||||
client_id,
|
||||
&Secret::new(code),
|
||||
&redirect_uri,
|
||||
)
|
||||
.await?;
|
||||
complete_chain(client, endpoints, tokens, now_ms()).await
|
||||
}
|
||||
|
||||
/// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。
|
||||
///
|
||||
/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少
|
||||
/// 128 位熵,挡住「猜到 state 就能伪造回调」这类攻击面。
|
||||
fn nonce() -> String {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|since| since.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let mut halves = [String::new(), String::new()];
|
||||
for half in &mut halves {
|
||||
// 每次 `RandomState::new()` 都是独立随机种子 → 两段各 64 位、彼此不可推。
|
||||
let mut hasher = RandomState::new().build_hasher();
|
||||
hasher.write_u128(nanos);
|
||||
hasher.write_u32(std::process::id());
|
||||
*half = format!("{:016x}", hasher.finish());
|
||||
}
|
||||
format!("{}{}", halves[0], halves[1])
|
||||
}
|
||||
|
||||
/// 百分号编码(RFC 3986 unreserved 之外一律转义;空格用 `%20` 而不是 `+`)。
|
||||
fn encode(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
for byte in raw.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(char::from(byte));
|
||||
}
|
||||
_ => out.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{authorization_url, encode, RedirectUri, AUTHORIZATION_CODE_GRANT};
|
||||
use crate::auth::microsoft::MicrosoftEndpoints;
|
||||
|
||||
#[test]
|
||||
fn redirect_uri_is_localhost_with_exact_port_and_path() {
|
||||
let uri = RedirectUri::new(51_234);
|
||||
assert_eq!(uri.as_str(), "http://localhost:51234/auth");
|
||||
assert_eq!(uri.port(), 51_234);
|
||||
assert_eq!(uri.to_string(), uri.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_url_matches_research_08_vector() {
|
||||
let endpoints = MicrosoftEndpoints::consumers();
|
||||
let uri = RedirectUri::new(8_080);
|
||||
let url = authorization_url(&endpoints, "0000-client", &uri, "state-123");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize\
|
||||
?client_id=0000-client&response_type=code\
|
||||
&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth\
|
||||
&scope=XboxLive.signin%20offline_access&prompt=select_account&state=state-123"
|
||||
);
|
||||
// 三个硬性细节各断言一遍,改坏了立刻知道是哪一条。
|
||||
assert!(url.contains("/consumers/oauth2/v2.0/authorize"));
|
||||
assert!(url.contains("prompt=select_account"));
|
||||
assert!(url.contains("scope=XboxLive.signin%20offline_access"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_escapes_everything_but_unreserved() {
|
||||
assert_eq!(encode("abc-._~09"), "abc-._~09");
|
||||
assert_eq!(encode("a b/c:"), "a%20b%2Fc%3A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_type_is_the_standard_one() {
|
||||
assert_eq!(AUTHORIZATION_CODE_GRANT, "authorization_code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_state_has_128_bits_of_entropy() {
|
||||
let first = super::nonce();
|
||||
let second = super::nonce();
|
||||
assert_eq!(first.len(), 32, "128 位 = 32 个 hex 字符:{first}");
|
||||
assert!(first.chars().all(|c| c.is_ascii_hexdigit()), "{first}");
|
||||
assert_ne!(first, second, "每次登录的 state 必须不同");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! 授权码流的本地回调服务器(`research/08` §3 方案 A:HMCL 主流做法)。
|
||||
//!
|
||||
//! 只干一件事:等浏览器把 `?code=` 送到 `http://localhost:{port}/auth`,把 code 交出去。
|
||||
//! 协议面故意压到最小(只认一个 GET 路径、只回一个静态页),这样它能被单测直接驱动。
|
||||
//!
|
||||
//! # 四条容易踩的细节
|
||||
//!
|
||||
//! 1. **重定向 URI 用 `localhost`,监听绑 `127.0.0.1`**:微软对 `localhost` 与 `127.0.0.1`
|
||||
//! 处理不一致(坑 5),URI 只能写前者;浏览器把 `localhost` 解析到 `::1` 时会被拒一次
|
||||
//! 再回落 IPv4,Chrome/Edge 都走这条回退,所以只绑 v4 就够。
|
||||
//! 2. **端口由内核分配**(绑定 `:0`),端口被占时按 `attempts` 重试;授权 URL 里的
|
||||
//! `redirect_uri` 必须用**最终绑到的端口**回填,换 token 时再原样带上(URI 严格一致)。
|
||||
//! 3. **非 `/auth` 的请求(`/favicon.ico`、浏览器预连)回 404 后继续等**:让一个 favicon
|
||||
//! 把整次登录判成失败是最容易犯的错。
|
||||
//! 4. **`state` 不匹配的请求一律回 400 后继续等**——包括带 `error=` 的那些:旧标签页的
|
||||
//! 迟到回调不该打断新的登录,更要紧的是 RFC 6749 §4.1.2.1 / RFC 9207 要求**错误响应同样
|
||||
//! 要校验 `state`**;否则任何人 `GET /auth?error=access_denied` 就能静默中止别人的登录。
|
||||
//! 真正的超时由调用方的时间预算兜底。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
|
||||
/// 回调路径(重定向 URI 的路径段)。
|
||||
pub const REDIRECT_PATH: &str = "/auth";
|
||||
/// 一次回调请求最多读这么多字节(超了就丢弃连接,防被灌内存)。
|
||||
pub const MAX_REQUEST_HEAD: usize = 8 * 1024;
|
||||
/// 端口被占用时的重试间隔。
|
||||
const RETRY_DELAY: Duration = Duration::from_millis(20);
|
||||
/// 单条连接读完请求头的上限(浏览器/预连不把请求头发完时不能把整个登录卡死)。
|
||||
const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
const SUCCESS_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||
<title>登录完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||
<h1>登录完成</h1><p>可以关闭此窗口,回到启动器继续。</p></body></html>";
|
||||
const DENIED_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||
<title>登录未完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||
<h1>登录未完成</h1><p>授权没有完成,可以回到启动器重试。</p></body></html>";
|
||||
const BAD_REQUEST_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||
<title>回调无效</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||
<h1>回调无效</h1><p>这个链接不属于本次登录。</p></body></html>";
|
||||
const NOT_FOUND_PAGE: &str = "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
|
||||
<title>Not Found</title></head><body><h1>Not Found</h1></body></html>";
|
||||
|
||||
/// 本地回调服务器(一个实例只服务一次登录)。
|
||||
#[derive(Debug)]
|
||||
pub struct CallbackServer {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
state: String,
|
||||
}
|
||||
|
||||
impl CallbackServer {
|
||||
/// 绑定回调端口。
|
||||
///
|
||||
/// `port` 为 `None` 时由内核分配(生产路径);给定端口时按 `attempts` 重试
|
||||
/// (CLI/测试里固定端口用,端口被占会退避再试)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 全部尝试都绑定失败时返回 [`AuthError::Network`](文案带端口与重试次数)。
|
||||
pub async fn bind(
|
||||
port: Option<u16>,
|
||||
attempts: u8,
|
||||
state: impl Into<String>,
|
||||
) -> AuthResult<Self> {
|
||||
let wanted = port.unwrap_or(0);
|
||||
let attempts = attempts.max(1);
|
||||
let mut last = String::new();
|
||||
for attempt in 0..attempts {
|
||||
match TcpListener::bind(("127.0.0.1", wanted)).await {
|
||||
Ok(listener) => {
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| AuthError::network("localhost", error.to_string()))?
|
||||
.port();
|
||||
return Ok(Self {
|
||||
listener,
|
||||
port,
|
||||
state: state.into(),
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
last = error.to_string();
|
||||
if attempt + 1 < attempts {
|
||||
tokio::time::sleep(RETRY_DELAY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let what = if wanted == 0 {
|
||||
"本地回调端口分配失败".to_string()
|
||||
} else {
|
||||
format!("本地回调端口 {wanted} 绑定失败(重试 {attempts} 次)")
|
||||
};
|
||||
Err(AuthError::network("localhost", format!("{what}:{last}")))
|
||||
}
|
||||
|
||||
/// 实际端口(回填 `redirect_uri` 用)。
|
||||
#[must_use]
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// 本次登录的 `state`(授权 URL 与回调校验共用)。
|
||||
#[must_use]
|
||||
pub fn state(&self) -> &str {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// 等浏览器把 code 送到 `/auth`,最多等 `timeout`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 超时([`AuthError::Timeout`])、用户在授权页点了取消([`AuthError::Canceled`])、
|
||||
/// 授权页回了别的 `error`([`AuthError::Denied`])、socket 出错([`AuthError::Network`])。
|
||||
pub async fn wait_for_code(self, timeout: Duration) -> AuthResult<String> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(wait_timeout(timeout));
|
||||
}
|
||||
let accepted = tokio::time::timeout(remaining, self.listener.accept()).await;
|
||||
let (mut stream, _) = match accepted {
|
||||
Ok(Ok(pair)) => pair,
|
||||
Ok(Err(error)) => {
|
||||
return Err(AuthError::network("localhost", error.to_string()));
|
||||
}
|
||||
Err(_) => return Err(wait_timeout(timeout)),
|
||||
};
|
||||
// 一条连接最多读这么久请求头:慢客户端/半开连接不能把整个登录拖过时间预算。
|
||||
// 预算按 **accept 之后** 的剩余时间重算(用 accept 前那份会让总时长超发最多 5s)。
|
||||
let budget = REQUEST_READ_TIMEOUT
|
||||
.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
|
||||
if budget.is_zero() {
|
||||
return Err(wait_timeout(timeout));
|
||||
}
|
||||
match tokio::time::timeout(budget, self.handle(&mut stream)).await {
|
||||
Ok(result) => {
|
||||
if let Some(code) = result? {
|
||||
return Ok(code);
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理一条连接:`Some(code)` = 登录完成,`None` = 与本次登录无关,继续等。
|
||||
async fn handle(&self, stream: &mut TcpStream) -> AuthResult<Option<String>> {
|
||||
let Some(target) = read_request_target(stream).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (path, query) = match target.split_once('?') {
|
||||
Some((path, query)) => (path, query),
|
||||
None => (target.as_str(), ""),
|
||||
};
|
||||
if path != REDIRECT_PATH {
|
||||
respond(stream, 404, NOT_FOUND_PAGE).await;
|
||||
return Ok(None);
|
||||
}
|
||||
let params = Query::parse(query);
|
||||
// state 必须先校验,且**先于** error:错误响应同样要认 state(RFC 6749 §4.1.2.1 /
|
||||
// RFC 9207)。否则任意一次 `GET /auth?error=access_denied`(state 随便给或不给)就能
|
||||
// 把这台机器上正在进行的登录静默中止 —— 不需要知道随机 state、不需要 code。
|
||||
if params.get("state") != Some(self.state.as_str()) {
|
||||
respond(stream, 400, BAD_REQUEST_PAGE).await;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(error) = params.get("error") {
|
||||
respond(stream, 200, DENIED_PAGE).await;
|
||||
return Err(oauth_error(error, params.get("error_description")));
|
||||
}
|
||||
let Some(code) = params.get("code").filter(|code| !code.is_empty()) else {
|
||||
respond(stream, 400, BAD_REQUEST_PAGE).await;
|
||||
return Ok(None);
|
||||
};
|
||||
respond(stream, 200, SUCCESS_PAGE).await;
|
||||
Ok(Some(code.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_timeout(timeout: Duration) -> AuthError {
|
||||
AuthError::Timeout {
|
||||
what: format!("等待浏览器回调(上限 {} 秒)", timeout.as_secs()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 授权页回的 `error` → 错误分流(`access_denied` 归取消,其余归拒绝)。
|
||||
fn oauth_error(code: &str, description: Option<&str>) -> AuthError {
|
||||
if matches!(code, "access_denied" | "authorization_declined") {
|
||||
return AuthError::Canceled;
|
||||
}
|
||||
AuthError::Denied {
|
||||
code: code.to_string(),
|
||||
description: description.unwrap_or("授权页没有给出原因").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读请求行里的 target(`/auth?code=...`)。非 GET/HEAD、超长头、连接中断都给 `None`。
|
||||
async fn read_request_target(stream: &mut TcpStream) -> Option<String> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
loop {
|
||||
if let Some(end) = find_headers_end(&buffer) {
|
||||
let head = String::from_utf8_lossy(&buffer[..end]).into_owned();
|
||||
let mut parts = head.lines().next()?.split(' ');
|
||||
let method = parts.next()?;
|
||||
if !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") {
|
||||
return None;
|
||||
}
|
||||
return parts.next().map(str::to_string);
|
||||
}
|
||||
if buffer.len() > MAX_REQUEST_HEAD {
|
||||
return None;
|
||||
}
|
||||
match stream.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => return None,
|
||||
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_headers_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|position| position + 4)
|
||||
}
|
||||
|
||||
async fn respond(stream: &mut TcpStream, status: u16, page: &str) {
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} {}\r\nContent-Type: text/html; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n{page}",
|
||||
reason(status),
|
||||
page.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
fn reason(status: u16) -> &'static str {
|
||||
match status {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
404 => "Not Found",
|
||||
_ => "Status",
|
||||
}
|
||||
}
|
||||
|
||||
/// 回调 query 的最小解析(`k=v` 对,percent 解码)。
|
||||
struct Query(Vec<(String, String)>);
|
||||
|
||||
impl Query {
|
||||
fn parse(raw: &str) -> Self {
|
||||
Self(
|
||||
raw.split('&')
|
||||
.filter(|pair| !pair.is_empty())
|
||||
.filter_map(|pair| {
|
||||
let (key, value) = pair.split_once('=')?;
|
||||
Some((decode(key), decode(value)))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Option<&str> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// `percent` 解码(`+` 视为空格;坏转义原样保留)。
|
||||
fn decode(raw: &str) -> String {
|
||||
let bytes = raw.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
match bytes[index] {
|
||||
b'%' if index + 2 < bytes.len() => {
|
||||
let parsed = std::str::from_utf8(&bytes[index + 1..index + 3])
|
||||
.ok()
|
||||
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
|
||||
match parsed {
|
||||
Some(byte) => {
|
||||
out.push(byte);
|
||||
index += 3;
|
||||
}
|
||||
None => {
|
||||
out.push(b'%');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
index += 1;
|
||||
}
|
||||
byte => {
|
||||
out.push(byte);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decode, oauth_error, Query};
|
||||
use crate::auth::error::AuthError;
|
||||
|
||||
#[test]
|
||||
fn query_decodes_percent_and_plus() {
|
||||
let query = Query::parse("code=ab%2Fcd&state=s+1&empty=");
|
||||
assert_eq!(query.get("code"), Some("ab/cd"));
|
||||
assert_eq!(query.get("state"), Some("s 1"));
|
||||
assert_eq!(query.get("empty"), Some(""));
|
||||
assert_eq!(query.get("missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_keeps_broken_escapes() {
|
||||
assert_eq!(decode("100%"), "100%");
|
||||
assert_eq!(decode("%zz"), "%zz");
|
||||
assert_eq!(decode("a%20b"), "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_denied_maps_to_canceled() {
|
||||
assert_eq!(oauth_error("access_denied", None), AuthError::Canceled);
|
||||
match oauth_error("invalid_request", Some("bad scope")) {
|
||||
AuthError::Denied { code, description } => {
|
||||
assert_eq!(code, "invalid_request");
|
||||
assert_eq!(description, "bad scope");
|
||||
}
|
||||
other => panic!("应是 Denied,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
//! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。
|
||||
//!
|
||||
//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」):
|
||||
//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`;
|
||||
//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。
|
||||
//! 两条流**共用同一个端点族**(`research/08` §2 硬性约束 1:租户只能用 `consumers`):
|
||||
//! - 设备码流:`devicecode` + `token`;
|
||||
//! - 授权码流:`authorize`(浏览器打开)+ 同一个 `token`(`grant_type=authorization_code`)。
|
||||
//!
|
||||
//! 都走 `login.microsoftonline.com/consumers/oauth2/v2.0/*`:用 `common` 或 AAD 租户 id
|
||||
//! 拿 `XboxLive.signin` 会直接被拒(`analyse/04` §4 的「第一个坑」)。
|
||||
//!
|
||||
//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链,
|
||||
//! 生产默认走 [`MicrosoftEndpoints::consumers`]。
|
||||
@@ -12,7 +15,9 @@
|
||||
pub struct MicrosoftEndpoints {
|
||||
/// 设备码申请(`POST`,表单 `client_id` + `scope`)。
|
||||
pub device_code: String,
|
||||
/// 换 token / 刷新 token(`POST`,表单)。
|
||||
/// 授权码流的授权页(`GET`,浏览器打开;`research/08` §3 方案 A)。
|
||||
pub authorize: String,
|
||||
/// 换 token / 刷新 token / 授权码换 token(`POST`,表单)。
|
||||
pub token: String,
|
||||
/// Xbox Live 用户认证(`POST` JSON)。
|
||||
pub xbl_authenticate: String,
|
||||
@@ -35,6 +40,8 @@ impl MicrosoftEndpoints {
|
||||
Self {
|
||||
device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
|
||||
.to_string(),
|
||||
authorize: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
|
||||
.to_string(),
|
||||
token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(),
|
||||
xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(),
|
||||
xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(),
|
||||
@@ -54,6 +61,7 @@ impl MicrosoftEndpoints {
|
||||
let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/'));
|
||||
Self {
|
||||
device_code: join("devicecode"),
|
||||
authorize: join("authorize"),
|
||||
token: join("token"),
|
||||
xbl_authenticate: join("xbl"),
|
||||
xsts_authorize: join("xsts"),
|
||||
@@ -66,9 +74,10 @@ impl MicrosoftEndpoints {
|
||||
|
||||
/// 所有端点(调试/诊断用)。
|
||||
#[must_use]
|
||||
pub fn all(&self) -> [&str; 8] {
|
||||
pub fn all(&self) -> [&str; 9] {
|
||||
[
|
||||
&self.device_code,
|
||||
&self.authorize,
|
||||
&self.token,
|
||||
&self.xbl_authenticate,
|
||||
&self.xsts_authorize,
|
||||
@@ -95,11 +104,15 @@ mod tests {
|
||||
let endpoints = MicrosoftEndpoints::consumers();
|
||||
assert!(endpoints.device_code.contains("login.microsoftonline.com"));
|
||||
assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token"));
|
||||
// 授权码流的授权页与设备码流同族同租户(`research/08` §2:只能用 consumers)。
|
||||
assert!(endpoints
|
||||
.authorize
|
||||
.contains("/consumers/oauth2/v2.0/authorize"));
|
||||
assert!(endpoints
|
||||
.xbl_authenticate
|
||||
.contains("user.auth.xboxlive.com"));
|
||||
assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com"));
|
||||
// 授权码流的 login.live.com 不在这张表里(本期不实现)。
|
||||
// 微软 OAuth 两流都不走 HMCL 的 login.live.com 端点族。
|
||||
assert!(endpoints
|
||||
.all()
|
||||
.iter()
|
||||
@@ -110,6 +123,7 @@ mod tests {
|
||||
fn all_on_keeps_paths_distinct() {
|
||||
let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/");
|
||||
assert_eq!(endpoints.device_code, "http://127.0.0.1:1234/devicecode");
|
||||
assert_eq!(endpoints.authorize, "http://127.0.0.1:1234/authorize");
|
||||
assert_eq!(
|
||||
endpoints.entitlements_license,
|
||||
"http://127.0.0.1:1234/entitlements/license"
|
||||
|
||||
@@ -223,7 +223,7 @@ pub(crate) async fn refresh_tokens(
|
||||
tokens_from(&reply, &endpoints.token)
|
||||
}
|
||||
|
||||
fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
|
||||
pub(crate) fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
|
||||
let access_token = reply
|
||||
.text("access_token")
|
||||
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?;
|
||||
@@ -237,7 +237,7 @@ fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
|
||||
}
|
||||
|
||||
/// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。
|
||||
fn oauth_error(reply: &Reply, url: &str) -> AuthError {
|
||||
pub(crate) fn oauth_error(reply: &Reply, url: &str) -> AuthError {
|
||||
let Some(code) = reply.text("error") else {
|
||||
return AuthError::network(
|
||||
url.split('?').next().unwrap_or(url),
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
//! 微软轨:device-code 全流程 + 会话/刷新语义(`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5)。
|
||||
//! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义
|
||||
//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。
|
||||
//!
|
||||
//! 分四块,各管一件事:
|
||||
//! 分五块,各管一件事:
|
||||
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
|
||||
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
|
||||
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
|
||||
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token);
|
||||
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
|
||||
//!
|
||||
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
|
||||
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
|
||||
//!
|
||||
//! # client id 从哪来
|
||||
//!
|
||||
//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的
|
||||
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
|
||||
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
|
||||
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
|
||||
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
|
||||
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么——
|
||||
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
|
||||
|
||||
pub mod authcode;
|
||||
pub mod callback;
|
||||
pub mod chain;
|
||||
pub mod endpoints;
|
||||
pub mod flow;
|
||||
|
||||
pub mod session;
|
||||
|
||||
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
|
||||
pub use callback::CallbackServer;
|
||||
pub use endpoints::MicrosoftEndpoints;
|
||||
pub use flow::{DeviceCodePrompt, PollPolicy};
|
||||
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
|
||||
@@ -45,6 +55,7 @@ pub struct MicrosoftAuth {
|
||||
client_id: String,
|
||||
endpoints: MicrosoftEndpoints,
|
||||
poll: PollPolicy,
|
||||
authcode: AuthCodePolicy,
|
||||
journal: Option<AuthJournal>,
|
||||
}
|
||||
|
||||
@@ -62,6 +73,7 @@ impl MicrosoftAuth {
|
||||
client_id: client_id.into(),
|
||||
endpoints: MicrosoftEndpoints::consumers(),
|
||||
poll: PollPolicy::default(),
|
||||
authcode: AuthCodePolicy::default(),
|
||||
journal: None,
|
||||
})
|
||||
}
|
||||
@@ -131,6 +143,13 @@ impl MicrosoftAuth {
|
||||
self
|
||||
}
|
||||
|
||||
/// 换授权码流参数(回调超时 / 固定端口 / 绑定重试 / 固定 state;测试与 CLI 用)。
|
||||
#[must_use]
|
||||
pub fn with_authcode_policy(mut self, policy: AuthCodePolicy) -> Self {
|
||||
self.authcode = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// 当前端点表。
|
||||
#[must_use]
|
||||
pub fn endpoints(&self) -> &MicrosoftEndpoints {
|
||||
@@ -177,6 +196,34 @@ impl MicrosoftAuth {
|
||||
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await
|
||||
}
|
||||
|
||||
/// 授权码流登录(浏览器 + 本地回调,`research/08` §3 方案 A,GUI 的默认入口)。
|
||||
///
|
||||
/// `present` 在端口绑定之后、等待回调之前被调用:宿主在这一刻拉起系统默认浏览器
|
||||
/// (core 不做 UI,也不假设有 GUI——CLI 可以只把 URL 打到 stderr)。
|
||||
/// 失败时与 [`Self::device_code_login`] 一样往失败日志追加一条结构化记录(M38)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 端口绑不上/网络失败、等回调超时、用户在授权页取消、OAuth 拒绝,以及整链的
|
||||
/// [`AuthError`](含白名单未批时 `login_with_xbox` 的 403 诊断)。
|
||||
pub async fn authcode_login<F>(&self, present: F) -> AuthResult<MicrosoftSession>
|
||||
where
|
||||
F: FnOnce(&AuthCodePrompt),
|
||||
{
|
||||
let result = authcode::login(
|
||||
&self.client,
|
||||
&self.endpoints,
|
||||
&self.client_id,
|
||||
self.authcode.clone(),
|
||||
present,
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = &result {
|
||||
self.record_failure("authcode_login", error);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 账户体系:三轨(离线 UUID / 微软 device-code / authlib-injector)、凭据存储边界。
|
||||
//! 账户体系:三轨(离线 UUID / 微软 device-code 与授权码 / authlib-injector)、凭据存储边界。
|
||||
//!
|
||||
//! 设计定稿:`DevDocs/PlanDoc/07-accounts.md`(决策 D9/Q21),协议细节来自
|
||||
//! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。
|
||||
@@ -19,7 +19,8 @@
|
||||
//! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象);
|
||||
//! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`);
|
||||
//! - [`offline`]:HMCL 兼容离线 UUID + 启动占位;
|
||||
//! - [`microsoft`]:device-code 全链(XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定;
|
||||
//! - [`microsoft`]:微软两条登录流(device-code / 授权码+本地回调)+ 共用后链
|
||||
//! (XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定;
|
||||
//! - [`authlib`]:标准 Yggdrasil 协议(authenticate/refresh/validate/invalidate,账户侧);
|
||||
//! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期);
|
||||
//! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38);
|
||||
@@ -47,6 +48,8 @@ pub use account::{AccountId, AccountKind, AccountMeta, Credentials};
|
||||
pub use body::{BodyKind, ResponseDetail};
|
||||
pub use error::{AuthError, AuthResult};
|
||||
pub use journal::{AuthFailureEvent, AuthJournal};
|
||||
pub use microsoft::{MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason};
|
||||
pub use microsoft::{
|
||||
AuthCodePolicy, AuthCodePrompt, MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason,
|
||||
};
|
||||
pub use secret::Secret;
|
||||
pub use store::{AccountStore, AuthPaths};
|
||||
@@ -186,13 +186,24 @@ pub fn dedupe(runtimes: Vec<JavaRuntime>) -> Vec<JavaRuntime> {
|
||||
kept
|
||||
}
|
||||
|
||||
/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。
|
||||
/// 去重键:剥掉 Windows 的 `\\?\` 前缀、分隔符一律折成 `/`、大小写只在 Windows 折叠。
|
||||
///
|
||||
/// 前两步与平台无关:同一条 `bin/java[.exe]` 用 `/` 还是 `\` 拼出来都是同一个文件,
|
||||
/// 键必须相同——`JAVA_HOME` 的写法与扫盘拼出来的路径常在这点上不一致,键不齐就重复列。
|
||||
/// 大小写折叠**只能**跟着平台走:NTFS 不区分大小写,而 POSIX 上 `Java` 与 `java` 是两个
|
||||
/// 目录,折了会把两份真实安装误并成一个。
|
||||
///
|
||||
/// 代价(已知且有意的取舍):POSIX 上文件名里的字面反斜杠也会被当分隔符。JDK 安装路径带
|
||||
/// 反斜杠属于病态情况,换来的是「同一条 java 的两种拼法永远同键」这条平台无关的不变量。
|
||||
#[must_use]
|
||||
pub fn path_key(path: &Path) -> String {
|
||||
let text = path.display().to_string();
|
||||
let text = text.strip_prefix(r"\\?\").unwrap_or(&text).to_string();
|
||||
let text = text
|
||||
.strip_prefix(r"\\?\")
|
||||
.unwrap_or(&text)
|
||||
.replace('\\', "/");
|
||||
if cfg!(windows) {
|
||||
text.replace('/', "\\").to_ascii_lowercase()
|
||||
text.to_ascii_lowercase()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
@@ -236,11 +247,56 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_key_normalises_separators() {
|
||||
let left = path_key(std::path::Path::new("C:/a/b/java.exe"));
|
||||
let right = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
|
||||
assert_eq!(left, right);
|
||||
assert!(!path_key(std::path::Path::new(r"\\?\C:\a\java.exe")).starts_with(r"\\"));
|
||||
fn path_key_normalises_separators_on_every_platform() {
|
||||
// 平台无关的不变量:同一条 `bin/java` 用 `/` 还是 `\` 拼出来,键必须相同。
|
||||
// (M49:原来这里只在 Windows 上成立,Linux 上 `\` 原样留着 → 断言挂。)
|
||||
let forward = path_key(std::path::Path::new("C:/a/b/java.exe"));
|
||||
let backward = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
|
||||
assert_eq!(forward, backward);
|
||||
assert!(
|
||||
!forward.contains('\\'),
|
||||
"键里不该残留反斜杠(与平台无关):{forward}"
|
||||
);
|
||||
assert!(
|
||||
!forward.starts_with(r"\\"),
|
||||
"`\\\\?\\` 前缀不进键:{forward}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_install_normalises_to_one_key_whatever_the_spelling() {
|
||||
// 回归钉子:`JAVA_HOME` 与扫盘常对同一个 java 给出不同写法,四种拼法必须同键。
|
||||
let spellings = [
|
||||
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||
r"C:\Program Files\Java\jdk-21\bin\java.exe",
|
||||
r"\\?\C:\Program Files\Java\jdk-21\bin\java.exe",
|
||||
r"C:\Program Files\Java\jdk-21/bin/java.exe",
|
||||
];
|
||||
let key = path_key(std::path::Path::new(spellings[0]));
|
||||
for spelling in spellings {
|
||||
assert_eq!(
|
||||
path_key(std::path::Path::new(spelling)),
|
||||
key,
|
||||
"同一安装的两种写法必须同键:{spelling}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_key_folds_case_only_where_the_filesystem_does() {
|
||||
// 大小写折叠跟平台走,两侧都钉住:NTFS 不区分大小写 → 折;
|
||||
// POSIX 上 `Java` 与 `java` 是两个目录 → 不折(折了会把两份真实安装误并)。
|
||||
let upper = path_key(std::path::Path::new(
|
||||
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||
));
|
||||
let lower = path_key(std::path::Path::new(
|
||||
"c:/program files/java/jdk-21/bin/java.exe",
|
||||
));
|
||||
if cfg!(windows) {
|
||||
assert_eq!(upper, lower, "Windows:大小写不同的同一路径应同键");
|
||||
} else {
|
||||
assert_ne!(upper, lower, "POSIX:大小写是有效区别,不应同键");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -81,6 +81,15 @@ impl RunningProcess {
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
process.creation_flags(CREATE_NEW_PROCESS_GROUP);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
// 与 Windows 的 CREATE_NEW_PROCESS_GROUP 对称:子进程自成一个进程组,
|
||||
// `tree_kill` 的 `kill -- -<pgid>` 才能覆盖整棵子树。`process_group` 是
|
||||
// std 1.64+ 的**安全** API,不需要 pre_exec / unsafe(旧注释说“unsafe 被挡”已过时)。
|
||||
// 参数 0 = `pgid = 子进程 pid`。
|
||||
process.process_group(0);
|
||||
}
|
||||
|
||||
let mut child = process.spawn().map_err(|source| LaunchError::Spawn {
|
||||
program: command.program.clone(),
|
||||
|
||||
@@ -6,27 +6,39 @@
|
||||
//! - Windows:`taskkill /T /F /PID`(`/T` 递归子树)。P0 不引入 `windows-sys`,因此没有
|
||||
//! Job Object——Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)留 P1,替换点就在本文件
|
||||
//! (tower 已批准,见 `launch-接口约定.md` §7-1)。
|
||||
//! - POSIX:`kill -TERM -<pid>`(命不中是常态,因为我们没有 `setsid`——`unsafe` pre_exec 被
|
||||
//! workspace `unsafe_code = deny` 挡住)→ 宽限 → `kill -KILL -<pid>`,再退到单 pid。
|
||||
//! - POSIX:子进程由 [`crate::launch::process`] 用 `CommandExt::process_group(0)` 放进**自己的**
|
||||
//! 进程组(与 Windows 的 `CREATE_NEW_PROCESS_GROUP` 对称,std 1.64+ 的安全 API),因此
|
||||
//! `kill -TERM -- -<pgid>` 一次覆盖整棵子树 → 宽限 → `kill -KILL -- -<pgid>`,再补一发单 pid。
|
||||
//!
|
||||
//! 两个平台都先尝试“温和”,宽限期后再“强制”。
|
||||
//!
|
||||
//! # 两条来自 CI 的硬约束
|
||||
//!
|
||||
//! 1. **组杀必须先验组**([`group_kill_target`]):目标进程必须是它自己进程组的**组长**,
|
||||
//! 且该组不能是我们所在的组。否则一次 `kill -- -<pgid>` 会把测试进程(CI 容器里全体进程
|
||||
//! 常常共用 pgid 1)一起带走——M48 之前 `test (ubuntu-latest)` 就是这么被 SIGTERM 打断的。
|
||||
//! 验不过就只发单 pid。
|
||||
//! 2. **`--` 不能省**:procps-ng 的 `kill` 会把裸 `-<pgid>` 当成又一个信号选项吞掉,**退出码 0
|
||||
//! 却什么都没发**(procps-ng 4.0.2 实测)。旧实现把这种“假成功”当组杀成功直接返回,
|
||||
//! 于是单 pid 兜底永远不执行,`stop()` 只能干等超时——`stop_kills_process_tree` 与
|
||||
//! `pipeline_stop_records_killed_exit` 两条红灯的真正死因。
|
||||
//!
|
||||
//! ⚠️ 外部命令的退出码**不能**用来判断“进程是否已被结束”:温和阶段成功关掉进程后,
|
||||
//! 再发 `taskkill /F` 会得到 128(“没有这个运行实例”)。所以 [`crate::launch::process`]
|
||||
//! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。
|
||||
|
||||
use std::io;
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。
|
||||
pub fn terminate(pid: u32) -> io::Result<()> {
|
||||
check_pid(pid)?;
|
||||
imp::signal(pid, false)
|
||||
}
|
||||
|
||||
/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。
|
||||
pub fn force_kill(pid: u32) -> io::Result<()> {
|
||||
check_pid(pid)?;
|
||||
imp::signal(pid, true)
|
||||
}
|
||||
|
||||
@@ -34,17 +46,70 @@ pub fn force_kill(pid: u32) -> io::Result<()> {
|
||||
///
|
||||
/// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用
|
||||
/// [`crate::launch::process::RunningProcess::stop`]。
|
||||
pub fn kill_tree(pid: u32, grace: Duration) -> io::Result<()> {
|
||||
if pid == 0 {
|
||||
return Err(io::Error::other("无效的 pid 0"));
|
||||
}
|
||||
pub fn kill_tree(pid: u32, grace: std::time::Duration) -> io::Result<()> {
|
||||
check_pid(pid)?;
|
||||
let _ = terminate(pid);
|
||||
if !grace.is_zero() {
|
||||
thread::sleep(grace);
|
||||
std::thread::sleep(grace);
|
||||
}
|
||||
force_kill(pid)
|
||||
}
|
||||
|
||||
/// `pid` 0 意味着“当前进程组”(POSIX `kill` 的 `-0`)——绝不能顺手发出去。
|
||||
fn check_pid(pid: u32) -> io::Result<()> {
|
||||
if pid == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"无效的 pid 0:拒绝向当前进程组发信号",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 允许组杀的进程组号;`None` = 只能发单 pid。
|
||||
///
|
||||
/// 三重校验,任一不过就退回单 pid:
|
||||
/// 1. 目标必须是它自己进程组的组长(`pgid == pid`)——否则 `-<pgid>` 指的不是“它的”子树;
|
||||
/// 2. 必须能读到我们自己所在的进程组;
|
||||
/// 3. 目标组不能就是我们所在的组——否则信号会打到自己人身上。
|
||||
#[cfg(unix)]
|
||||
pub fn group_kill_target(pid: u32) -> Option<u32> {
|
||||
if pid == 0 {
|
||||
return None;
|
||||
}
|
||||
let target = pgid_of(pid)?;
|
||||
if target != pid {
|
||||
return None;
|
||||
}
|
||||
let own = pgid_of(std::process::id())?;
|
||||
(target != own).then_some(target)
|
||||
}
|
||||
|
||||
/// 读 `pid` 所在进程组号。Linux 走 `/proc`,其它 POSIX 用 `ps`;读不到就是 `None`(不发组杀)。
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pgid_of(pid: u32) -> Option<u32> {
|
||||
// `/proc/<pid>/stat`: `pid (comm) state ppid pgrp ...`,`comm` 可能含空格和括号,
|
||||
// 所以从**最后一个** `)` 之后开始数字段。
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let tail = &stat[stat.rfind(')')? + 1..];
|
||||
let mut fields = tail.split_whitespace();
|
||||
fields.next()?; // state
|
||||
fields.next()?; // ppid
|
||||
fields.next()?.parse().ok() // pgrp
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
fn pgid_of(pid: u32) -> Option<u32> {
|
||||
let output = Command::new("ps")
|
||||
.args(["-o", "pgid=", "-p", &pid.to_string()])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
@@ -79,28 +144,42 @@ mod imp {
|
||||
|
||||
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
|
||||
let flag = if force { "-KILL" } else { "-TERM" };
|
||||
// 先按进程组(`-<pid>`);没有 setsid 时通常失败,再退到单 pid。
|
||||
if run_kill(flag, &format!("-{pid}"))?.is_some() {
|
||||
return Ok(());
|
||||
let mut delivered = false;
|
||||
// 验过的独立进程组:一次覆盖组长 + 它的全部后代。
|
||||
if let Some(pgid) = group_kill_target(pid) {
|
||||
let group = format!("-{pgid}");
|
||||
delivered |= run_kill(flag, &["--", &group])?;
|
||||
}
|
||||
match run_kill(flag, &pid.to_string())? {
|
||||
Some(status) if status.success() => Ok(()),
|
||||
Some(status) => Err(io::Error::other(format!("kill 退出码 {status}"))),
|
||||
None => Err(io::Error::other("找不到 kill 命令")),
|
||||
// 单 pid 兜底永远执行:组杀不可用/进程已不在组里时靠它。
|
||||
let single = pid.to_string();
|
||||
delivered |= run_kill(flag, &[&single])?;
|
||||
if delivered {
|
||||
Ok(())
|
||||
} else {
|
||||
// 常见于“进程已经退出”,不值得中断关闭流程;但绝不谎报成功。
|
||||
Err(io::Error::other(format!(
|
||||
"kill {flag} {pid} 没有送达(进程可能已退出)"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn run_kill(flag: &str, target: &str) -> io::Result<Option<std::process::ExitStatus>> {
|
||||
/// 发一发 `kill <flag> <args...>`,返回是否送达。
|
||||
///
|
||||
/// `kill` 不存在时给**明确**错误而不是静默失败:否则调用方只会看到“进程仍在运行”,
|
||||
/// 排查方向会被带偏(M48 之前的红灯就是这么来的)。
|
||||
fn run_kill(flag: &str, args: &[&str]) -> io::Result<bool> {
|
||||
match Command::new("kill")
|
||||
.arg(flag)
|
||||
.arg(target)
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
{
|
||||
Ok(status) if status.success() => Ok(Some(status)),
|
||||
Ok(_) => Ok(None),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Ok(status) => Ok(status.success()),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"找不到 kill 命令:POSIX 进程树回收依赖 procps(Linux)或系统自带 kill(macOS/BSD)",
|
||||
)),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量;
|
||||
//! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理;
|
||||
//! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr);
|
||||
//! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断);
|
||||
//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38);
|
||||
//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义;
|
||||
//! - `authlib_api`:Yggdrasil 四动作的报文级对接。
|
||||
@@ -12,6 +13,7 @@ mod support;
|
||||
|
||||
mod auth_failure_journal;
|
||||
mod authlib_api;
|
||||
mod microsoft_authcode;
|
||||
mod microsoft_diagnostics;
|
||||
mod microsoft_flow;
|
||||
mod microsoft_refresh;
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! 授权码流(`research/08` §3 方案 A):本地回调服务器 + 授权 URL + code 换链。
|
||||
//!
|
||||
//! 覆盖验收口径:授权码换到 MS token → XBL → XSTS 全通;白名单未批前
|
||||
//! `login_with_xbox` 的 403 只要求**诊断文案正确**(M38 版),不绕过。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::auth::microsoft::{
|
||||
AuthCodePolicy, AuthCodePrompt, CallbackServer, MicrosoftAuth, MicrosoftEndpoints,
|
||||
};
|
||||
use unlucky_core::auth::AuthError;
|
||||
|
||||
use crate::support::{full_routes, response_of, AuthMock, MockReply};
|
||||
|
||||
const CLIENT_ID: &str = "test-client-id";
|
||||
|
||||
fn policy(state: &str, timeout_ms: u64) -> AuthCodePolicy {
|
||||
AuthCodePolicy {
|
||||
callback_timeout: Duration::from_millis(timeout_ms),
|
||||
bind_attempts: 3,
|
||||
port: None,
|
||||
state: Some(state.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 模拟浏览器:把 `code` 送到本地回调地址(真发一次 HTTP,走 localhost 解析)。
|
||||
async fn deliver(redirect_uri: &str, query: &str) {
|
||||
let url = format!("{redirect_uri}?{query}");
|
||||
// 回调服务器可能在 accept 中,给一拍让绑定先落地。
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
let response = reqwest::get(&url).await.expect("浏览器回调");
|
||||
assert_eq!(response.status(), 200, "回调页应回 200");
|
||||
let page = response.text().await.expect("回调页正文");
|
||||
assert!(page.contains("登录完成"), "回调页应是给用户看的完成页");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authcode_login_completes_the_chain() {
|
||||
let mock = AuthMock::start(full_routes()).await;
|
||||
let auth = MicrosoftAuth::new(CLIENT_ID)
|
||||
.expect("构造")
|
||||
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
|
||||
.with_authcode_policy(policy("state-abc", 5_000));
|
||||
|
||||
let mut prompted: Option<AuthCodePrompt> = None;
|
||||
let session = auth
|
||||
.authcode_login(|prompt| {
|
||||
prompted = Some(prompt.clone());
|
||||
let redirect = prompt.redirect_uri.clone();
|
||||
tokio::spawn(
|
||||
async move { deliver(&redirect, "code=the-auth-code&state=state-abc").await },
|
||||
);
|
||||
})
|
||||
.await
|
||||
.expect("授权码登录成功");
|
||||
|
||||
// 1. 宿主拿到的是「授权 URL + 本地回调地址」,两者端口必须一致。
|
||||
let prompt = prompted.expect("必须回调展示授权 URL");
|
||||
let redirect = prompt.redirect_uri.clone();
|
||||
assert!(redirect.starts_with("http://localhost:"), "{redirect}");
|
||||
assert!(redirect.ends_with("/auth"), "{redirect}");
|
||||
assert!(prompt.authorization_url.contains("prompt=select_account"));
|
||||
// 端点表在测试里整体指向 mock,所以授权页的路径是 `/authorize`(租户口径见向量单测)。
|
||||
assert!(
|
||||
prompt.authorization_url.starts_with(&mock.base())
|
||||
&& prompt
|
||||
.authorization_url
|
||||
.contains("/authorize?client_id=test-client-id"),
|
||||
"{}",
|
||||
prompt.authorization_url
|
||||
);
|
||||
assert!(prompt.authorization_url.contains("state=state-abc"));
|
||||
assert_eq!(prompt.timeout, Duration::from_millis(5_000));
|
||||
|
||||
// 2. code 换 token 的表单:grant_type + 原样重定向 URI(URI 严格一致那条坑)。
|
||||
let token_bodies = mock.bodies_on("/token");
|
||||
assert_eq!(token_bodies.len(), 1, "只该换一次 token");
|
||||
let body = &token_bodies[0];
|
||||
assert!(body.contains("grant_type=authorization_code"), "{body}");
|
||||
assert!(body.contains("code=the-auth-code"), "{body}");
|
||||
assert!(
|
||||
body.contains("redirect_uri=http%3A%2F%2Flocalhost%3A") && !body.contains(&redirect),
|
||||
"redirect_uri 必须是编码后的同一字面量:{body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("scope=XboxLive.signin+offline_access")
|
||||
|| body.contains("scope=XboxLive.signin%20offline_access"),
|
||||
"{body}"
|
||||
);
|
||||
// 授权 URL 里的 redirect_uri 与 token 请求里的必须是同一个端口。
|
||||
let encoded = redirect.replace(':', "%3A").replace('/', "%2F");
|
||||
assert!(
|
||||
prompt.authorization_url.contains(&encoded),
|
||||
"{}",
|
||||
prompt.authorization_url
|
||||
);
|
||||
assert!(body.contains(&encoded), "{body}");
|
||||
|
||||
// 3. 后链与会话:与设备码流同一套字段语义。
|
||||
assert_eq!(session.profile_name, "Steve");
|
||||
assert_eq!(session.access_token.expose(), "mc-access-token-value");
|
||||
assert_eq!(session.refresh_token.expose(), "live-refresh-token-value");
|
||||
assert!(!format!("{session:?}").contains("mc-access-token-value"));
|
||||
assert_eq!(mock.bodies_on("/xbl").len(), 1);
|
||||
assert_eq!(mock.bodies_on("/xsts").len(), 1);
|
||||
mock.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_server_ignores_unrelated_requests_and_bad_state() {
|
||||
let server = CallbackServer::bind(None, 1, "state-ok")
|
||||
.await
|
||||
.expect("绑定");
|
||||
let port = server.port();
|
||||
let browser = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let base = format!("http://localhost:{port}");
|
||||
// favicon / 浏览器预热:404 但绝不能结束等待。
|
||||
let favicon = reqwest::get(format!("{base}/favicon.ico"))
|
||||
.await
|
||||
.expect("favicon");
|
||||
assert_eq!(favicon.status(), 404);
|
||||
// state 不匹配(旧标签页的迟到回调):400,继续等。
|
||||
let stale = reqwest::get(format!("{base}/auth?code=stale-code&state=state-old"))
|
||||
.await
|
||||
.expect("迟到的回调");
|
||||
assert_eq!(stale.status(), 400);
|
||||
let good = reqwest::get(format!("{base}/auth?code=good-code&state=state-ok"))
|
||||
.await
|
||||
.expect("真回调");
|
||||
assert_eq!(good.status(), 200);
|
||||
});
|
||||
|
||||
let code = server
|
||||
.wait_for_code(Duration::from_secs(5))
|
||||
.await
|
||||
.expect("应拿到 code");
|
||||
assert_eq!(code, "good-code");
|
||||
browser.await.expect("模拟浏览器任务");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreign_state_cannot_cancel_the_login() {
|
||||
// P2 回归:回调先校验 state,再处理 error(RFC 6749 §4.1.2.1 / RFC 9207)。
|
||||
// 修之前 `GET /auth?error=access_denied`(state 随便给或不给)就能把登录判成 Canceled。
|
||||
let server = CallbackServer::bind(None, 1, "state-ok")
|
||||
.await
|
||||
.expect("绑定");
|
||||
let port = server.port();
|
||||
let browser = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let base = format!("http://localhost:{port}/auth");
|
||||
// 1. 连 state 都不给:与本次登录无关 → 400,继续等。
|
||||
let bare = reqwest::get(format!("{base}?error=access_denied"))
|
||||
.await
|
||||
.expect("伪造取消");
|
||||
assert_eq!(bare.status(), 400);
|
||||
// 2. state 明显不匹配:同样忽略,绝不当成用户取消。
|
||||
let foreign = reqwest::get(format!("{base}?error=access_denied&state=not-our-state"))
|
||||
.await
|
||||
.expect("伪造取消带错 state");
|
||||
assert_eq!(foreign.status(), 400);
|
||||
// 3. 真正带对 state 的回调仍然能完成登录。
|
||||
let good = reqwest::get(format!("{base}?code=good-code&state=state-ok"))
|
||||
.await
|
||||
.expect("真回调");
|
||||
assert_eq!(good.status(), 200);
|
||||
});
|
||||
|
||||
let code = server
|
||||
.wait_for_code(Duration::from_secs(5))
|
||||
.await
|
||||
.expect("伪造的 access_denied 不能中止登录");
|
||||
assert_eq!(code, "good-code");
|
||||
browser.await.expect("模拟浏览器任务");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_denied_with_our_state_is_still_canceled() {
|
||||
// 另一半语义:用户真的在授权页点了取消(带本机 state)→ 仍然是 Canceled。
|
||||
let server = CallbackServer::bind(None, 1, "state-ok")
|
||||
.await
|
||||
.expect("绑定");
|
||||
let port = server.port();
|
||||
let browser = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let url = format!("http://localhost:{port}/auth?error=access_denied&state=state-ok");
|
||||
let _ = reqwest::get(url).await;
|
||||
});
|
||||
let error = server
|
||||
.wait_for_code(Duration::from_secs(5))
|
||||
.await
|
||||
.expect_err("本机 state 的取消要生效");
|
||||
assert_eq!(error, AuthError::Canceled);
|
||||
browser.await.expect("模拟浏览器任务");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_timeout_is_reported() {
|
||||
let auth = MicrosoftAuth::new(CLIENT_ID)
|
||||
.expect("构造")
|
||||
.with_authcode_policy(policy("state-abc", 30));
|
||||
let error = auth
|
||||
.authcode_login(|_prompt| {})
|
||||
.await
|
||||
.expect_err("没人回调就该超时");
|
||||
assert_eq!(error.code(), "timeout");
|
||||
assert!(
|
||||
error.message().contains("浏览器回调"),
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_denied_on_the_callback_page_is_canceled() {
|
||||
let auth = MicrosoftAuth::new(CLIENT_ID)
|
||||
.expect("构造")
|
||||
.with_authcode_policy(policy("state-abc", 5_000));
|
||||
let error = auth
|
||||
.authcode_login(|prompt| {
|
||||
let redirect = prompt.redirect_uri.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
let url = format!("{redirect}?error=access_denied&state=state-abc");
|
||||
let _ = reqwest::get(&url).await;
|
||||
});
|
||||
})
|
||||
.await
|
||||
.expect_err("用户取消");
|
||||
assert_eq!(error, AuthError::Canceled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn busy_port_retries_then_reports_which_port() {
|
||||
// 先占住一个端口,再让回调服务器去绑同一个:必须失败并说清是哪个端口。
|
||||
let occupied = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("占端口");
|
||||
let port = occupied.local_addr().expect("地址").port();
|
||||
let error = CallbackServer::bind(Some(port), 2, "s")
|
||||
.await
|
||||
.expect_err("端口已被占");
|
||||
assert_eq!(error.code(), "network");
|
||||
assert!(
|
||||
error.message().contains(&port.to_string()),
|
||||
"文案要指出端口:{}",
|
||||
error.message()
|
||||
);
|
||||
assert!(error.message().contains("重试 2 次"), "{}", error.message());
|
||||
// 同一台机器上换个端口就能绑上(重试机制不是把错误吞掉)。
|
||||
let ok = CallbackServer::bind(None, 1, "s").await.expect("内核分配");
|
||||
assert_ne!(ok.port(), port);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_with_xbox_403_keeps_the_m38_diagnostic() {
|
||||
// 白名单未批的真机状态:授权码换到 token、XBL/XSTS 全通,最后一步 403。
|
||||
let mut routes = full_routes();
|
||||
let (_, login) = routes
|
||||
.iter_mut()
|
||||
.find(|(path, _)| *path == "/login_with_xbox")
|
||||
.expect("路由表里有 login_with_xbox");
|
||||
*login = vec![MockReply::json_status(
|
||||
403,
|
||||
json!({ "path": "/authentication/login_with_xbox", "errorType": "FORBIDDEN",
|
||||
"error": "Invalid app registration" }),
|
||||
)];
|
||||
let mock = AuthMock::start(routes).await;
|
||||
let auth = MicrosoftAuth::new(CLIENT_ID)
|
||||
.expect("构造")
|
||||
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
|
||||
.with_authcode_policy(policy("state-abc", 5_000));
|
||||
|
||||
let error = auth
|
||||
.authcode_login(|prompt| {
|
||||
let redirect = prompt.redirect_uri.clone();
|
||||
tokio::spawn(async move { deliver(&redirect, "code=c&state=state-abc").await });
|
||||
})
|
||||
.await
|
||||
.expect_err("403 必须冒出来");
|
||||
|
||||
let detail = response_of(&error);
|
||||
assert_eq!(detail.status, 403);
|
||||
assert_eq!(detail.detail_code(), "login_with_xbox_403");
|
||||
// 「授权码这条流本身是通的」——这正是验收止步的位置。
|
||||
assert_eq!(mock.bodies_on("/token").len(), 1);
|
||||
assert_eq!(mock.bodies_on("/xbl").len(), 1);
|
||||
assert_eq!(mock.bodies_on("/xsts").len(), 1);
|
||||
let message = error.message();
|
||||
assert!(message.contains("Minecraft 登录被拒"), "{message}");
|
||||
assert!(message.contains("Invalid app registration"), "{message}");
|
||||
mock.stop();
|
||||
}
|
||||
@@ -96,17 +96,20 @@ fn scanned_java_is_still_used_when_nothing_else_matches() {
|
||||
#[test]
|
||||
fn dedupe_collapses_one_install_found_twice() {
|
||||
// `JAVA_HOME` 指向 Program Files 里的 JDK → 同一个 java 不应列两次。
|
||||
// 第二条的「另一种写法」随平台给:Windows 上大小写和分隔符都可能不一致(NTFS 不区分
|
||||
// 大小写),POSIX 上大小写是有效区别,只能靠分隔符拼出同一路径的第二种写法。
|
||||
let duplicate = if cfg!(windows) {
|
||||
"c:/program files/java/jdk-21/bin/java.exe"
|
||||
} else {
|
||||
"C:\\Program Files\\Java\\jdk-21/bin/java.exe"
|
||||
};
|
||||
let candidates = discover::dedupe(vec![
|
||||
runtime(
|
||||
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||
21,
|
||||
JavaOrigin::JavaHome,
|
||||
),
|
||||
runtime(
|
||||
"c:/program files/java/jdk-21/bin/java.exe",
|
||||
21,
|
||||
JavaOrigin::System,
|
||||
),
|
||||
runtime(duplicate, 21, JavaOrigin::System),
|
||||
]);
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(candidates[0].origin, JavaOrigin::JavaHome);
|
||||
|
||||
@@ -13,3 +13,4 @@ mod process_smoke;
|
||||
mod pump_buffer;
|
||||
mod session_events;
|
||||
mod sink_cases;
|
||||
mod tree_kill_cases;
|
||||
@@ -111,6 +111,46 @@ fn stop_kills_process_tree() {
|
||||
assert!(!child.is_running());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn spawn_puts_child_in_its_own_process_group() {
|
||||
/// `/proc/<pid>/stat` 里的 `pgrp` 字段。
|
||||
fn pgid(pid: u32) -> u32 {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).expect("读 /proc stat");
|
||||
let tail = &stat[stat.rfind(')').expect("stat 应有 comm 括号") + 1..];
|
||||
tail.split_whitespace()
|
||||
.nth(2)
|
||||
.expect("stat 应有 pgrp 字段")
|
||||
.parse()
|
||||
.expect("pgrp 应是数字")
|
||||
}
|
||||
|
||||
let mut child = RunningProcess::spawn(
|
||||
&shell_command("sleep 30"),
|
||||
None,
|
||||
Arc::new(NullSink),
|
||||
config(),
|
||||
)
|
||||
.expect("spawn 应成功");
|
||||
let pid = child.pid();
|
||||
let own_pgid = pgid(std::process::id());
|
||||
|
||||
assert_eq!(
|
||||
pgid(pid),
|
||||
pid,
|
||||
"子进程应是自己的进程组组长(process_group(0))"
|
||||
);
|
||||
assert_ne!(pgid(pid), own_pgid, "子进程不能挂在测试进程的进程组上");
|
||||
assert_eq!(
|
||||
unlucky_core::launch::tree_kill::group_kill_target(pid),
|
||||
Some(pid),
|
||||
"独立组的组长才是合法的组杀目标"
|
||||
);
|
||||
|
||||
child.stop().expect("停止应成功");
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_reports_missing_program() {
|
||||
let mut command = shell_command("echo hi");
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 进程树回收的安全钉子:组杀只打“目标自己的独立进程组”,绝不命中我们所在的组。
|
||||
//!
|
||||
//! 这些用例钉的是 M48 的两条根因:
|
||||
//! - 子进程必须自成一个进程组(`process_group(0)`),否则 `kill -- -<pgid>` 没有可用的组;
|
||||
//! - 组杀前必须验组([`tree_kill::group_kill_target`]),否则 CI 容器里“全体进程 pgid=1”
|
||||
//! 的那种环境会被一发组杀连测试进程一起带走。
|
||||
//!
|
||||
//! `/proc` 只有 Linux 有,进程组相关的用例都用 `#[cfg(target_os = "linux")]` 门住;
|
||||
//! `pid == 0` 的守卫与平台无关,各平台都跑。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::launch::tree_kill;
|
||||
|
||||
/// `pid` 0 在 POSIX `kill` 里表示“当前进程组”——绝不能顺手发出去。
|
||||
#[test]
|
||||
fn pid_zero_is_rejected() {
|
||||
assert!(tree_kill::terminate(0).is_err(), "terminate(0) 必须拒绝");
|
||||
assert!(tree_kill::force_kill(0).is_err(), "force_kill(0) 必须拒绝");
|
||||
assert!(
|
||||
tree_kill::kill_tree(0, Duration::ZERO).is_err(),
|
||||
"kill_tree(0) 必须拒绝"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
||||
use unlucky_core::launch::tree_kill::group_kill_target;
|
||||
|
||||
/// `/proc/<pid>/stat` 里 `)` 之后的字段(state ppid pgrp ...)。
|
||||
fn stat_tail(pid: u32) -> Option<Vec<String>> {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let tail = stat.get(stat.rfind(')')? + 1..)?;
|
||||
Some(tail.split_whitespace().map(str::to_string).collect())
|
||||
}
|
||||
|
||||
fn pgid(pid: u32) -> Option<u32> {
|
||||
stat_tail(pid)?.get(2)?.parse().ok()
|
||||
}
|
||||
|
||||
/// 还活着(僵尸不算:已经死了只是没人 wait)。
|
||||
fn running(pid: u32) -> bool {
|
||||
matches!(stat_tail(pid).as_deref().and_then(|fields| fields.first().map(String::as_str)), Some(state) if state != "Z")
|
||||
}
|
||||
|
||||
fn wait_gone(pid: u32, budget: Duration) -> bool {
|
||||
let deadline = std::time::Instant::now() + budget;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !running(pid) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
!running(pid)
|
||||
}
|
||||
|
||||
/// 组长的子进程(`process_group(0)`):自带一个组。
|
||||
fn spawn_grouped(script: &str) -> Child {
|
||||
use std::os::unix::process::CommandExt;
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.expect("spawn 应成功")
|
||||
}
|
||||
|
||||
/// 和测试进程同组的子进程(不调 `process_group`):组长不是它。
|
||||
fn spawn_in_our_group(script: &str) -> Child {
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn 应成功")
|
||||
}
|
||||
|
||||
/// 测试失败时也别把 `sleep 30` 留在机器上。
|
||||
struct KillOnDrop(u32);
|
||||
|
||||
impl Drop for KillOnDrop {
|
||||
fn drop(&mut self) {
|
||||
let _ = tree_kill::force_kill(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 自己的进程组号:组杀安全的基准。
|
||||
fn self_pgid() -> u32 {
|
||||
pgid(std::process::id()).expect("/proc/self/stat 应可读")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_kill_target_accepts_only_a_verified_own_group() {
|
||||
let own_pgid = self_pgid();
|
||||
// 我们自己所在的组永远不是合法目标(哪怕我们正好是组长)。
|
||||
assert_eq!(
|
||||
group_kill_target(std::process::id()),
|
||||
None,
|
||||
"自己的进程号绝不能成为组杀目标"
|
||||
);
|
||||
assert_eq!(
|
||||
group_kill_target(own_pgid),
|
||||
None,
|
||||
"自己的进程组绝不能成为组杀目标"
|
||||
);
|
||||
assert_eq!(group_kill_target(0), None, "pid 0 不是合法目标");
|
||||
|
||||
// 同组的子进程:它不是组长,`-<pid>` 指向的不是“它的”组。
|
||||
let mut child = spawn_in_our_group("sleep 30");
|
||||
let pid = child.id();
|
||||
let _guard = KillOnDrop(pid);
|
||||
assert_eq!(pgid(pid), Some(own_pgid), "同组子进程应继承我们的 pgid");
|
||||
assert_eq!(
|
||||
group_kill_target(pid),
|
||||
None,
|
||||
"非组长子进程不能走组杀(`-<pid>` 会打到别人的组)"
|
||||
);
|
||||
|
||||
// 独立组的子进程:合法的组杀目标。
|
||||
let mut grouped = spawn_grouped("sleep 30");
|
||||
let grouped_pid = grouped.id();
|
||||
let _grouped_guard = KillOnDrop(grouped_pid);
|
||||
assert_eq!(pgid(grouped_pid), Some(grouped_pid), "子进程应是组长");
|
||||
assert_eq!(
|
||||
group_kill_target(grouped_pid),
|
||||
Some(grouped_pid),
|
||||
"独立组的组长可以组杀"
|
||||
);
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = grouped.kill();
|
||||
let _ = grouped.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_kill_reaps_grandchildren_without_touching_our_group() {
|
||||
let mut child = spawn_grouped("sleep 30 & sleep 30");
|
||||
let pid = child.id();
|
||||
let _guard = KillOnDrop(pid);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
|
||||
let children_of = || -> Vec<u32> {
|
||||
std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children"))
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.filter_map(|value| value.parse().ok())
|
||||
.collect()
|
||||
};
|
||||
let grandchildren = children_of();
|
||||
assert!(
|
||||
!grandchildren.is_empty(),
|
||||
"shell 应当先 fork 出一个 sleep 子进程:{grandchildren:?}"
|
||||
);
|
||||
for grandchild in &grandchildren {
|
||||
assert_eq!(pgid(*grandchild), Some(pid), "后代必须落在子进程自己的组里");
|
||||
}
|
||||
|
||||
tree_kill::terminate(pid).expect("组杀应送达");
|
||||
assert!(wait_gone(pid, Duration::from_secs(3)), "组长应被组杀带走");
|
||||
for grandchild in &grandchildren {
|
||||
assert!(
|
||||
wait_gone(*grandchild, Duration::from_secs(3)),
|
||||
"组杀应覆盖后代 {grandchild}"
|
||||
);
|
||||
}
|
||||
|
||||
// 组杀没有溢出:我们自己还在原来的组里,而且没有被信号带走。
|
||||
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
|
||||
assert_ne!(pid, self_pgid(), "目标组和我们的组必须不同");
|
||||
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminate_still_kills_a_child_that_shares_our_group() {
|
||||
let own_pgid = self_pgid();
|
||||
let mut child = spawn_in_our_group("sleep 30");
|
||||
let pid = child.id();
|
||||
let _guard = KillOnDrop(pid);
|
||||
assert_eq!(pgid(pid), Some(own_pgid));
|
||||
|
||||
tree_kill::terminate(pid).expect("单 pid 兜底应送达");
|
||||
assert!(
|
||||
wait_gone(pid, Duration::from_secs(3)),
|
||||
"同组子进程也必须能被单 pid TERM 干掉"
|
||||
);
|
||||
// 走到这里本身就说明“组杀没有命中自身进程组”——否则测试进程已被 TERM。
|
||||
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
|
||||
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
# 打包与发布工程(packaging/)
|
||||
|
||||
单一真相源:**单文件产物矩阵 + 发布契约**。`.github/workflows/release.yml` 按本文实现,
|
||||
`packaging/make_artifacts.py` 是本地与 CI **共用**的打包脚本(同一份代码 → 同一套目录布局)。
|
||||
本目录只放发布工程资产(脚本 / 说明 / 发布说明模板),不参与任何 crate 的编译。
|
||||
|
||||
## 1. 产物矩阵
|
||||
|
||||
| 槽位 | 产物文件名 | 形态 | 归档内容 |
|
||||
|------|-----------|------|---------|
|
||||
| `windows-x64` | `unlucky-launcher-<ver>-windows-x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 |
|
||||
| `windows-arm64` | `unlucky-launcher-<ver>-windows-arm64.exe` | **裸 exe**(同 x64 策略) | 同上;在 x64 宿主上交叉编译到 `aarch64-pc-windows-msvc` |
|
||||
| `linux-x64` | `unlucky-launcher-<ver>-linux-x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md` |
|
||||
| `linux-arm64` | `unlucky-launcher-<ver>-linux-arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 |
|
||||
| `macos-arm64` | `unlucky-launcher-<ver>-macos-arm64.tar.gz` | 单二进制归档 | 同上 |
|
||||
| `macos-x64` | `unlucky-launcher-<ver>-macos-x64.tar.gz` | 单二进制归档 | 同上 |
|
||||
| 全平台 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
|
||||
|
||||
约定细节:
|
||||
|
||||
- **"单文件"指可执行文件本身零运行时依赖**(无 WebView、无 JRE、无 sidecar 资源目录、无 DLL 旁挂)。
|
||||
归档里额外放三份合规文本是许可义务要求,不是运行时依赖;Windows 因为"一个 exe 装不下文本",
|
||||
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
|
||||
- 归档解压后是**一个顶层目录** `unlucky-launcher-<ver>-<槽位>/`,用户不会解压出一堆散文件。
|
||||
- `<ver>` 不带前导 `v`(tag 是 `v0.1.0`,文件名是 `0.1.0`)——由脚本统一裁剪。
|
||||
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
|
||||
同源构建的 sha256 不受打包时刻影响。
|
||||
- **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` /
|
||||
Mach-O `cputype`),与槽位声明的架构不符即失败——交叉槽位(x64 宿主产 arm64 exe)不会
|
||||
出现"名字是 arm64、内容还是 x64"的假产物。
|
||||
|
||||
## 2. 版本来源(唯一真相源)
|
||||
|
||||
版本号只有一个来源:**`Cargo.toml` 的 `[workspace.package] version`**。
|
||||
|
||||
- `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件),
|
||||
四个 workspace member 版本不一致时直接失败。
|
||||
- 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验,
|
||||
**不一致即失败**——避免出现 tag 说 0.2.0、产物里写 0.1.0 的发布事故。
|
||||
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
|
||||
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
|
||||
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
|
||||
|
||||
## 3. 怎么构建
|
||||
|
||||
### 本地(当前平台)
|
||||
|
||||
```bash
|
||||
cargo build --release --locked # 产出 target/release/{unlucky-launcher,ull}
|
||||
python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt
|
||||
python packaging/make_artifacts.py --version v0.1.0 # 顺带校验版本一致性
|
||||
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
|
||||
```
|
||||
|
||||
脚本按宿主自动判定槽位(Windows x64 / Windows arm64 / Linux x64 / Linux arm64 / macOS arm64 /
|
||||
macOS x64),其它组合直接报错退出——不在矩阵里的平台不出"凑合能跑"的产物。
|
||||
|
||||
交叉构建(例如在 x64 宿主上产 Windows arm64 产物)靠两个参数把"产物根"和"槽位"显式喂进去,
|
||||
脚本不去猜宿主与产物是不是同一架构:
|
||||
|
||||
```bash
|
||||
rustup target add aarch64-pc-windows-msvc
|
||||
cargo build --release --locked --target aarch64-pc-windows-msvc \
|
||||
--bin unlucky-launcher --bin ull
|
||||
python packaging/make_artifacts.py --slot windows-arm64 \
|
||||
--build-dir target/aarch64-pc-windows-msvc/release --out dist
|
||||
```
|
||||
|
||||
`--slot` 与宿主槽位不一致时,`ull --version` 冒烟会**自动跳过并打印原因**(arm64 产物在 x64
|
||||
宿主机上根本执行不了;拿"跳过"当通过是假证据,所以跳过永远带原因)——那条路径的验证只能落在
|
||||
CI 真机上:`windows-arm64` 由 cross 构建产物 + M50 的 dumpbin 导入表门禁覆盖,`linux-arm64`
|
||||
由 `ubuntu-22.04-arm` 原生 runner 上真跑冒烟覆盖。
|
||||
|
||||
### CI(四平台六槽位矩阵)
|
||||
|
||||
`.github/workflows/release.yml`:
|
||||
|
||||
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布(验证矩阵仍编得过)。
|
||||
`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」是两条互不冲突的规则。
|
||||
- 矩阵(六槽位;`matrix.build_dir` 参数化产物根,脚本不再假设产物一定在 `target/release`):
|
||||
`windows-latest` x64(MSVC + `-C target-feature=+crt-static`)、`windows-latest` **交叉编译**到
|
||||
`aarch64-pc-windows-msvc`(产物在 `target/aarch64-pc-windows-msvc/release`)、`ubuntu-22.04`
|
||||
(glibc 2.35 地板)、`ubuntu-22.04-arm`(原生 ARM64 runner,**与 x64 同 2.35 地板**)、`macos-latest`(arm64)、
|
||||
`macos-15-intel`(Intel x64;`macos-13` 已下线,`macos-26-intel` 为更新替位)。
|
||||
- 每个槽位 `cargo build --release --locked`(交叉槽位多两步:`rustup target add` + ARM64 库体检
|
||||
——查 VC 的 `lib/arm64/libcmt.lib`(`+crt-static` 靠它)与 SDK 的 `um/arm64/kernel32.lib`,
|
||||
缺了直接给出"给 VS 加 `VC.Tools.ARM64` 或改用原生 `windows-11-arm`"的可执行提示)→
|
||||
`make_artifacts.py --slot … --build-dir …` → 归档内容自检 → 上传 artifact。
|
||||
- `release` job:下载全部 artifact → 重算一份全量 `SHA256SUMS.txt` → 用 `packaging/release-notes.md`
|
||||
模板渲染支持矩阵 → `gh release create`(**pre-release 默认开**:首建时 `push` 触发按 pre-release,`workflow_dispatch` 可关)。
|
||||
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
|
||||
+ `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。
|
||||
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run,
|
||||
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
|
||||
`workflow_dispatch` 显式给了输入才断言——`prerelease=true` → `--prerelease`,`prerelease=false` →
|
||||
`--prerelease=false`。要改标记两条路等价且互不打架:① 走流水线
|
||||
`gh workflow run release.yml --ref v0.1.0 -f prerelease=false`(会落到 edit 分支显式断言)
|
||||
② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑不碰该标记,
|
||||
不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。
|
||||
|
||||
Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两者都属"标准工具链"(D21),
|
||||
但发布产物要**不依赖 VC++ 可再发行包**,`+crt-static` 把 CRT 静态链进 exe,Win10+ 的 UCRT 本身是
|
||||
系统组件,于是 exe 真正单文件可拷走。GNU 交叉产物在 CI 上还要拉 MinGW 链接器,收益为零。
|
||||
|
||||
## 4. 每平台运行时依赖
|
||||
|
||||
### Windows x64(Win10/11)
|
||||
|
||||
| 依赖 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `KERNEL32` / `USER32` / `GDI32` / `ADVAPI32` / `SHELL32` / `ole32` / `WS2_32` … | 系统 | Win10+ 一律自带 |
|
||||
| `ucrtbase` / `api-ms-win-crt-*`(UCRT) | 系统 | Win10+ 内置,**不需要** VC++ 可再发行包 |
|
||||
| MSVC 运行时(`VCRUNTIME140` / `MSVCP140`) | **无** | `+crt-static` 静态链接,故不需要可再发行包 |
|
||||
| OpenGL(`opengl32`) | 系统 | 只在 GL 渲染路径用;缺失走软渲染 |
|
||||
|
||||
本机(GNU 工具链)实测导入表证据见 §7。
|
||||
|
||||
### Windows ARM64(Win11 on ARM)
|
||||
|
||||
产物形态与门禁跟 x64 一模一样(裸 exe + 静态 CRT + dumpbin 导入表硬门禁),差别只在**怎么编**:
|
||||
CI 在 `windows-latest`(x64 宿主)上交叉编译到 `aarch64-pc-windows-msvc`,产物落在
|
||||
`target/aarch64-pc-windows-msvc/release`(由 `matrix.build_dir` 注入,脚本不硬编码路径)。
|
||||
GitHub 文档(2026)已把 `windows-11-arm` / `windows-11-vs2026-arm` 列为公开仓库**免费**的标准
|
||||
arm64 runner,原生路线可行;本流水线仍选交叉编译,因为①双 Windows 槽位共用同一套已验证镜像与
|
||||
工具链、②M50 的 dumpbin 定位链取的是 `Hostx64/x64/dumpbin.exe`(读 ARM64 PE 的导入表没问题),
|
||||
原生 ARM 宿主上那条链得再补 `Hostarm64` 分支——不为一个槽位放宽刚立的门禁。
|
||||
|
||||
运行时依赖与 x64 同表(UCRT 是 Win11 ARM 的系统组件,`+crt-static` 静态链 CRT,不需要可再发行包)。
|
||||
游戏侧如实说:**启动器原生运行**;Temurin 没有 windows-aarch64 产物,Microsoft OpenJDK 有该
|
||||
发行版(core 的 `microsoft` 源按 `<os>-<arch>` 拼名,命名已覆盖),但 Minecraft 本体没有
|
||||
Windows ARM64 原生库(Mojang/LWJGL 只发 x64/x86),实际游玩仍走系统 x64 模拟层 + x64 JDK。
|
||||
所以本槽位只承诺「启动器产物」支持,不替 Java/游戏管理做架构级承诺。
|
||||
|
||||
### Linux x64
|
||||
|
||||
| 依赖 | 说明 |
|
||||
|------|------|
|
||||
| glibc ≥ 2.35 | 由 `ubuntu-22.04` 构建决定;更老的发行版请自行重建或等 musl 版 |
|
||||
| `libxkbcommon.so.0` | winit 键盘处理 |
|
||||
| `libfontconfig.so.1` + `libfreetype.so.6` | 文本渲染 |
|
||||
| `libGL.so.1` / `libEGL.so.1` | femtovg GL 渲染;缺失走软渲染 |
|
||||
| `libxcb.so.1` / `libX11.so.6` | X11 后端(Wayland 会话下也需 XWayland 相关库) |
|
||||
| `libwayland-client.so.0` | Wayland 后端 |
|
||||
|
||||
Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
|
||||
运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。
|
||||
|
||||
### Linux arm64(aarch64)
|
||||
|
||||
依赖与 x64 完全同一套(`libxkbcommon` / `fontconfig` / `libGL` / `libxcb` / `libwayland-client`),
|
||||
**glibc 地板也相同**:arm64 槽位跑在原生 `ubuntu-22.04-arm` runner 上,地板 = **2.35**,与 x64
|
||||
用的 `ubuntu-22.04` 一致。选 22.04 而不是 24.04 就是为了这个地板——24.04 的 glibc 更高,会把
|
||||
Debian 12(2.36)、Raspberry Pi OS Bookworm(2.36)这些实际在跑的 ARM 发行版挡在门外。
|
||||
要抬地板(例如将来 ARM 生态整体上移)换 `release.yml` 里那个槽位的 `os` 即可,脚本与产物名都不用动;
|
||||
`ubuntu-24.04-arm` / `ubuntu-26.04-arm` 都是同一家族的标准公开仓库标签(4C/16GB、免费不限量)。
|
||||
|
||||
### macOS 12+
|
||||
|
||||
| 依赖 | 说明 |
|
||||
|------|------|
|
||||
| 系统框架(Cocoa / Metal / IOKit / Security …) | 由操作系统提供,无第三方动态库 |
|
||||
| `libSystem` | 系统 |
|
||||
|
||||
macOS 产物是**裸二进制**而非 `.app` bundle:符合单文件承诺,但没有 Dock 图标、Finder 双击不生效,
|
||||
从终端启动(README 里给了 `xattr -d com.apple.quarantine` 的 Gatekeeper 解法)。
|
||||
签名/公证(需 Apple Developer 账号)与 `.app` 打包是后续工作,不做假承诺。
|
||||
|
||||
## 5. 软渲染兜底(无 GPU / 老驱动 / 远程桌面)
|
||||
|
||||
Slint 默认 `renderer-femtovg`(GL)。两条兜底路径,效果等价,任选其一:
|
||||
|
||||
```bash
|
||||
unlucky-launcher --software-renderer # 启动参数(本仓库解析后设 SLINT_BACKEND)
|
||||
SLINT_BACKEND=winit-software unlucky-launcher # 环境变量(Slint 原生开关)
|
||||
```
|
||||
|
||||
- 参数解析在 `crates/app/src/main.rs` 的 `apply_renderer_flag()`,只认完全相等的 `--software-renderer`。
|
||||
- 软渲染是 CPU 光栅化,无 GPU 也能出图;代价是动画与大窗口重绘更慢(Metro 纯色界面影响有限)。
|
||||
- 验收/无交互桌面场景另有进程内截图通道:`--screenshot <png>`(见 `crates/app/src/screenshot.rs`),
|
||||
它同样能在软渲染下工作,是 CI 与远程机唯一可靠的"真出图"证据。
|
||||
- 发布冒烟(本机实跑,见 §7)就是用 `--software-renderer --screenshot` 组合证明"无 GPU 也能起来"。
|
||||
|
||||
## 6. Cargo 体积优化(release profile)
|
||||
|
||||
`Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[profile.release]
|
||||
opt-level = "z" # 体积优先(启动器不是算力密集;UI 走 Slint 渲染,热路径不在 Rust 侧)
|
||||
lto = "fat" # 全量 LTO:跨 crate 内联+死代码消除,比 thin 再小一截(实测见 §7)
|
||||
codegen-units = 1 # 单 CGU,配合 LTO 拿最大优化面
|
||||
panic = "abort" # 去掉 unwind 表
|
||||
strip = "symbols" # 剥符号表(GNU/MSVC 路径都生效)
|
||||
```
|
||||
|
||||
取舍:`lto = "fat"` + `codegen-units = 1` 让 release 链接明显变慢(本机全量 release 构建 10 分钟量级,
|
||||
见 §7.1),换来的是体积与启动时间。变慢的根源在 `codegen-units = 1`:整个 crate 只切出一个 CGU,
|
||||
LTO 也就只有一个线程可跑(`fat` 本身只管"跨 crate 全量参与",不等于单线程)。
|
||||
本地开发迭代走 `cargo build`(dev profile)不受影响,但 **`ci.yml` 每次 push/PR 都跑
|
||||
`cargo build --workspace --release`**,会实打实吃这份 fat LTO 的链接时间——CI 慢的一部分是 release
|
||||
档构建,不是测试本身。
|
||||
`panic = "abort"` 意味着 panic 不带 backtrace 栈展开——UI 层已用 `Result` 承接错误,
|
||||
`unwrap` 只出现在测试里,这是可接受的取舍。
|
||||
|
||||
## 7. 实测(本机,不是估算)
|
||||
|
||||
构建环境:rustc 1.99.1 stable(host `x86_64-pc-windows-gnu`,MinGW 链接器),
|
||||
`cargo build --release --locked --bin unlucky-launcher --bin ull`,12 核机器(同一时刻另有别的 worker
|
||||
在做全量 debug 构建,属于偏悲观的真实负载)。产物:`target/release/unlucky-launcher.exe`(GUI,交付物)
|
||||
与 `target/release/ull.exe`(CLI)。
|
||||
|
||||
### 7.1 体积(实测字节数,不是估算)
|
||||
|
||||
| release profile | `unlucky-launcher.exe` | `ull.exe` | 冷构建耗时(本机 12 核) |
|
||||
|-----------------|-----------------------|-----------|-----------|
|
||||
| `lto = "thin"`(改前) | 16,366,080 B = 15.61 MiB | 7,572,992 B = 7.22 MiB | 11m52s |
|
||||
| `lto = "fat"`(改后,本仓库采用) | **14,595,584 B = 13.92 MiB** | **6,157,824 B = 5.87 MiB** | 9m27s(增量,依赖已编好) |
|
||||
|
||||
`fat` 相对 `thin` 的实测差:GUI **−1,770,496 B(−10.8%)**,CLI **−1,415,168 B(−18.7%)**。
|
||||
一次全量 LTO 把跨 crate 的死代码与重复实例化一起消掉,收益远超常见印象里的"1~2%";
|
||||
代价是 release 链接更慢——慢在 `codegen-units = 1`(单 CGU → LTO 单线程),不是 `fat` 本身,
|
||||
且只影响发布/验收构建,日常 `cargo build` 走 dev profile 不受影响。
|
||||
|
||||
体积构成(按依赖树判断):Slint 运行时(femtovg + software 双渲染器 + winit/accesskit/托盘)+ rustls/reqwest
|
||||
+ rig/rmcp/axum 是主要来源;`ull` 明显更小是 D18 纪律的直接结果(CLI 零 UI 框架依赖)。
|
||||
|
||||
### 7.2 冒烟(真跑,2026-10-31 夜间 + 11-01 早,本机 Win11 26100)
|
||||
|
||||
| 命令 | 实测结果 |
|
||||
|------|---------|
|
||||
| `ull.exe --version` | 输出 `ull 0.1.0(unlucky-cli)` + `os: Windows 11 · 10.0.26100(x86_64)· 满足最低要求`,exit 0 —— 版本与 workspace `0.1.0` 一致 |
|
||||
| `unlucky-launcher.exe --software-renderer --screenshot … --data-dir …` | exit 0,产出 1180×760 PNG(3,588,298 B);看图确认 Metro 实例页(侧栏/搜索/空态/AI 助手面板)正常渲染 → **软渲染路径真的能出图**,不依赖 GL |
|
||||
| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 目前不解析 `--version`,会直接开窗口。已作为 finding 上报,见 §10-1 |
|
||||
|
||||
### 7.3 打包验证(本机 Windows 槽位真跑)
|
||||
|
||||
```
|
||||
smoke: ull.exe --version -> 0.1.0 (ok)
|
||||
artifact: unlucky-launcher-0.1.0-windows-x64.exe 13.92 MiB sha256=d4954c62…c3570
|
||||
sums: SHA256SUMS.txt
|
||||
$ sha256sum -c SHA256SUMS.txt
|
||||
unlucky-launcher-0.1.0-windows-x64.exe: OK
|
||||
```
|
||||
|
||||
版本守卫也实测过:`--version v9.9.9` 时脚本以 `tag/version mismatch: requested 9.9.9, Cargo.toml says 0.1.0` 失败退出(exit 1)。
|
||||
|
||||
UNIX 归档的可复现性单独取证(本机 Windows 槽位只走裸 exe 分支,tar 路径要另测):
|
||||
reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → **两次 sha256 完全一致**
|
||||
`a99d6e9e727cd7e390434c566a15978451f340858fa89836e2f63790bbc392f8`;成员 `mtime=0`、
|
||||
`uid=gid=0`、`uname/gname` 为空、`mode` 0755(二进制)/ 0644(文本),gzip header 的 mtime 字段是
|
||||
`00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。
|
||||
即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。
|
||||
|
||||
### 7.4 运行时依赖证据(PE 导入表)
|
||||
|
||||
`objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带:
|
||||
`KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/
|
||||
uxtheme/uiautomationcore/opengl32/ws2_32/bcrypt/bcryptprimitives/ntdll` + 一组 `api-ms-win-crt-*`(UCRT API set)。
|
||||
|
||||
**没有** `VCRUNTIME140.dll` / `MSVCP140.dll` 这类 MSVC 可再发行包依赖,也没有 `libwinpthread-1.dll` /
|
||||
`libgcc_s_*.dll` 之类 MinGW 运行时 —— 即"拷走一个 exe 就能跑"(Win10+ 自带 UCRT)。
|
||||
CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `dumpbin -dependents` 把这条变成硬门禁,
|
||||
两侧都 fail、没有降级分支:**出现 VCRUNTIME/MSVCP/ucrtbase 就 fail**,**找不到 dumpbin 同样 fail**
|
||||
(`windows-latest` 自带 VS 工具链,基本找得到;"找不到就跳过"等于让门禁在最需要它的时候失效)。
|
||||
总之不允许"悄悄要用户装可再发行包"。
|
||||
注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。
|
||||
|
||||
### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制)
|
||||
|
||||
本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载
|
||||
`packaging/make_artifacts.py` 调函数 + 走 CLI 全流程,证据见下):
|
||||
|
||||
| 取证项 | 结果 |
|
||||
|--------|------|
|
||||
| 宿主判定注入 | `win32/AMD64`→`windows-x64`、`win32/ARM64`→`windows-arm64`、`linux/x86_64`→`linux-x64`、`linux/aarch64`→`linux-arm64`、`linux/arm64`→`linux-arm64`、`darwin/arm64`→`macos-arm64`、`darwin/x86_64`→`macos-x64`、`freebsd/riscv64`→exit 1 |
|
||||
| 文件头架构识别 | 真 `unlucky-launcher.exe`→`x64`;合成 PE `0xAA64`→`arm64` / `0x8664`→`x64`;ELF `0xB7`→`arm64` / `0x3E`→`x64`;Mach-O `0x0100000C`→`arm64` / `0x01000007`→`x64`;垃圾字节→`None`(拒绝出包) |
|
||||
| `windows-arm64` stub 全流程 | 把真 exe 的 PE 机器码改成 ARM64 当 stub:`arch: … -> arm64 (ok for windows-arm64)`、`smoke: skipped (host is windows-x64, artifact is windows-arm64)`、产物 `unlucky-launcher-0.1.0-windows-arm64.exe` 14.01 MiB,`SHA256SUMS.txt` 行名一致 |
|
||||
| 架构守卫真拦 | x64 二进制冒充 `--slot windows-arm64` → exit 1:`is a x64 binary but slot windows-arm64 says arm64` |
|
||||
| `linux-arm64` stub 归档 | 成员 `unlucky-launcher-0.1.0-linux-arm64/{LICENSE,NOTICE,README.md,unlucky-launcher}`,权限 0644/0755、`uid=gid=0`、mtime 0、gzip 头 mtime 字段 `00 00 00 00`;连调两次 sha256 完全一致(`f949d0f5…`)→ 可复现规则与 x64 同源 |
|
||||
| `windows-x64` 原生回归 | `arch: … -> x64`、`smoke: ull.exe --version -> 0.1.0 (ok)`、产物 14.01 MiB、`sha256sum -c SHA256SUMS.txt` OK(这一次用的是当前工作树的二进制,与 §7.3 那次不是同一次构建,sha256 自然不同) |
|
||||
|
||||
**没在本地验证、只能由 CI 补的**:`windows-arm64` 的交叉链接与真实 ARM64 导入表、`linux-arm64` 在
|
||||
原生 arm64 runner 上的真跑。本机连 `raw.githubusercontent.com` 都解析不了,所以 runner 镜像里到底装没装
|
||||
ARM64 的 MSVC 库**没法在这里查证**——`release.yml` 为此加了一道链接前的体检(查 `libcmt.lib` 与
|
||||
`um/arm64/kernel32.lib`,缺了直接给"加 `VC.Tools.ARM64` 组件或改原生 runner"的提示),首跑即见分晓。
|
||||
merge 后由 tower 重指 tag 重跑流水线,两槽位进 `SHA256SUMS.txt` 即算闭环。
|
||||
|
||||
## 8. 发行合规
|
||||
|
||||
- `LICENSE`(Apache-2.0 全文)与 `NOTICE`(含 Slint Royalty-Free 2.0 义务说明)随包分发,
|
||||
三份文本在 UNIX 归档内、在 Windows 侧作为并列 Release 资产。
|
||||
- Slint 的 RF 2.0 义务:关于页保留 `AboutSlint` 部件(已在 UI 内),不得移除;
|
||||
选 RF 分支即不触发 GPL 分支义务(见 `deny.toml` 的许可证白名单注释)。
|
||||
- `README.md`(英文主版)随包分发;中文版 `README.zh-CN.md` 留在仓库不进包(避免包体噪音)。
|
||||
- 依赖许可门禁不变:`deny.toml` 白名单(Apache-2.0 / MIT / BSD / Zlib / BSL-1.0 / ISC / CC0 / Unlicense /
|
||||
Unicode-3.0 / CDLA-Permissive-2.0 / Slint-RF-2.0)。发布任务不新增任何依赖。
|
||||
- 发布产物不含游戏本体、不含 JRE、不含任何账号数据;`ull`/GUI 的数据目录在用户侧生成。
|
||||
|
||||
## 9. 支持矩阵(对外口径,与 release-notes 模板逐行一致)
|
||||
|
||||
| 平台 | 级别 | 运行时要求 |
|
||||
|------|------|-----------|
|
||||
| Windows 10/11 x64 | 一等 | 无(UCRT 系统自带) |
|
||||
| Windows 11 ARM64 | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK |
|
||||
| macOS 12+(arm64 / x64) | 一等 | 无第三方库;未签名需去 quarantine |
|
||||
| Linux x64 X11 | 一等 | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb |
|
||||
| Linux x64 Wayland | 支持 | 上述 + wayland-client |
|
||||
| Linux arm64 X11 | 一等 | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb |
|
||||
| Linux arm64 Wayland | 支持 | 上述 + wayland-client |
|
||||
| Windows 8.1/8/7 | 不支持 | 基线已上调(D21) |
|
||||
| Android Termux + X11 | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 |
|
||||
|
||||
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版,Termux 行同样标注
|
||||
experimental / not part of this release matrix)——改一边必须同步另一边,否则标题里的"一致"就是假话。
|
||||
|
||||
两个 arm64 槽位都是**正式发布槽位**(产物进 Release、进 `SHA256SUMS.txt`、release job 的六槽位齐全性
|
||||
检查会拦下缺失),不要跟 Termux 那行混为一谈:Termux 是"源码自建的实验级旁路",Linux arm64 是
|
||||
"原生 runner 编、原生跑"的一等产物。
|
||||
|
||||
## 10. 已知边界 / 后续
|
||||
|
||||
1. **GUI 无 `--version`**:`unlucky-launcher.exe` 目前只解析 `--software-renderer` / `--screenshot*` /
|
||||
`--data-dir` / `--mock`,`--version` 会当成普通参数忽略并直接开窗口。版本自证目前靠:
|
||||
①产物名里的 `<ver>` ②`ull --version`(同 workspace 版本)③tag 交叉校验。
|
||||
加一个 `--version` 分支属于 `crates/app` 范围,已作为 finding 上报,不在本 mission 内改。
|
||||
2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号),
|
||||
当前以 SHA256SUMS + Release 说明作为完整性参考。
|
||||
3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。
|
||||
4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但 Slint 在 musl 上需额外验证,未纳入本矩阵。
|
||||
5. **`ull` CLI 未随包**:本次矩阵只发 GUI 单文件(用户核心卖点是启动器本体);headless 用户可从
|
||||
源码 `cargo build --release --bin ull`,或在后续把 `ull` 加进同一矩阵(脚本已按槽位参数化,改动很小)。
|
||||
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行
|
||||
`warning: linker stderr: corrupt .drectve at end of def file`,来自 MinGW `ld` 对 `.drectve` 段的
|
||||
老式抱怨,不影响产物(体积/导入表/冒烟均已实测正常);CI 的 MSVC 槽位不会出现这条。
|
||||
7. **两个 arm64 槽位本机跑不了**:本机是 x64 Windows,既没有 ARM 机器也没有 MSVC 的 ARM64 链接库
|
||||
组合,`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**:前者靠 `dumpbin -dependents`
|
||||
的静态 CRT 门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟。
|
||||
本机能做且已做的是脚本层取证(见 §7.5):宿主判定注入(`win32/ARM64`、`linux/aarch64` 等全槽位)、
|
||||
可执行文件头架构识别(真 x64 exe + 合成 PE/ELF/Mach-O 头)、以及把真 exe 的 PE 机器码改成 ARM64
|
||||
当 stub 走完命名/校验/归档全流程 —— 不假装能在 x64 上执行 arm64 二进制。
|
||||
|
||||
## 11. 文件清单
|
||||
|
||||
| 文件 | 作用 | 行数上限 |
|
||||
|------|------|---------|
|
||||
| `packaging/README.md` | 本文:矩阵/依赖/版本/合规契约 | ≤400 |
|
||||
| `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 |
|
||||
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
||||
| `.github/workflows/release.yml` | tag 触发 → 矩阵构建 → Release | ≤400 |
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the single-file release artifacts for the current platform.
|
||||
|
||||
Release matrix (see `packaging/README.md` for the full contract):
|
||||
|
||||
Windows x64 unlucky-launcher-<ver>-windows-x64.exe (bare exe, no zip)
|
||||
Windows arm64 unlucky-launcher-<ver>-windows-arm64.exe (bare exe, cross-built)
|
||||
Linux x64 unlucky-launcher-<ver>-linux-x64.tar.gz
|
||||
Linux arm64 unlucky-launcher-<ver>-linux-arm64.tar.gz
|
||||
macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz
|
||||
macOS x64 unlucky-launcher-<ver>-macos-x64.tar.gz
|
||||
all SHA256SUMS.txt
|
||||
|
||||
`--version` is taken from the cargo workspace so a tag can never drift away from
|
||||
`Cargo.toml`; pass `--version` to cross-check a tag against it (mismatch is fatal).
|
||||
The archives carry the launcher binary plus LICENSE / NOTICE / README.md, which is
|
||||
what the license obligations ask for; Windows ships the bare `.exe` (the single-file
|
||||
selling point) and the same three documents are attached to the Release next to it.
|
||||
|
||||
Slot and build directory are overridable because a cross build's binaries live in
|
||||
`target/<triple>/release` and the build host (x64) is not the artifact (arm64): CI
|
||||
passes both from its matrix, and the executable header is read back so a binary can
|
||||
never be shipped under a slot name it was not built for.
|
||||
|
||||
Usage:
|
||||
cargo build --release --locked
|
||||
python3 packaging/make_artifacts.py [--version v0.1.0] [--out dist] [--no-smoke]
|
||||
# cross build (arm64 exe from an x64 host):
|
||||
python3 packaging/make_artifacts.py --slot windows-arm64 \\
|
||||
--build-dir target/aarch64-pc-windows-msvc/release
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
LAUNCHER_BIN = "unlucky-launcher"
|
||||
CLI_BIN = "ull"
|
||||
# 随包分发的合规文本(仓库根目录);英文主版 README 是对外分发口径。
|
||||
COMPLIANCE_DOCS = ("LICENSE", "NOTICE", "README.md")
|
||||
SUMS_NAME = "SHA256SUMS.txt"
|
||||
DEFAULT_BUILD_DIR = "target/release"
|
||||
|
||||
# 发布矩阵:槽位 -> (OS 家族, 架构, 产物形态)。键名即产物文件名里的槽位段,
|
||||
# 单一真相源是 packaging/README.md §1;未知槽位一律拒绝,不出"凑合能跑"的产物。
|
||||
SLOTS = {
|
||||
"windows-x64": ("windows", "x64", "exe"),
|
||||
"windows-arm64": ("windows", "arm64", "exe"),
|
||||
"linux-x64": ("linux", "x64", "tar"),
|
||||
"linux-arm64": ("linux", "arm64", "tar"),
|
||||
"macos-arm64": ("macos", "arm64", "tar"),
|
||||
"macos-x64": ("macos", "x64", "tar"),
|
||||
}
|
||||
# `sys.platform` / `platform.machine()` 的别名归一(aarch64 与 arm64 是同一架构的两种叫法)。
|
||||
OS_FAMILIES = (("win", "windows"), ("linux", "linux"), ("darwin", "macos"))
|
||||
MACHINE_ALIASES = {"amd64": "x64", "x86_64": "x64", "aarch64": "arm64", "arm64": "arm64"}
|
||||
# 可执行文件头里的机器码 -> 架构(PE / ELF / Mach-O)。
|
||||
IMAGE_ARCH = {
|
||||
("pe", 0x8664): "x64",
|
||||
("pe", 0xAA64): "arm64",
|
||||
("elf", 0x3E): "x64",
|
||||
("elf", 0xB7): "arm64",
|
||||
("macho", 0x01000007): "x64",
|
||||
("macho", 0x0100000C): "arm64",
|
||||
}
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def fail(message: str) -> NoReturn:
|
||||
print(f"error: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def workspace_version() -> str:
|
||||
"""Read `[workspace.package] version` through cargo itself (single source of truth)."""
|
||||
try:
|
||||
raw = subprocess.run(
|
||||
["cargo", "metadata", "--no-deps", "--format-version", "1"],
|
||||
cwd=REPO_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
# Windows 默认 GBK:cargo 的 package description 是中文,不显式指定会解码失败。
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).stdout
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
fail(f"cargo metadata failed: {exc}")
|
||||
packages = json.loads(raw)["packages"]
|
||||
versions = {pkg["version"] for pkg in packages}
|
||||
if len(versions) != 1:
|
||||
fail(f"workspace members disagree on version: {sorted(versions)}")
|
||||
return versions.pop()
|
||||
|
||||
|
||||
def host_slot(os_name: str | None = None, machine: str | None = None) -> str:
|
||||
"""Map the build host to a matrix slot.
|
||||
|
||||
Both arguments are injectable so the mapping can be exercised for a host we do not
|
||||
own (no ARM machine here); they default to the real ones.
|
||||
"""
|
||||
os_name = (sys.platform if os_name is None else os_name).lower()
|
||||
raw_machine = platform.machine() if machine is None else machine
|
||||
arch = MACHINE_ALIASES.get(raw_machine.lower())
|
||||
family = next((fam for prefix, fam in OS_FAMILIES if os_name.startswith(prefix)), None)
|
||||
for slot, (slot_os, slot_arch, _) in SLOTS.items():
|
||||
if (slot_os, slot_arch) == (family, arch):
|
||||
return slot
|
||||
# 报错带**原始**取值而不是归一后的 None:`riscv64` 这类不认识的架构得在诊断里看得见。
|
||||
fail(f"unsupported build host {os_name}/{raw_machine}; matrix is {' / '.join(SLOTS)}")
|
||||
|
||||
|
||||
def binary(name: str, build_dir: Path, family: str) -> Path:
|
||||
path = build_dir / (f"{name}.exe" if family == "windows" else name)
|
||||
if not path.is_file():
|
||||
fail(f"missing {path}; run `cargo build --release --locked` first")
|
||||
return path
|
||||
|
||||
|
||||
def image_arch(path: Path) -> str | None:
|
||||
"""Architecture stamped into the executable header, or `None` if unreadable."""
|
||||
with path.open("rb") as handle:
|
||||
head = handle.read(0x40)
|
||||
if head[:4] == b"\x7fELF":
|
||||
order = "little" if head[5] == 1 else "big"
|
||||
return IMAGE_ARCH.get(("elf", int.from_bytes(head[18:20], order)))
|
||||
if head[:2] == b"MZ":
|
||||
handle.seek(int.from_bytes(head[0x3C:0x40], "little"))
|
||||
pe = handle.read(6)
|
||||
if pe[:4] == b"PE\0\0":
|
||||
return IMAGE_ARCH.get(("pe", int.from_bytes(pe[4:6], "little")))
|
||||
return None
|
||||
if head[:4] == b"\xcf\xfa\xed\xfe": # thin 64-bit little-endian Mach-O
|
||||
return IMAGE_ARCH.get(("macho", int.from_bytes(head[4:8], "little")))
|
||||
return None
|
||||
|
||||
|
||||
def verify_arch(path: Path, slot: str) -> None:
|
||||
"""Prove the binary really is built for the slot it is about to be named after."""
|
||||
want = SLOTS[slot][1]
|
||||
got = image_arch(path)
|
||||
if got is None:
|
||||
fail(f"cannot read the machine type of {path}; refusing to ship an unverified binary")
|
||||
if got != want:
|
||||
fail(f"{path} is a {got} binary but slot {slot} says {want}")
|
||||
print(f"arch: {path.name} -> {got} (ok for {slot})")
|
||||
|
||||
|
||||
def smoke_version(cli: Path, version: str) -> None:
|
||||
"""`ull --version` must report the workspace version (guards tag/version drift)."""
|
||||
proc = subprocess.run(
|
||||
[str(cli), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
if proc.returncode != 0:
|
||||
fail(f"`{cli.name} --version` exited {proc.returncode}: {out.strip()}")
|
||||
if version not in out:
|
||||
fail(f"`{cli.name} --version` does not report {version}: {out.strip()}")
|
||||
print(f"smoke: {cli.name} --version -> {version} (ok)")
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_sums(out_dir: Path, assets: list[Path]) -> Path:
|
||||
sums = out_dir / SUMS_NAME
|
||||
lines = [f"{sha256(path)} {path.name}" for path in sorted(assets)]
|
||||
sums.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||
return sums
|
||||
|
||||
|
||||
def stage_archive(out_dir: Path, tag: str, version: str, launcher: Path) -> Path:
|
||||
"""tar.gz with one top-level dir holding the binary + compliance docs."""
|
||||
stem = f"unlucky-launcher-{version}-{tag}"
|
||||
stage = out_dir / stem
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
stage.mkdir(parents=True)
|
||||
shutil.copy2(launcher, stage / launcher.name)
|
||||
for doc in COMPLIANCE_DOCS:
|
||||
source = REPO_ROOT / doc
|
||||
if not source.is_file():
|
||||
fail(f"compliance file {source} is missing")
|
||||
shutil.copy2(source, stage / doc)
|
||||
# tar 内的 mtime/uid/mode 与 gzip 头的 mtime 全部归零 → 同源构建的 sha256 与打包时刻无关。
|
||||
archive = out_dir / f"{stem}.tar.gz"
|
||||
with archive.open("wb") as raw, gzip.GzipFile(
|
||||
fileobj=raw, mode="wb", compresslevel=9, mtime=0
|
||||
) as gz, tarfile.open(fileobj=gz, mode="w") as tar:
|
||||
for entry in sorted(stage.iterdir()):
|
||||
info = tar.gettarinfo(str(entry), arcname=f"{stem}/{entry.name}")
|
||||
info.mtime = 0
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ""
|
||||
info.mode = 0o755 if entry.name == launcher.name else 0o644
|
||||
with entry.open("rb") as handle:
|
||||
tar.addfile(info, handle)
|
||||
shutil.rmtree(stage)
|
||||
return archive
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", help="expected version (a leading 'v' is stripped); cross-checks Cargo.toml")
|
||||
parser.add_argument("--out", default="dist", help="output directory (default: dist/)")
|
||||
parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host")
|
||||
parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})")
|
||||
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
|
||||
args = parser.parse_args()
|
||||
|
||||
version = workspace_version()
|
||||
if args.version:
|
||||
expected = args.version[1:] if args.version.startswith("v") else args.version
|
||||
if expected != version:
|
||||
fail(f"tag/version mismatch: requested {expected}, Cargo.toml says {version}")
|
||||
native = host_slot()
|
||||
slot = args.slot or native
|
||||
family, _, kind = SLOTS[slot]
|
||||
build_dir = Path(args.build_dir)
|
||||
if not build_dir.is_absolute():
|
||||
build_dir = REPO_ROOT / build_dir
|
||||
launcher = binary(LAUNCHER_BIN, build_dir, family)
|
||||
verify_arch(launcher, slot)
|
||||
# 跨槽位产物(宿主 x64 / 目标 arm64)在本机根本执行不了,冒烟只能等 CI 真机;
|
||||
# 拿"跳过了"当通过是假证据,所以把跳过原因原样打出来。
|
||||
if args.no_smoke or slot != native:
|
||||
reason = "--no-smoke" if args.no_smoke else f"host is {native}, artifact is {slot}"
|
||||
print(f"smoke: skipped ({reason})")
|
||||
else:
|
||||
smoke_version(binary(CLI_BIN, build_dir, family), version)
|
||||
|
||||
out_dir = Path(args.out)
|
||||
if not out_dir.is_absolute():
|
||||
out_dir = REPO_ROOT / out_dir
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if kind == "exe":
|
||||
asset = out_dir / f"unlucky-launcher-{version}-{slot}.exe"
|
||||
shutil.copy2(launcher, asset)
|
||||
else:
|
||||
asset = stage_archive(out_dir, slot, version, launcher)
|
||||
|
||||
sums = write_sums(out_dir, [asset])
|
||||
size_mb = asset.stat().st_size / (1024 * 1024)
|
||||
print(f"artifact: {asset.name} {size_mb:.2f} MiB sha256={sha256(asset)}")
|
||||
print(f"sums: {sums.name}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,102 @@
|
||||
# Unlucky Launcher {{VERSION}}
|
||||
|
||||
Rust-native **single-file** Minecraft launcher: Slint + Metro UI, built-in AI agent
|
||||
(chat / config edits / crash diagnosis) and an MCP server other agent tools can attach to.
|
||||
No WebView, no bundled runtime, no JRE download needed to start the launcher itself.
|
||||
|
||||
## Downloads
|
||||
|
||||
| Platform | Asset | Notes |
|
||||
| --- | --- | --- |
|
||||
| Windows 10/11 x64 | `unlucky-launcher-{{VERSION}}-windows-x64.exe` | single exe, double-click to run |
|
||||
| Windows 11 ARM64 | `unlucky-launcher-{{VERSION}}-windows-arm64.exe` | single exe, runs natively on ARM64 Windows |
|
||||
| Linux x64 (X11/Wayland) | `unlucky-launcher-{{VERSION}}-linux-x64.tar.gz` | single binary inside, no installer |
|
||||
| Linux arm64 (X11/Wayland) | `unlucky-launcher-{{VERSION}}-linux-arm64.tar.gz` | single binary inside; needs glibc 2.35+ |
|
||||
| macOS 12+ Apple Silicon | `unlucky-launcher-{{VERSION}}-macos-arm64.tar.gz` | single binary inside, no installer |
|
||||
| macOS 12+ Intel | `unlucky-launcher-{{VERSION}}-macos-x64.tar.gz` | single binary inside, no installer |
|
||||
|
||||
`LICENSE`, `NOTICE` and `README.md` ship in every archive; on Windows they are attached to
|
||||
this release next to the `.exe` (a lone exe cannot carry them), so the license terms travel
|
||||
with the download on every platform.
|
||||
|
||||
## Support matrix
|
||||
|
||||
| Platform | Level | Runtime requirements |
|
||||
| --- | --- | --- |
|
||||
| Windows 10/11 x64 | first class | nothing beyond the OS (UCRT is part of Win10+) |
|
||||
| Windows 11 ARM64 | first class (launcher artifact) | nothing extra; launcher runs natively, the game itself runs through the OS x64 emulation layer with an x64 JDK |
|
||||
| macOS 12+ (arm64 / x64) | first class | nothing; unsigned build, see Gatekeeper note |
|
||||
| Linux x64, X11 | first class | `libxkbcommon`, `fontconfig`, `libGL` (or software renderer), `libxcb` |
|
||||
| Linux x64, Wayland | supported | `wayland-client`, `libxkbcommon`, plus the X11 set for XWayland apps |
|
||||
| Linux arm64, X11 | first class | glibc 2.35+ (built on `ubuntu-22.04-arm`, same floor as x64), `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` |
|
||||
| Linux arm64, Wayland | supported | the above plus `wayland-client` |
|
||||
| Windows 8.1/8/7 | unsupported | baseline was raised to Win10 (decision D21) |
|
||||
| Android — Termux + X11 | experimental, not promised | CLI only (build from source), GUI unverified; not part of this release matrix |
|
||||
|
||||
Both arm64 rows are **formally released slots** (the assets ship in this release and in
|
||||
`SHA256SUMS.txt`), not the experimental Termux path: Linux arm64 is built and smoke-tested on a
|
||||
native ARM64 runner, Windows ARM64 is cross-built with the same static-CRT gate as x64.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows** — put the `.exe` anywhere and run it (pick `-windows-arm64.exe` on ARM64 Windows,
|
||||
`-windows-x64.exe` everywhere else). Portable data lives next to the exe when a
|
||||
`portable.marker` file is present, otherwise under `%APPDATA%`.
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
tar -xzf unlucky-launcher-{{VERSION}}-linux-x64.tar.gz # use linux-arm64 on aarch64
|
||||
cd unlucky-launcher-{{VERSION}}-linux-x64 # ditto
|
||||
chmod +x unlucky-launcher && ./unlucky-launcher
|
||||
```
|
||||
|
||||
**macOS**
|
||||
|
||||
```bash
|
||||
tar -xzf unlucky-launcher-{{VERSION}}-macos-arm64.tar.gz
|
||||
cd unlucky-launcher-{{VERSION}}-macos-arm64
|
||||
xattr -d com.apple.quarantine unlucky-launcher # unsigned build, Gatekeeper quarantine
|
||||
chmod +x unlucky-launcher && ./unlucky-launcher
|
||||
```
|
||||
|
||||
## No GPU? Software renderer
|
||||
|
||||
The UI prefers OpenGL (femtovg) and falls back to Slint's CPU renderer when no usable GL
|
||||
driver is present. Force it on any machine with:
|
||||
|
||||
```bash
|
||||
unlucky-launcher --software-renderer # or: SLINT_BACKEND=winit-software unlucky-launcher
|
||||
```
|
||||
|
||||
## Verify the download
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
sha256sum -c SHA256SUMS.txt --ignore-missing
|
||||
# Windows (PowerShell)
|
||||
Get-FileHash .\unlucky-launcher-{{VERSION}}-windows-x64.exe -Algorithm SHA256
|
||||
```
|
||||
|
||||
## Checksums
|
||||
|
||||
```
|
||||
{{SUMS}}
|
||||
```
|
||||
|
||||
## Known limitations of this build
|
||||
|
||||
- Microsoft (MSA) account login depends on a Mojang app-id whitelist that is still pending;
|
||||
until it is granted, use offline accounts or authlib-injector (the 403 is reported with a
|
||||
readable diagnostic instead of a silent failure).
|
||||
- Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn
|
||||
on first launch. Checksums above are the integrity reference for now.
|
||||
- macOS ships the bare binary (single-file promise), not a `.app` bundle: no Dock icon or
|
||||
Finder double-click — launch it from a terminal.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0. Third-party notices (including Slint's Royalty-Free 2.0 track) are in `NOTICE`.
|
||||
Unlucky Launcher is not affiliated with Mojang or Microsoft; it does not ship game files.
|
||||
|
||||
Made with [Slint](https://slint.dev) — Slint Royalty-Free 2.0 track, unmodified.
|
||||
Reference in new issue
Block a user